A ranked list of bars with the name and number on top of the bar, so both stay readable at any width. Rows can be links or buttons, a long list folds to its top few, and every value is real text.
Top pages this week
- /pricing+8%32%12,840Number 1
- /docs/getting-started+3%23%9,310Number 2
- /blog/launch-week+41%18%7,420Number 3
- /components/data-table-2%13%5,180Number 4
- /changelog+1%10%3,960Number 5
"use client"
import * as React from "react"
import { FileText } from "lucide-react"
import { BarList } from "@/components/ballmac/bar-list"
const pages = [
{ id: "1", label: "/pricing", value: 12840, hint: "+8%", href: "#pricing" },
{ id: "2", label: "/docs/getting-started", value: 9310, hint: "+3%", href: "#docs" },
{ id: "3", label: "/blog/launch-week", value: 7420, hint: "+41%", href: "#blog" },
{ id: "4", label: "/components/data-table", value: 5180, hint: "-2%", href: "#table" },
{ id: "5", label: "/changelog", value: 3960, hint: "+1%", href: "#changelog" },
{ id: "6", label: "/about", value: 1210, hint: "-9%", href: "#about" },
{ id: "7", label: "/contact", value: 640, hint: "0%", href: "#contact" },
]
export default function BarListDemo() {
return (
<div className="w-full max-w-md">
<BarList title="Top pages this week" items={pages.map((p) => ({ ...p, icon: <FileText className="text-muted-foreground" /> }))} limit={5} showShare />
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/bar-listInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/bar-list.tsx// Ballmac UI: Bar List. https://ui.ballmac.com/components/bar-list "use client" import * as React from "react" import { ChevronDown, ExternalLink } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type BarListItem = { id: string label: string value: number /** Makes the label a link. */ href?: string /** A small icon or logo before the label. */ icon?: React.ReactNode /** A short note after the value, for example "+12%". */ hint?: string } type BarListProps = Omit<React.ComponentProps<"div">, "onSelect" | "title"> & { items: BarListItem[] /** Heading above the list. */ title?: string /** Formats each value. Defaults to a grouped number. */ format?: (value: number) => string /** Shows only this many items, with a button to show the rest. */ limit?: number /** Sorts by value, largest first. Defaults to true. */ sort?: boolean /** Shows each item's share of the total as a percent. */ showShare?: boolean /** Makes rows buttons. Called with the item id. */ onSelect?: (id: string) => void /** The id of the selected row, when `onSelect` is used. */ selected?: string | null } const nf = new Intl.NumberFormat("en-US") /** * A ranked list of bars: the bar sits behind the label so the name and the number stay readable at any width, rows can be links or buttons, and a long list folds * to its top few. The values are text, so nothing depends on the bars. */ function BarList({ items, title, format = (v) => nf.format(v), limit, sort = true, showShare = false, onSelect, selected, className, ...props }: BarListProps) { const msg = useMessages() const id = React.useId() const [open, setOpen] = React.useState(false) const ordered = React.useMemo(() => (sort ? [...items].sort((a, b) => b.value - a.value) : items), [items, sort]) const max = Math.max(1, ...ordered.map((i) => i.value)) const total = ordered.reduce((s, i) => s + i.value, 0) const shown = limit && !open ? ordered.slice(0, limit) : ordered const hidden = ordered.length - shown.length return ( <div data-slot="bar-list" className={cn("grid min-w-0 gap-3", className)} {...props}> {title && <h3 id={`${id}-t`} className="text-sm font-medium">{title}</h3>} <ol aria-labelledby={title ? `${id}-t` : undefined} className="grid grid-cols-[minmax(0,1fr)] gap-1.5"> {shown.map((it, i) => { const pct = Math.max(2, (it.value / max) * 100) const on = selected === it.id const label = ( <span className="flex min-w-0 items-center gap-2"> {it.icon && <span aria-hidden="true" className="flex size-4 shrink-0 items-center justify-center [&>svg]:size-4">{it.icon}</span>} <span className="truncate">{it.label}</span> {it.href && <ExternalLink aria-hidden="true" className="size-3 shrink-0 text-muted-foreground rtl:-scale-x-100" />} </span> ) const row = "relative flex min-h-9 min-w-0 items-center justify-between gap-3 overflow-hidden rounded-md px-3 text-sm outline-none" const bar = ( <span aria-hidden="true" className={cn("absolute inset-y-0 start-0 rounded-md transition-[width] duration-500 ease-out motion-reduce:transition-none", on ? "bg-foreground/20" : "bg-muted")} style={{ width: `${pct}%` }} /> ) const value = ( <span className="relative flex shrink-0 items-baseline gap-2 tabular-nums"> {it.hint && <span className="text-xs text-muted-foreground">{it.hint}</span>} {showShare && total > 0 && <span className="text-xs text-muted-foreground">{msg("bar-list.share", "{percent}%", { percent: Math.round((it.value / total) * 100) })}</span>} <span className="font-medium">{format(it.value)}</span> </span> ) const content = ( <> {bar} <span className="relative min-w-0">{label}</span> {value} </> ) return ( <li key={it.id} className="min-w-0"> {it.href ? ( <a href={it.href} className={cn(row, "focus-visible:ring-[3px] focus-visible:ring-ring/50 hover:[&>span:first-child]:bg-foreground/15")}> {content} </a> ) : onSelect ? ( <button type="button" aria-pressed={on} onClick={() => onSelect(it.id)} className={cn(row, "w-full text-start focus-visible:ring-[3px] focus-visible:ring-ring/50")}> {content} </button> ) : ( <div className={row}>{content}</div> )} <span className="sr-only">{msg("bar-list.rank", "Number {rank}", { rank: i + 1 })}</span> </li> ) })} </ol> {limit && ordered.length > limit && ( <button type="button" aria-expanded={open} onClick={() => setOpen((v) => !v)} className="inline-flex h-8 w-fit items-center gap-1.5 rounded-md px-2 text-xs font-medium text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50"> <ChevronDown aria-hidden="true" className={cn("size-3.5 transition-transform motion-reduce:transition-none", open && "rotate-180")} /> {open ? msg("bar-list.less", "Show fewer") : msg("bar-list.more", "Show {count} more", { count: hidden })} </button> )} </div> ) } export { BarList, type BarListItem, type BarListProps }Update the import paths to match your project setup.
Usage
import { BarList } from "@/components/ballmac/bar-list"The full example is in the Code tab above.
Examples
Rows as filter buttons
Visitors by country
- Number 1
- Number 2
- Number 3
- Number 4
Filtering by: France
"use client"
import * as React from "react"
import { BarList } from "@/components/ballmac/bar-list"
const items = [
{ id: "de", label: "Germany", value: 4210 },
{ id: "fr", label: "France", value: 3180 },
{ id: "es", label: "Spain", value: 1990 },
{ id: "it", label: "Italy", value: 1240 },
]
export default function BarListSelect() {
const [sel, setSel] = React.useState<string | null>("fr")
return (
<div className="grid w-full max-w-md gap-3">
<BarList title="Visitors by country" items={items} selected={sel} onSelect={setSel} format={(v) => `${(v / 1000).toFixed(1)}k`} />
<p aria-live="polite" className="text-sm text-muted-foreground">Filtering by: {items.find((i) => i.id === sel)?.label ?? "everything"}</p>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
items* | BarListItem[] | — |
titleHeading above the list. | string | — |
formatFormats each value. Defaults to a grouped number. | (value: number) => string | (v) => nf.format(v) |
limitShows only this many items, with a button to show the rest. | number | — |
sortSorts by value, largest first. Defaults to true. | boolean | true |
showShareShows each item's share of the total as a percent. | boolean | false |
onSelectMakes rows buttons. Called with the item id. | (id: string) => void | — |
selectedThe id of the selected row, when `onSelect` is used. | string | null | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| List | An ordered list; each row also says 'Number n' for screen readers |
| Rows | Links and buttons are native; buttons have pressed state |
| Fold | A button with aria-expanded |
| Values | Written as text, with the share as a percent if asked, so nothing depends on bar length |
Use with AI
items is [{ id, label, value, href?, icon?, hint? }]. Sorted by value unless sort is false. title, format(value), showShare (percent of total), limit (folds the rest behind a Show N more button). href makes a row a link; onSelect(id) with selected makes rows toggle buttons. The bar is decoration behind real text. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Bar List (@ballmac/bar-list) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Top pages, sources, countries, products or errors in an analytics view
- A leaderboard where the label matters more than the shape
Not for
- Comparing a few values precisely over time; use chart-bar
- Part-to-whole with many parts; use treemap or split-bar
Registry JSON: https://ui.ballmac.com/r/bar-list.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Split Bar
One bar cut into shares with a legend that says the same in numbers. Pointing at a legend row lights its slice and dims the rest, and choosing it keeps it lit.
Funnel Chart
A funnel as bars that narrow step by step. Between steps it says how many carried on and how many dropped out, flags the biggest drop, and ends with the overall result as a sentence.
Stat Card
A metric card with a clear comparison trend, optional visual, and readable value hierarchy.
KPI Row
A compact, semantic row of key metrics that adapts from one to four columns.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.