← ui

bar-list

ui

ranked things as bars: top pages, referrers, a funnel, each a share of the largest

npx shadcn add @ag/bar-list
Already installed? Preview an update
npx shadcn add @ag/bar-list --dry-run

Your 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, optional href and icon; label names the list). Each bar is its value's share of max, the largest value by default; the values sit in their own column in mono with tabular figures, through format. The order is yours. A zero is no bar; a negative value or one past max throws. Long labels are cut with an ellipsis and whole in their title; empty says what an empty list says. Renders in a server component, format included.
Updated 2026-10-12 · release notes

also called bar chart, leaderboard, top list

shelves data & status, charts

the demo above, verbatim

bar-list.demo.tsx
"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

bar-list.tsx
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>
  )
}