{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ratio-bar",
  "title": "Ratio Bar",
  "description": "One horizontal bar that shows how a whole is divided up, with a legend that names every part — the GitHub-style language / storage bar. Reach for it whenever the question is \"what is this made of?\" rather than \"how far along is it?\": disk or storage usage broken down by file type, a plan or quota bar (seats used, API calls, build minutes, bandwidth), a budget or spend breakdown by category, traffic by source or device, test results split into passed / failed / skipped, a portfolio or vote split, tickets by status, or a repository language bar. Common asks it answers: \"stacked bar component react\", \"percentage breakdown bar shadcn\", \"storage usage bar\", \"disk usage breakdown\", \"quota / capacity bar\", \"segmented progress bar\", \"share of total bar\", \"distribution bar\", \"usage meter with legend\", \"percentages that add up to 100\". Pass `parts` as `{ label, value }` objects in any unit you like — bytes, requests, dollars — and only the ratios are used. Add `total` to switch from \"parts of a whole\" to \"used out of a capacity\": the gap is drawn as empty track and listed as its own row (rename it with `remainderLabel`, or pass `null` to draw it without listing it). `precision` adds decimals, `formatValue` puts the raw figure next to each share, `showLegend={false}` keeps the legend for screen readers only, and each part takes a `className` for its colour (the default is a ramp of your primary colour, which is theme-aware in any shadcn project; pass `bg-chart-1`…`bg-chart-5` or your own classes for distinct hues). It handles the parts a hand-rolled version gets wrong. The percentages are apportioned by largest remainder rather than rounded one at a time, so three equal parts read 34 / 33 / 33 instead of 33 / 33 / 33 and the column always totals exactly 100. A part too small to round to a whole percent reads \"<1%\" rather than the lie \"0%\", and a part that is nearly but not quite everything reads \">99%\" rather than \"100%\". Tiny slices keep a two-pixel minimum so they stay visible without stealing width from the rest, while a part worth exactly zero draws nothing at all and is still listed. Negative, NaN and Infinity values count as zero instead of collapsing the layout. The legend names and quantifies every part, so nothing is carried by colour alone (WCAG 1.4.1) and the bar itself is aria-hidden. No hooks and no clock: it renders inside a React server component with no \"use client\" of its own, ships no client JavaScript, and produces identical markup on the server and in the browser. `ratioPercents` is exported for the same figures in a table or tooltip. Official shadcn/ui has nothing for this: progress is a single value with no parts, and chart is a Recharts wrapper for plotted series rather than one inline bar with no dependencies.",
  "files": [
    {
      "path": "registry/ui/ratio-bar.tsx",
      "content": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * One part of the whole. `value` is in whatever unit the caller likes — bytes, requests,\n * dollars, seats — because only the ratios between the parts are ever used.\n */\nexport interface RatioBarPart {\n  /** Name shown in the legend and read out by a screen reader, e.g. \"Images\". */\n  label: React.ReactNode\n  /**\n   * Size of this part. Negative, NaN and Infinity count as 0 rather than poisoning the\n   * whole bar: one bad number out of a database should cost you one slice, not the layout.\n   */\n  value: number\n  /**\n   * Tailwind background class for this slice and its legend dot, e.g. \"bg-chart-2\" or\n   * \"bg-emerald-500\". Defaults to a ramp of the primary colour. Pass your own once you\n   * have more than five parts, or when the parts already have colours of their own\n   * (languages, plan tiers, log levels).\n   */\n  className?: string\n  /** React key for this part. Falls back to the array index. */\n  id?: string\n}\n\nexport interface RatioBarProps\n  extends Omit<React.ComponentPropsWithoutRef<\"div\">, \"children\"> {\n  /** The parts, in the order they should appear from the left. */\n  parts: RatioBarPart[]\n  /**\n   * Capacity, when the parts are \"used out of a budget\" rather than \"all there is\" —\n   * a disk quota, a plan limit, a sprint's hours. The gap between the parts and the\n   * capacity is drawn as empty track and listed as its own legend row. Leave it unset\n   * for a plain breakdown, where the parts themselves are the whole. A capacity below\n   * what the parts already add up to is ignored, since a bar cannot be more than full.\n   */\n  total?: number\n  /**\n   * Legend label for the unused capacity (default \"Free\"). Pass `null` to draw the gap\n   * without listing it. Has no effect unless `total` leaves a gap.\n   */\n  remainderLabel?: React.ReactNode\n  /** Show the legend (default true). When false it is still rendered for screen readers. */\n  showLegend?: boolean\n  /** Decimal places on the percentages (default 0, capped at 6). */\n  precision?: number\n  /**\n   * Render the raw value next to each percentage, e.g. a byte or currency formatter.\n   * Omit to show percentages alone.\n   */\n  formatValue?: (value: number) => React.ReactNode\n  /** Classes for the track, e.g. \"h-3\" or \"rounded-sm\". */\n  barClassName?: string\n  /** Accessible name for the breakdown, e.g. \"Storage by file type\". */\n  \"aria-label\"?: string\n}\n\n/**\n * Steps of the primary colour rather than five different hues: `--primary` exists in every\n * shadcn project and a tint of it cannot leave the theme, while the fixed chart palette\n * (`--chart-1`…`--chart-5`) only exists in projects scaffolded after charts landed. Colour\n * is never the only carrier of meaning here — the legend names every part — so a ramp is\n * enough, and callers with established colours pass `className` per part.\n */\nconst DEFAULT_COLORS = [\n  \"bg-primary\",\n  \"bg-primary/75\",\n  \"bg-primary/55\",\n  \"bg-primary/40\",\n  \"bg-primary/25\",\n]\n\n/** Anything that is not a positive, finite number contributes nothing. */\nfunction usable(value: number) {\n  return Number.isFinite(value) && value > 0 ? value : 0\n}\n\nfunction clampPrecision(precision: number) {\n  return Math.min(6, Math.max(0, Math.trunc(precision) || 0))\n}\n\n/**\n * Percentages of the total that sum to exactly 100, by largest remainder (Hamilton)\n * apportionment.\n *\n * Rounding each part on its own is the bug this exists to avoid: three equal parts come out\n * as 33% / 33% / 33% and a reader who adds them up finds 99, while 1/3 + 1/3 + 1/3 at one\n * decimal overshoots to 100.2. Here every part is floored first and the leftover points go\n * to the parts that were cut hardest, so the column always totals 100 and no part is off by\n * more than one step.\n *\n * A part worth nothing always gets 0: the points handed out are only ever as many as there\n * are parts with something left over, so a zero can never be rounded up into existence.\n *\n * Exported because the same numbers are usually wanted next to the bar — in a table, a\n * tooltip, a CSV export — and computing them a second way is how the two end up disagreeing.\n */\nexport function ratioPercents(values: number[], precision = 0): number[] {\n  const scale = 10 ** clampPrecision(precision)\n  const safe = values.map(usable)\n  const total = safe.reduce((sum, value) => sum + value, 0)\n  if (total <= 0) return safe.map(() => 0)\n\n  const target = 100 * scale\n  const exact = safe.map((value) => (value / total) * target)\n  const floors = exact.map((n) => Math.floor(n))\n  const units = floors.slice()\n  let short = target - floors.reduce((sum, n) => sum + n, 0)\n\n  // Biggest fractional part first. When two parts were cut equally hard the point goes to the\n  // bigger one, where it distorts the reading least — half a point on top of 37.5 is a 1%\n  // overstatement, the same half point on top of 12.5 is a 4% one. Parts that are equal in\n  // both respects fall to the earlier one, because Array#sort has been stable since ES2019;\n  // that matters beyond tidiness, since a comparator that resolved ties by chance would let\n  // the server and the browser render different numbers.\n  const order = exact\n    .map((_, i) => i)\n    .sort((a, b) => exact[b] - floors[b] - (exact[a] - floors[a]) || exact[b] - exact[a])\n  for (let i = 0; i < order.length && short > 0; i++, short--) units[order[i]] += 1\n\n  return units.map((u) => u / scale)\n}\n\n/**\n * A percentage that has been rounded to nothing, or up to everything, is a lie about a part\n * that is neither. Those two cases are relabelled rather than re-rounded, so the numbers\n * still sum to 100 while a 0.02% slice reads \"<1%\" instead of \"0%\".\n */\nfunction formatPercent(\n  percent: number,\n  precision: number,\n  value: number,\n  total: number\n) {\n  const step = 1 / 10 ** precision\n  if (value > 0 && percent === 0) return `<${step.toFixed(precision)}%`\n  if (percent === 100 && value < total) return `>${(100 - step).toFixed(precision)}%`\n  return `${percent.toFixed(precision)}%`\n}\n\nexport const RatioBar = React.forwardRef<HTMLDivElement, RatioBarProps>(\n  function RatioBar(\n    {\n      className,\n      parts,\n      total,\n      remainderLabel = \"Free\",\n      showLegend = true,\n      precision = 0,\n      formatValue,\n      barClassName,\n      \"aria-label\": ariaLabel,\n      ...props\n    },\n    ref\n  ) {\n    const decimals = clampPrecision(precision)\n    const values = parts.map((part) => usable(part.value))\n    const sum = values.reduce((acc, value) => acc + value, 0)\n    const capacity =\n      typeof total === \"number\" && Number.isFinite(total) && total > sum ? total : sum\n    const gap = capacity - sum\n\n    // The gap is apportioned alongside the parts so that a bar with spare capacity still\n    // adds up to 100% including the empty stretch.\n    const percents = ratioPercents([...values, gap], decimals)\n    const gapPercent = percents[percents.length - 1]\n\n    const colorFor = (part: RatioBarPart, index: number) =>\n      part.className ?? DEFAULT_COLORS[index % DEFAULT_COLORS.length]\n\n    // Composed as nodes rather than interpolated into a template string: `formatValue` is\n    // declared to return a `ReactNode`, and a template literal would render anything that is not\n    // already a string as \"[object Object]\" — so the type says an element is welcome while the\n    // output says otherwise. A string return renders exactly as it did.\n    const describe = (value: number, percent: number): React.ReactNode => {\n      const share = formatPercent(percent, decimals, value, capacity)\n      return formatValue ? (\n        <>\n          {formatValue(value)} ({share})\n        </>\n      ) : (\n        share\n      )\n    }\n\n    return (\n      <div ref={ref} className={cn(\"w-full\", className)} {...props}>\n        {/*\n          Decorative: every number and name below is in the list, so a reader that skips\n          the bar loses nothing. The slices are sized by the exact share rather than by the\n          rounded percentage — a 0.4% slice should stay a visible sliver, not collapse\n          because its label rounded down to \"<1%\".\n        */}\n        <div\n          aria-hidden=\"true\"\n          className={cn(\n            \"flex h-2 w-full gap-px overflow-hidden rounded-full bg-muted\",\n            barClassName\n          )}\n        >\n          {parts.map((part, index) =>\n            values[index] > 0 ? (\n              <span\n                key={part.id ?? index}\n                className={cn(\"h-full min-w-[2px]\", colorFor(part, index))}\n                style={{ flexGrow: values[index] / capacity, flexBasis: 0 }}\n              />\n            ) : null\n          )}\n          {gap > 0 ? (\n            <span\n              className=\"h-full\"\n              style={{ flexGrow: gap / capacity, flexBasis: 0 }}\n            />\n          ) : null}\n        </div>\n\n        <ul\n          aria-label={ariaLabel}\n          className={cn(\n            showLegend\n              ? \"mt-3 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-sm\"\n              : \"sr-only\"\n          )}\n        >\n          {parts.map((part, index) => (\n            <li key={part.id ?? index} className=\"flex items-center gap-1.5\">\n              <span\n                aria-hidden=\"true\"\n                className={cn(\n                  \"h-2 w-2 shrink-0 rounded-full\",\n                  colorFor(part, index)\n                )}\n              />\n              <span className=\"text-foreground\">{part.label}</span>\n              <span className=\"tabular-nums text-muted-foreground\">\n                {describe(values[index], percents[index])}\n              </span>\n            </li>\n          ))}\n          {gap > 0 && remainderLabel != null ? (\n            <li className=\"flex items-center gap-1.5\">\n              <span\n                aria-hidden=\"true\"\n                className=\"h-2 w-2 shrink-0 rounded-full border border-border bg-muted\"\n              />\n              <span className=\"text-foreground\">{remainderLabel}</span>\n              <span className=\"tabular-nums text-muted-foreground\">\n                {describe(gap, gapPercent)}\n              </span>\n            </li>\n          ) : null}\n        </ul>\n      </div>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}