{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bar-list",
  "type": "registry:ui",
  "title": "Bar list",
  "description": "A ranked list where each row carries a thin bar for its share: top pages, most-used shortcuts, busiest repos. An optional lead, a label, a 4px rail, a mono value and a change in words. The bars line up across rows, and under 28rem the bar moves under the label.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/bar-list.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/bar-list 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Bar list: a ranked list where each row carries a thin bar for its share. Top pages, most-used shortcuts,\n * busiest repos. A sibling of the Meter and the Stat delta in chart.tsx and stat.tsx.\n *\n * - Anatomy: an optional lead (a key cap, an icon or a small avatar, up to 48px wide so two key caps fit), the\n *   label (small, fg, truncates), the bar, the value (small, fg, tabular, end-aligned) and an optional delta\n *   (caption). A row is at least 44px tall (airy) and rows are split by `--litt-line` hairlines.\n * - Bar: the Meter's rail, a 4px rail at the control radius. The track is fg at 12% (the Meter's), the fill is `--litt-fg` (the quiet\n *   default) or the hue's mark tone, and its width is value / max, where max defaults to the largest value. One\n *   hue means one thing in a list (R7): the quantity the bars measure, or, with a hue per row, each row's kind. A bar never takes the accent.\n * - Alignment: one grid for the whole list. Every row is a subgrid of it, so the bars start at the same x and end\n *   at the same x, and the values and deltas line up as columns. The list measures itself with a container query,\n *   not the window: under 28rem the bar moves under the label at the full width of the row, and the lead, the\n *   value and the delta stay on the first line.\n * - Delta: a word and the number (\"Up 15%\"), at the caption size. The word says the direction. The color says\n *   whether it is news: `--litt-success` for good, `--litt-danger` for bad, `--litt-fg-subtle` for neither, with the judgment in\n *   hidden text (\"good news\"), exactly as the Stat does. Never an arrow glyph alone, never a dot.\n * - Rows: a row with an `href` is a link, and a row without one is a button when the list has `onSelect`. Both\n *   fill with fg at 4% on hover, at the control radius, on the instant tier, and with fg at 6% while pressed on a\n *   coarse pointer. Their focus ring is inset (2px `outline-litt-focus`, -2px offset), because the row is flush with its\n *   neighbours. A static row has no hover and no focus stop.\n * - Semantics: an ordered list named by `label`. Each row reads \"Archive, 412 times\" plus the delta's words, built\n *   from the visible text and hidden words. The lead and the bar are hidden from assistive tech: put anything that\n *   matters in the label.\n * - Motion: none. The bars never animate, and hover and press are color changes at `--litt-dur-instant`.\n * - Fun mode (motion/fun.tsx, a trial, off unless a <FunMode> is on): the bars grow in rank order on arrival, a beat\n *   apart, and spring to new lengths; each value counts with its bar and never shows more than it holds. Rows that\n *   trade places slide past each other, the climber over the other, measured from layout offsets. While a row slides it\n *   borrows the list's own background, so the two never show through each other; still, every row is the static row.\n *   Rows are keyed by label in fun mode, so a row keeps its identity when it moves. Reduced motion turns it off.\n */\nimport { useLayoutEffect, useRef, type ReactNode } from \"react\";\nimport { animate, motion, useTransform } from \"motion/react\";\nimport { useFun, useFunValue } from \"../../lib/litt/motion\";\n\n/** The five data hues, in their fixed order (R7). */\nexport type BarListHue = \"blue\" | \"amber\" | \"teal\" | \"violet\" | \"rose\";\n\n/** Whether a change is good news, bad news or neither. It sets the delta's color, and a hidden word for screen readers. */\ntype Tone = \"good\" | \"bad\" | \"neutral\";\n\n/** The hue's mark tone fills the bar. */\nconst HUE: Record<BarListHue, string> = {\n  blue: \"bg-litt-data-blue\",\n  amber: \"bg-litt-data-amber\",\n  teal: \"bg-litt-data-teal\",\n  violet: \"bg-litt-data-violet\",\n  rose: \"bg-litt-data-rose\",\n};\n\nconst TONE: Record<Tone, string> = {\n  good: \"text-litt-success\",\n  bad: \"text-litt-danger\",\n  neutral: \"text-litt-fg-subtle\",\n};\nconst TONE_WORD: Record<Tone, string> = { good: \", good news\", bad: \", bad news\", neutral: \"\" };\n\nconst groupDigits = (n: number) => n.toLocaleString(\"en-GB\", { maximumFractionDigits: 2 });\n\nexport type BarListItem = {\n  /** What the row is, in a few words: \"Archive\". It truncates when the row is narrow. */\n  label: string;\n  /** The row's quantity, zero or more. It sets the bar's width and is the number shown. */\n  value: number;\n  /** A node before the label: a Kbd, a 16px icon or a small avatar, up to 48px wide. It is hidden from screen readers. */\n  lead?: ReactNode;\n  /** Makes the row a link. The row still calls `onSelect` when it is pressed. */\n  href?: string;\n  /** What changed, in words and a number: \"Up 15% on last month\". Never an arrow on its own. */\n  delta?: string;\n  /** Whether the change is good news, bad news or neither. A fall in errors is good, so this is not the direction. */\n  tone?: Tone;\n  /** This row's own data hue, when the rows are kinds and each kind keeps its color on the page (video in violet). It overrides the list's hue. One hue per kind. */\n  hue?: BarListHue;\n};\n\nexport type BarListProps = {\n  /** Names the list for assistive tech and says what it ranks: \"Most-used shortcuts\". */\n  label: string;\n  /** The rows, in the order to show them. Sort them yourself: the list does not. */\n  items: BarListItem[];\n  /** The value that fills a bar. Defaults to the largest value, so the first bar of a sorted list is full. */\n  max?: number;\n  /** Turns a value into the text shown and read: `(v) => \\`${v} s\\``. The default groups thousands. */\n  formatValue?: (value: number) => string;\n  /** What the values count, said after the number to screen readers: \"times\". Pass [\"commit\", \"commits\"] to say the first for exactly 1. Leave it out when the label says it. */\n  unit?: string | [one: string, other: string];\n  /** Colors the bars with one of the five data hues. Leave it out for the quiet foreground default. */\n  hue?: BarListHue;\n  /** Makes rows without an `href` buttons, and is called with the row and its index when any row is pressed. */\n  onSelect?: (item: BarListItem, index: number) => void;\n  /** Extra classes for the list's box, such as a width or a top margin. */\n  className?: string;\n};\n\n/*\n * The columns, named by their start line. 0.5rem edge tracks hold the rows' side padding, so a fill reaches past\n * the text. Cells carry their own air (no column gap), so a column that no row uses is 0px wide.\n */\nconst WIDE = \"grid-cols-[0.5rem_[lead]_auto_[label]_fit-content(14rem)_[bar]_minmax(6rem,1fr)_[value]_auto_[delta]_auto_[end]_0.5rem]\";\nconst NARROW = \"@max-[28rem]:grid-cols-[0.5rem_[lead]_auto_[label]_minmax(0,1fr)_[value]_auto_[delta]_auto_[end]_0.5rem]\";\n\nconst ROW = \"col-span-full grid min-h-litt-control-md grid-cols-subgrid items-center rounded-litt-control py-2 text-left\";\nconst PRESS =\n  \"w-full cursor-pointer [-webkit-tap-highlight-color:transparent] transition-[background-color] duration-(--litt-dur-instant) ease-litt \" +\n  \"hover:bg-[color-mix(in_oklab,var(--litt-fg)_4%,transparent)] in-[[data-material=flat]]:hover:bg-litt-surface-3 pointer-coarse:active:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] \" +\n  \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-litt-focus\";\n\nfunction Row({\n  item,\n  index,\n  share,\n  fill,\n  shown,\n  format,\n  said,\n  onSelect,\n  register,\n}: {\n  item: BarListItem;\n  index: number;\n  share: number;\n  fill: string;\n  shown: string;\n  format: (value: number) => string;\n  said: string;\n  onSelect?: (item: BarListItem, index: number) => void;\n  register?: (el: HTMLLIElement | null) => void;\n}) {\n  const tone = item.tone ?? \"neutral\";\n  /* Fun mode: the bar grows in rank order and springs to new lengths, the value counting with it. */\n  const fun = useFun();\n  const grow = useFunValue(share, { count: item.value, delay: index * (fun?.wave ?? 0) * 5 });\n  const slide = useTransform(grow.value, (r) => `${r - 100}%`);\n  const value = fun && grow.moving ? format(Math.round(grow.shown)) : shown;\n  const press = () => onSelect?.(item, index);\n  const cells = (\n    <>\n      {item.lead != null && (\n        <span aria-hidden=\"true\" className=\"col-start-[lead] row-start-1 pr-3\">\n          <span className=\"flex max-w-12 items-center gap-1\">{item.lead}</span>\n        </span>\n      )}\n      <span className=\"col-start-[label] row-start-1 block min-w-0 truncate text-litt-small text-litt-fg\">\n        {item.label}\n        <span className=\"sr-only\">, </span>\n      </span>\n      <span\n        aria-hidden=\"true\"\n        className=\"col-start-[bar] row-start-1 px-4 @max-[28rem]:col-start-[label] @max-[28rem]:col-end-[end] @max-[28rem]:row-start-2 @max-[28rem]:px-0 @max-[28rem]:pt-1\"\n      >\n        <span className=\"block h-1 overflow-hidden rounded-litt-control bg-[color-mix(in_oklab,var(--litt-fg)_12%,transparent)] in-[[data-material=flat]]:bg-litt-surface-3\">\n          {grow.moving ? (\n            <motion.span className={\"block h-full w-full rounded-litt-control \" + fill} style={{ x: slide }} />\n          ) : (\n            <span className={\"block h-full rounded-litt-control \" + fill} style={{ width: `${share}%` }} />\n          )}\n        </span>\n      </span>\n      <span className=\"col-start-[value] row-start-1 text-right @max-[28rem]:pl-3 text-litt-small tabular-nums text-litt-fg\">\n        {value}\n        {said && <span className=\"sr-only\">{said}</span>}\n      </span>\n      {item.delta && (\n        <span className={\"col-start-[delta] row-start-1 pl-4 text-litt-caption whitespace-nowrap \" + TONE[tone]}>\n          {item.delta}\n          <span className=\"sr-only\">{TONE_WORD[tone]}</span>\n        </span>\n      )}\n    </>\n  );\n  return (\n    <li ref={register} className=\"col-span-full grid grid-cols-subgrid border-t border-litt-line first:border-t-0 in-[[data-material=flat]]:border-transparent\">\n      {item.href ? (\n        <a href={item.href} onClick={press} className={ROW + \" \" + PRESS}>\n          {cells}\n        </a>\n      ) : onSelect ? (\n        <button type=\"button\" onClick={press} className={ROW + \" \" + PRESS}>\n          {cells}\n        </button>\n      ) : (\n        <div className={ROW}>{cells}</div>\n      )}\n    </li>\n  );\n}\n\n/** A ranked list with a thin bar in each row. Rows can link or be pressed; the bars never animate. */\nexport function BarList({ label, items, max, formatValue, unit, hue, onSelect, className = \"\" }: BarListProps) {\n  const fmt = formatValue ?? groupDigits;\n  const top = max ?? Math.max(0, ...items.map((item) => item.value));\n  const fill = hue ? HUE[hue] : \"bg-litt-fg\";\n  const unitFor = (v: number) => (Array.isArray(unit) ? (v === 1 ? unit[0] : unit[1]) : unit);\n\n  /* Fun mode: rows keep their identity by label (when labels are unique), and a row that changed place slides from\n     where it was. Offsets, not the screen, so a zoomed or scaled parent never skews the slide. */\n  const fun = useFun();\n  const byLabel = !!fun && new Set(items.map((i) => i.label)).size === items.length;\n  const rows = useRef(new Map<string, HTMLLIElement>());\n  const was = useRef(new Map<string, number>());\n  useLayoutEffect(() => {\n    if (!byLabel || !fun) return void was.current.clear();\n    rows.current.forEach((el, key) => {\n      const from = was.current.get(key);\n      const to = el.offsetTop;\n      was.current.set(key, to);\n      if (from === undefined || from === to) return;\n      Object.assign(el.style, { position: \"relative\", zIndex: from > to ? \"2\" : \"1\", background: backdrop(el) });\n      animate(el, { y: [from - to, 0] }, fun.swap).then(() => Object.assign(el.style, { position: \"\", zIndex: \"\", background: \"\", transform: \"\" }));\n    });\n  });\n\n  return (\n    <div className={\"@container w-full \" + className}>\n      <ol role=\"list\" aria-label={label} className={\"m-0 grid list-none p-0 \" + WIDE + \" \" + NARROW}>\n        {items.map((item, i) => {\n          const share = top > 0 ? Math.max(0, Math.min(100, (item.value / top) * 100)) : 0;\n          const word = unit ? unitFor(item.value) : \"\";\n          /* Hidden words after the number: the unit, then a comma when the delta follows. */\n          const said = (word ? ` ${word}` : \"\") + (item.delta ? \", \" : \"\");\n          const key = byLabel ? item.label : `${i}-${item.label}`;\n          return (\n            <Row\n              key={key}\n              item={item}\n              index={i}\n              share={share}\n              fill={item.hue ? HUE[item.hue] : fill}\n              shown={fmt(item.value)}\n              format={fmt}\n              said={said}\n              onSelect={onSelect}\n              register={\n                byLabel\n                  ? (el) => {\n                      if (el) rows.current.set(key, el);\n                      else rows.current.delete(key);\n                    }\n                  : undefined\n              }\n            />\n          );\n        })}\n      </ol>\n    </div>\n  );\n}\n\n/** The first background behind an element that is not transparent: what a sliding row borrows so it covers the other. */\nfunction backdrop(el: HTMLElement) {\n  for (let at: HTMLElement | null = el.parentElement; at; at = at.parentElement) {\n    const bg = getComputedStyle(at).backgroundColor;\n    if (bg && bg !== \"transparent\" && !/^rgba\\(.*,\\s*0\\)$/.test(bg)) return bg;\n  }\n  return \"\";\n}\n",
      "target": "components/litt/bar-list.tsx"
    }
  ],
  "docs": "Import BarList from components/litt/bar-list. Pass a label and items of { label, value }, plus a unit for screen readers. A row with an href is a link, and with onSelect the others are buttons.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
