ranked things as bars: top pages, referrers, a funnel, each a share of the largest
npx shadcn add @ag/bar-listAlready installed? Preview an update
npx shadcn add @ag/bar-list --dry-runYour installed source stays unchanged until you update it. How updates work
Latest changes · 2026-10-12
- New:
BarList, ranked things as horizontal bars (items:label,value, optionalhrefandicon;labelnames the list). Each bar is its value's share ofmax, the largest value by default; the values sit in their own column in mono with tabular figures, throughformat. The order is yours. A zero is no bar; a negative value or one pastmaxthrows. Long labels are cut with an ellipsis and whole in their title;emptysays what an empty list says. Renders in a server component,formatincluded.
also called bar chart, leaderboard, top list
shelves data & status, charts
the demo above, verbatim
"use client"
import { Globe, Link2, Mail, MessageCircle, Search } from "lucide-react"
import { Sample } from "@/app/samples"
import { BarList } from "@/registry/base-nova/ui/bar-list"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { Panel } from "@/registry/base-nova/ui/panel"
// #region pages
/** The month's most read pages, ranked: the first runs the whole width. */
const PAGES = [
{ label: "/", value: 18_204, href: "#pages" },
{ label: "/pricing", value: 6_120, href: "#pages" },
{ label: "/blog/the-registry", value: 4_388, href: "#pages" },
{ label: "/docs/install", value: 2_901, href: "#pages" },
{ label: "/changelog", value: 1_045, href: "#pages" },
{ label: "/about", value: 312, href: "#pages" },
]
const views = new Intl.NumberFormat("en-US", { notation: "compact" })
// #endregion
// #region referrers
const REFERRERS = [
{ label: "google.com", value: 3_480, icon: <Search /> },
{ label: "news.ycombinator.com", value: 2_210, icon: <MessageCircle /> },
{ label: "direct", value: 1_640, icon: <Link2 /> },
{ label: "newsletter", value: 702, icon: <Mail /> },
{ label: "other", value: 288, icon: <Globe /> },
]
// #endregion
// #region long
/** Labels longer than their row: cut with an ellipsis, whole under the hand. */
const LONG = [
{
label:
"/blog/2026/10/how-we-cut-cold-starts-by-moving-everything-to-the-edge",
value: 9_120,
},
{
label: "/docs/guides/authentication/oauth/providers/github/troubleshooting",
value: 4_008,
},
{ label: "/a", value: 51 },
]
// #endregion
// #region funnel
/** A funnel keeps its steps' order (no ranking here), each a share of the first. */
const FUNNEL = [
{ label: "visited", value: 12_400 },
{ label: "started trial", value: 1_812 },
{ label: "added a card", value: 403 },
{ label: "paid", value: 288 },
]
const ofVisits = (n: number) =>
`${n.toLocaleString("en-US")} · ${((n / 12_400) * 100).toFixed(1)}%`
// #endregion
// #region plans
/** Shares of a whole the list does not show (every account): max is the whole. */
const PLANS = [
{ label: "free", value: 8_210 },
{ label: "pro", value: 1_120 },
{ label: "team", value: 96 },
{ label: "enterprise", value: 0 },
]
// #endregion
export default function BarListDemo() {
return (
<div className="space-y-8">
<Sample name="pages" glance title="top pages, each a link" with="pages">
<BarList
label="top pages"
items={PAGES}
format={views.format}
className="w-72"
/>
</Sample>
<Sample name="referrers" title="with a mark before each" with="referrers">
<BarList label="referrers" items={REFERRERS} className="w-80" />
</Sample>
<Sample
name="long"
title="labels longer than the row"
description="cut with an ellipsis and whole in the title; the values keep their own column, so every bar is measured against the same width."
with="long"
>
<BarList label="top posts" items={LONG} className="w-72" />
</Sample>
<Sample
name="funnel"
title="a funnel, in its own order"
description="the order is the caller's, never re-sorted. format says each value as a count and its share of the first step."
with="funnel"
>
<BarList
label="trial funnel"
items={FUNNEL}
format={ofVisits}
className="w-96 max-w-full"
/>
</Sample>
<Sample
name="whole"
title="shares of a whole, and zero"
description="max is every account, so no bar runs the full width; enterprise has none yet, and a zero is no bar at all. below it, an empty list."
with="plans"
>
<div className="grid w-72 gap-6">
<BarList label="plans" items={PLANS} max={9_426} />
<BarList label="refund reasons" items={[]} empty="no refunds" />
</div>
</Sample>
<Sample
name="materials"
title="plain and on glass"
description="it draws no surface: the bars are the text's ink at 10% on whatever holds it."
with="referrers"
>
<div className="grid gap-4 lg:grid-cols-2">
<Panel title="referrers" className="w-full max-w-sm">
<BarList label="referrers" items={REFERRERS} />
</Panel>
<MaterialProvider material="glass">
<div className="rounded-xl bg-[url(/lightbox/peaks.jpg)] bg-cover bg-center p-6">
<Panel title="referrers" className="w-full max-w-sm">
<BarList label="referrers" items={REFERRERS} />
</Panel>
</div>
</MaterialProvider>
</div>
</Sample>
</div>
)
}the source, every file an install writes
import type * as React from "react"
import { cn } from "@/lib/utils"
// RANKED THINGS AS BARS: the top pages, referrers, countries, a plan split. A row per
// item, its label over a bar as long as its share of the largest, its value at the
// end in mono with tabular figures, so a column of numbers lines up its digits.
//
// <BarList
// label="top pages"
// items={[{ label: "/pricing", value: 1203, href: "/analytics/pricing" }, …]}
// format={(n) => `${n} views`}
// />
//
// THE ORDER IS THE CALLER'S: a ranking is usually sorted by value, but a funnel's
// steps or a week's days are not, and a list that re-sorted them would say something
// else. Each bar is its value as a share of `max`, by default the largest value: the
// first of a ranking runs the whole width. A zero is no bar at all (a sliver would
// say there was something); a negative value, or one past `max`, throws: a share of a
// whole cannot be either.
//
// The values sit in a column of their own, as wide as the widest one (every row on
// one grid), so the bars are measured against the same width and their lengths
// compare. A label too long for its bar's row is cut with an ellipsis and whole in its
// title. A row is a link when it has `href`, lit under the hand; `icon` sits before
// its label. Nothing to show, `empty` says so.
//
// In the text's colour on whatever holds it: the bars are its ink at 10%. No
// "use client": it renders in a server component as it is, `format` included.
export interface BarListItem {
/** What the row is. Unique in the list. */
label: string
/** Its value, from zero. */
value: number
/** The row is a link to here. */
href?: string
/** A mark before the label (an icon, a flag, a favicon), about 16 px. */
icon?: React.ReactNode
}
const plain = new Intl.NumberFormat("en-US")
export type BarListProps = Omit<React.ComponentProps<"ol">, "children"> & {
items: readonly BarListItem[]
/** What the list is, for a screen reader ("top pages"). */
label: string
/** How a value is said. Default grouped digits ("48,210"). */
format?: (value: number) => string
/** The value a full bar stands for. Default the largest value. */
max?: number
/** What the list says with no items. Default "nothing yet". */
empty?: React.ReactNode
}
/** A row's two cells: the bar with its label over it, the value after it. */
const ROW =
"col-span-2 grid min-h-8 grid-cols-subgrid items-center rounded-md text-[13px]"
export function BarList({
items,
label,
format = plain.format,
max,
empty = "nothing yet",
className,
...props
}: BarListProps) {
const labels = new Set<string>()
for (const item of items) {
if (!(Number.isFinite(item.value) && item.value >= 0))
throw new Error(
`BarList "${label}": "${item.label}" is ${item.value}, and a bar is a share from zero`,
)
if (labels.has(item.label))
throw new Error(`BarList "${label}": two rows are "${item.label}"`)
labels.add(item.label)
}
const whole = max ?? Math.max(0, ...items.map((i) => i.value))
for (const item of items)
if (item.value > whole)
throw new Error(
`BarList "${label}": "${item.label}" is ${item.value}, past max ${whole}`,
)
if (items.length === 0)
return (
<p
data-slot="bar-list-empty"
className={cn("m-0 text-[13px] text-current/55", className)}
>
{empty}
</p>
)
return (
<ol
aria-label={label}
data-slot="bar-list"
className={cn(
"m-0 grid list-none grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1 p-0",
className,
)}
{...props}
>
{items.map((item) => {
const cells = (
<>
<span className="relative flex h-8 min-w-0 items-center">
<span
aria-hidden
data-slot="bar-list-bar"
className="absolute inset-y-0 left-0 w-(--ag-bar-list-share) rounded-md bg-current/10"
style={
{
"--ag-bar-list-share": `${whole === 0 ? 0 : (item.value / whole) * 100}%`,
} as React.CSSProperties
}
/>
<span
data-slot="bar-list-label"
className="relative flex min-w-0 items-center gap-2 px-2.5 [&_svg]:size-4 [&_svg]:shrink-0"
>
{item.icon}
<span title={item.label} className="truncate text-current/90">
{item.label}
</span>
</span>
</span>
<span
data-slot="bar-list-value"
className="pr-1 text-right font-mono text-current/75 text-xs tabular-nums"
>
{format(item.value)}
</span>
</>
)
return (
<li key={item.label} data-slot="bar-list-row" className={ROW}>
{item.href ? (
<a
href={item.href}
className={cn(
ROW,
"text-current outline-none hover:bg-current/5 focus-visible:bg-current/5 focus-visible:-outline-offset-2",
)}
>
{cells}
</a>
) : (
cells
)}
</li>
)
})}
</ol>
)
}