{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "spread",
  "type": "registry:ui",
  "title": "Spread",
  "description": "How values spread, one thin rail per row on a shared scale: the full range faint, the middle half in a data hue, the median as a tick and in mono at the end. Build times by site, reply times by client. A row gives its values or its quartiles, a goal is a dashed line through every rail, and under 28rem the label moves above its rail. Static, with a legend in words.",
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/spread.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/spread 2026.10.26. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Spread: how values spread, one thin rail per row on a shared scale. Build times by Framer site, reply times by\n * client. The Dot plot's quiet sibling: where the Dot plot draws every value as a dot, a row here draws five\n * numbers as a thread, so a dozen rows still read at a glance. A sibling of the Meter in chart.tsx and the Bar list.\n *\n * - Row: the label at the start (small, `--litt-fg`, truncates), the rail across the shared scale, then the median at\n *   the end (caption, `--litt-fg-subtle`, tabular, as the Meter's reading). Rows are `--litt-space-6` apart with no\n *   dividers and no boxes. A row is a stat of its own, so nothing here is pressable and nothing takes focus.\n * - Rail: a 4px track at the control radius in fg 12% across the whole scale. Over it the full range (min to\n *   max) is a 4px segment in fg 24%, over that the middle half (p25 to p75) is a 4px segment in the hue's mark\n *   tone, and the median is a 2px wide, 12px tall tick in the hue's text tone, centered on the rail. A hue is\n *   drawn thin, a thread through neutral space (R7). The hue means the measure (build time, reply time), so a\n *   row past the goal keeps it.\n * - Data: a row is `{ label, values }` (the component computes the quartiles, by linear interpolation) or\n *   `{ label, min, p25, median, p75, max }` when an API has already done it. Rows may mix.\n * - Alignment: one grid for the list, as the Bar list does. Every row is a subgrid of it, so every rail starts\n *   and ends at the same x and the medians line up in a column. The list measures itself with a container query,\n *   not the window: under 28rem the label moves above its rail, which then spans the full width of the row.\n * - Scale: linear or log (durations from minutes to days read on a log scale). Left out, the domain runs from\n *   the smallest value to the largest (and the goal), rounded outward to round numbers. The tick labels sit once,\n *   under the last row, at the caption size, `--litt-fg-subtle`, tabular; a label that would touch its neighbour is left\n *   out, and the first and last stay inside the rail. They are measured from the rail's own width.\n * - Goal: a 1px dashed `--litt-line-strong` line through every rail at the goal. In the wide layout the segments\n *   join into one line down the list. Its label sits once under the rows, where the line ends. A row whose\n *   median is past the goal says so in hidden words.\n * - Legend: one line under the scale says what the marks are in words (\"Faint: the full range. Blue: the middle\n *   half. Tick: the median.\"), with tiny swatches drawn in the same tones.\n * - Semantics: a list named by `label`. A row reads \"Halo site: median 41 s, middle half 32 s to 48 s, range\n *   22 s to 71 s\" (and \", past the 4 h goal\"), built from the visible label and median plus hidden words. The\n *   rails, the ticks and the goal line are hidden from assistive tech, because the numbers are in the words.\n * - Motion: none. No entrance, no count-up. A changed value just changes.\n */\nimport { useLayoutEffect, useMemo, useRef, useState } from \"react\";\n\n/** The five data hues, in their fixed order (R7). */\nexport type SpreadHue = \"blue\" | \"amber\" | \"teal\" | \"violet\" | \"rose\";\n\ntype SpreadFromValues = {\n  /** What the row is, in a few words: \"Halo site\". It truncates when the row is narrow. */\n  label: string;\n  /** Every value the row has, in any order. The component works out the range, the quartiles and the median. */\n  values: number[];\n};\n\ntype SpreadFromQuantiles = {\n  /** What the row is, in a few words: \"Halo site\". It truncates when the row is narrow. */\n  label: string;\n  /** The smallest value: where the full range starts. */\n  min: number;\n  /** The first quartile: where the middle half starts. */\n  p25: number;\n  /** The median, drawn as the tick and shown at the end of the row. */\n  median: number;\n  /** The third quartile: where the middle half ends. */\n  p75: number;\n  /** The largest value: where the full range ends. */\n  max: number;\n};\n\n/** One row: either all of its values, or the five numbers already computed. */\nexport type SpreadItem = SpreadFromValues | SpreadFromQuantiles;\n\nexport type SpreadProps = {\n  /** Names the list for assistive tech and says what is measured: \"Build times by site\". */\n  label: string;\n  /** The rows, in the order to show them. Sort them yourself: the list does not. */\n  items: SpreadItem[];\n  /** The scale: \"linear\", or \"log\" for values that run from minutes to days. A log scale needs values above zero. */\n  scale?: \"linear\" | \"log\";\n  /** The ends of the scale as [min, max]. Left out, it runs from the smallest value to the largest (and the goal), rounded outward to round numbers. */\n  domain?: [min: number, max: number];\n  /** The labelled positions under the last row, such as `{ value: 60, label: \"1 h\" }`. Left out, they come from the domain: decades on a log scale, round steps on a linear one. A label that would touch its neighbour is left out. */\n  ticks?: { value: number; label: string }[];\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 are, said after a number to screen readers when formatValue does not already say it: \"seconds\". Pass [\"second\", \"seconds\"] to say the first for exactly 1. */\n  unit?: string | [one: string, other: string];\n  /** The data hue of the middle half and the median: blue, amber, teal, violet or rose. One hue means the measure, so it does not change for a row past the goal. */\n  hue?: SpreadHue;\n  /** Draws a 1px dashed line through every rail at this value, and names a row whose median is past it. Leave it out for no line. */\n  goal?: number;\n  /** The text under the goal line, such as \"4 h goal\". Left out, it reads the goal and the word goal. */\n  goalLabel?: string;\n  /** Extra classes for the list's box, such as a width or a top margin. */\n  className?: string;\n};\n\n/* ----------------------------------------------------------------- constants and helpers */\n\nconst FIGURE = 7.4; // the advance of a 12px tabular figure in Geist with its tracking, rounded up\nconst TICK_GAP = 12; // the least air between two tick labels\n\n/** Mark tone for the middle half and text tone for the median, as fills, plus the hue's name for the legend. Literal strings, so Tailwind sees them. */\nconst HUE: Record<SpreadHue, { mark: string; tick: string; name: string }> = {\n  blue: { mark: \"bg-litt-data-blue\", tick: \"bg-litt-data-blue-text\", name: \"Blue\" },\n  amber: { mark: \"bg-litt-data-amber\", tick: \"bg-litt-data-amber-text\", name: \"Amber\" },\n  teal: { mark: \"bg-litt-data-teal\", tick: \"bg-litt-data-teal-text\", name: \"Teal\" },\n  violet: { mark: \"bg-litt-data-violet\", tick: \"bg-litt-data-violet-text\", name: \"Violet\" },\n  rose: { mark: \"bg-litt-data-rose\", tick: \"bg-litt-data-rose-text\", name: \"Rose\" },\n};\n\n/** The Meter's track, and the range laid over it. */\nconst TRACK = \"bg-[color-mix(in_oklab,var(--litt-fg)_12%,transparent)]\";\nconst RANGE = \"bg-[color-mix(in_oklab,var(--litt-fg)_24%,transparent)]\";\n\nconst groupDigits = (n: number) => n.toLocaleString(\"en-GB\", { maximumFractionDigits: 2 });\n\ntype Stats = { min: number; p25: number; median: number; p75: number; max: number };\n\n/** A quantile of sorted values, by linear interpolation between neighbours. */\nfunction quantile(sorted: number[], q: number) {\n  const at = (sorted.length - 1) * q;\n  const lo = Math.floor(at);\n  const hi = Math.ceil(at);\n  return sorted[lo]! + (sorted[hi]! - sorted[lo]!) * (at - lo);\n}\n\n/** The five numbers of a row, or null when it has none (an empty row, or values a log scale cannot place). */\nfunction statsOf(item: SpreadItem, log: boolean): Stats | null {\n  if (\"values\" in item) {\n    const sorted = item.values.filter((v) => Number.isFinite(v) && (!log || v > 0)).sort((a, b) => a - b);\n    if (sorted.length === 0) return null;\n    return { min: sorted[0]!, p25: quantile(sorted, 0.25), median: quantile(sorted, 0.5), p75: quantile(sorted, 0.75), max: sorted[sorted.length - 1]! };\n  }\n  const { min, p25, median, p75, max } = item;\n  const all = [min, p25, median, p75, max];\n  return all.every((v) => Number.isFinite(v) && (!log || v > 0)) ? { min, p25, median, p75, max } : null;\n}\n\n/** A round step (1, 2, 5 or 10 times a power of ten) that puts about four to six of them across a linear domain. */\nfunction niceStep(min: number, max: number) {\n  const raw = (max - min) / 5;\n  const pow = 10 ** Math.floor(Math.log10(raw));\n  return [1, 2, 5, 10].map((m) => m * pow).find((s) => s >= raw) ?? raw;\n}\n\nfunction niceTicks(min: number, max: number): number[] {\n  if (!(max > min)) return [min];\n  const step = niceStep(min, max);\n  const first = Math.ceil(min / step - 1e-9);\n  const out: number[] = [];\n  for (let i = first; i * step <= max + 1e-9; i++) out.push(Number((i * step).toPrecision(12)));\n  return out;\n}\n\n/** Decades across a log domain, then 2s and 5s when there are fewer than three. */\nfunction logTicks(min: number, max: number): number[] {\n  const e0 = Math.floor(Math.log10(min));\n  const e1 = Math.ceil(Math.log10(max));\n  const within = (mults: number[]) => {\n    const out: number[] = [];\n    for (let e = e0; e <= e1; e++) for (const m of mults) out.push(Number((m * 10 ** e).toPrecision(12)));\n    return out.filter((v) => v >= min && v <= max);\n  };\n  const decades = within([1]);\n  return decades.length >= 3 ? decades : within([1, 2, 5]);\n}\n\n/** The nearest 1, 2 or 5 times a power of ten, below (or above) a value. */\nfunction round125(value: number, up: boolean) {\n  const pow = 10 ** Math.floor(Math.log10(value));\n  const m = value / pow;\n  const steps = [1, 2, 5, 10];\n  const pick = up ? steps.find((s) => s >= m - 1e-9) : [...steps].reverse().find((s) => s <= m + 1e-9);\n  return Number(((pick ?? 1) * pow).toPrecision(12));\n}\n\n/** The scale's ends: the caller's, or the data's (and the goal's) rounded outward to round numbers. */\nfunction resolveDomain(rows: (Stats | null)[], log: boolean, goal: number | undefined, domain: [number, number] | undefined): [number, number] {\n  if (domain) {\n    const lo = log ? Math.max(domain[0], 1e-9) : domain[0];\n    return [lo, domain[1] > lo ? domain[1] : log ? lo * 10 : lo + 1];\n  }\n  const edges = rows.flatMap((r) => (r ? [r.min, r.max] : []));\n  if (goal !== undefined && (!log || goal > 0)) edges.push(goal);\n  if (edges.length === 0) return log ? [1, 10] : [0, 1];\n  const lo = Math.min(...edges);\n  const hi = Math.max(...edges);\n  if (log) {\n    const a = round125(lo, false);\n    const b = round125(hi, true);\n    return [a, b > a ? b : a * 10];\n  }\n  if (!(hi > lo)) return [lo - 1, hi + 1];\n  const step = niceStep(lo, hi);\n  return [Number((Math.floor(lo / step + 1e-9) * step).toPrecision(12)), Number((Math.ceil(hi / step - 1e-9) * step).toPrecision(12))];\n}\n\n/** A box's width, measured before paint and kept current. 0 until the first measure, and on the server. */\nfunction useWidth() {\n  const ref = useRef<HTMLDivElement>(null);\n  const [width, setWidth] = useState(0);\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const read = () => setWidth(Math.floor(el.getBoundingClientRect().width));\n    read();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const ro = new ResizeObserver(read);\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  return [ref, width] as const;\n}\n\n/** Where a label of `w` px, centred on `x`, starts so that it stays inside 0..width. */\nconst startFor = (x: number, w: number, width: number) => Math.max(0, Math.min(width - w, x - w / 2));\n\n/** A label's inline position: centred on `x`, but flush with the rail's end when it would pass it, so the estimate of its width never pushes it out. */\nconst placeAt = (x: number, w: number, width: number): { left: number } | { right: number } => (x + w / 2 > width ? { right: 0 } : { left: startFor(x, w, width) });\n\n/*\n * The columns, named by their start line: the label, the rail and the median. The grid carries the 1rem\n * between them. Under 28rem the rail leaves the row's first line and spans from the label to the end.\n */\nconst WIDE = \"grid-cols-[[label]_fit-content(10rem)_[rail]_minmax(6rem,1fr)_[median]_auto_[end]]\";\nconst NARROW = \"@max-[28rem]:grid-cols-[[label]_minmax(0,1fr)_[median]_auto_[end]]\";\nconst RAIL_COLS = \"col-start-[rail] col-end-[median] @max-[28rem]:col-start-[label] @max-[28rem]:col-end-[end]\";\n\n/* ----------------------------------------------------------------- the component */\n\n/** One thin rail per row on a shared scale: the full range, the middle half and the median. Static, and never the accent. */\nexport function Spread({ label, items, scale = \"linear\", domain, ticks, formatValue, unit, hue = \"blue\", goal, goalLabel, className = \"\" }: SpreadProps) {\n  const fmt = formatValue ?? groupDigits;\n  const log = scale === \"log\";\n  const tones = HUE[hue];\n  const [footRef, width] = useWidth();\n\n  const unitFor = (v: number) => (Array.isArray(unit) ? (v === 1 ? unit[0] : unit[1]) : unit);\n  /* A number as it is read: the text, then the unit when there is one. A range keeps the unit for its end. */\n  const say = (v: number) => fmt(v) + (unit ? ` ${unitFor(v)}` : \"\");\n  const sayRange = (a: number, b: number) => (fmt(a) === fmt(b) ? say(a) : `${fmt(a)} to ${say(b)}`);\n  const goalText = goalLabel ?? (goal !== undefined ? `${fmt(goal)} goal` : \"\");\n\n  const rows = useMemo(() => items.map((item) => statsOf(item, log)), [items, log]);\n  const [min, max] = useMemo(() => resolveDomain(rows, log, goal, domain), [rows, log, goal, domain]);\n  const span = log ? Math.log(max) - Math.log(min) : max - min;\n  /* Where a value sits on the scale, as a share of the rail's width. */\n  const at = (v: number) => {\n    if (log && !(v > 0)) return 0;\n    const f = ((log ? Math.log(v) - Math.log(min) : v - min) / span) * 100;\n    return Math.min(100, Math.max(0, f));\n  };\n  const goalDrawn = goal !== undefined && goal >= min && goal <= max;\n\n  /* Tick labels left to right, placed in px from the rail's width; one that would touch the one before it is left out. */\n  const shown = useMemo(() => {\n    const list = (ticks ?? (log ? logTicks(min, max) : niceTicks(min, max)).map((value) => ({ value, label: fmt(value) })))\n      .filter((t) => t.value >= min && t.value <= max)\n      .map((t) => {\n        const f = ((log ? Math.log(t.value) - Math.log(min) : t.value - min) / span) * width;\n        return { ...t, w: t.label.length * FIGURE, x: f };\n      })\n      .sort((a, b) => a.x - b.x);\n    const out: { key: number; label: string; at: { left: number } | { right: number } }[] = [];\n    let right = -Infinity;\n    for (const t of list) {\n      const left = startFor(t.x, t.w, width);\n      if (left < right + TICK_GAP) continue;\n      right = left + t.w;\n      out.push({ key: t.value, label: t.label, at: placeAt(t.x, t.w, width) });\n    }\n    return out;\n  }, [ticks, log, min, max, span, fmt, width]);\n\n  const goalAt = goalDrawn ? placeAt((at(goal) / 100) * width, goalText.length * FIGURE, width) : { left: 0 };\n\n  return (\n    <div className={\"@container w-full \" + className}>\n      <div className={\"grid gap-x-4 \" + WIDE + \" \" + NARROW}>\n        <ul role=\"list\" aria-label={label} className=\"col-span-full m-0 grid list-none grid-cols-subgrid gap-y-6 p-0\">\n          {items.map((item, i) => {\n            const s = rows[i];\n            const past = s !== null && s !== undefined && goal !== undefined && s.median > goal;\n            return (\n              <li\n                key={`${i}-${item.label}`}\n                className=\"col-span-full grid min-h-6 grid-cols-subgrid items-center @max-[28rem]:min-h-0 @max-[28rem]:gap-y-2\"\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\n                <span\n                  aria-hidden=\"true\"\n                  className={\"relative row-start-1 self-stretch @max-[28rem]:row-start-2 @max-[28rem]:h-3 @max-[28rem]:self-center \" + RAIL_COLS}\n                >\n                  <span className={\"absolute inset-x-0 top-1/2 block h-1 -translate-y-1/2 rounded-litt-control \" + TRACK} />\n                  {s && (\n                    <>\n                      <span\n                        className={\"absolute top-1/2 block h-1 -translate-y-1/2 rounded-litt-control \" + RANGE}\n                        style={{ left: `${at(s.min)}%`, width: `${at(s.max) - at(s.min)}%` }}\n                      />\n                      <span\n                        className={\"absolute top-1/2 block h-1 -translate-y-1/2 rounded-litt-control \" + tones.mark}\n                        style={{ left: `${at(s.p25)}%`, width: `${at(s.p75) - at(s.p25)}%` }}\n                      />\n                    </>\n                  )}\n                  {goalDrawn && (\n                    <span className=\"absolute -inset-y-3 w-px @max-[28rem]:-inset-y-1\" style={{ left: `${at(goal)}%` }}>\n                      <svg width=\"1\" height=\"100%\" focusable=\"false\" className=\"block h-full overflow-visible\">\n                        <line x1={0.5} x2={0.5} y1={0} y2=\"100%\" strokeWidth={1} strokeDasharray=\"4 4\" shapeRendering=\"crispEdges\" className=\"stroke-litt-line-strong\" />\n                      </svg>\n                    </span>\n                  )}\n                  {s && <span className={\"absolute top-1/2 block h-3 w-0.5 -translate-x-1/2 -translate-y-1/2 \" + tones.tick} style={{ left: `${at(s.median)}%` }} />}\n                </span>\n\n                <span className=\"col-start-[median] row-start-1 text-right text-litt-caption font-normal whitespace-nowrap tabular-nums text-litt-fg-subtle\">\n                  {s ? (\n                    <>\n                      <span className=\"sr-only\">median </span>\n                      {fmt(s.median)}\n                      <span className=\"sr-only\">\n                        {unit ? ` ${unitFor(s.median)}` : \"\"}, middle half {sayRange(s.p25, s.p75)}, range {sayRange(s.min, s.max)}\n                        {past ? `, past the ${goalText}` : \"\"}\n                      </span>\n                    </>\n                  ) : (\n                    \"No data\"\n                  )}\n                </span>\n              </li>\n            );\n          })}\n        </ul>\n\n        {/* The scale, once, under the last row: the goal's label where its line ends, then the ticks. It is drawn at the rail's measured width. */}\n        <div aria-hidden=\"true\" className=\"col-span-full mt-3 grid grid-cols-subgrid @max-[28rem]:mt-2\">\n          <div ref={footRef} className={\"relative row-start-1 \" + RAIL_COLS + (goalDrawn ? \" h-12\" : \" h-6\")}>\n            {width > 0 && (\n              <>\n                {goalDrawn && goalText && (\n                  <span className=\"absolute top-0 flex h-6 items-center text-litt-caption font-normal whitespace-nowrap tabular-nums text-litt-fg-subtle\" style={goalAt}>\n                    {goalText}\n                  </span>\n                )}\n                {shown.map((t) => (\n                  <span key={t.key} className=\"absolute bottom-0 flex h-6 items-center text-litt-caption font-normal whitespace-nowrap tabular-nums text-litt-fg-subtle\" style={t.at}>\n                    {t.label}\n                  </span>\n                ))}\n              </>\n            )}\n          </div>\n        </div>\n\n        <p className=\"col-span-full mt-4 flex flex-wrap items-center gap-x-6 gap-y-2 text-litt-small text-litt-fg-muted\">\n          <span className=\"flex items-center gap-2\">\n            <span aria-hidden=\"true\" className={\"block h-1 w-3 shrink-0 rounded-litt-control \" + TRACK}>\n              <span className={\"block h-full rounded-litt-control \" + RANGE} />\n            </span>\n            Faint: the full range.\n          </span>\n          <span className=\"flex items-center gap-2\">\n            <span aria-hidden=\"true\" className={\"block h-1 w-3 shrink-0 rounded-litt-control \" + tones.mark} />\n            {tones.name}: the middle half.\n          </span>\n          <span className=\"flex items-center gap-2\">\n            <span aria-hidden=\"true\" className={\"block h-3 w-0.5 shrink-0 \" + tones.tick} />\n            Tick: the median.\n          </span>\n        </p>\n      </div>\n    </div>\n  );\n}\n",
      "target": "components/litt/spread.tsx"
    }
  ],
  "docs": "Import Spread from components/litt/spread. Pass a label and items of { label, values } or { label, min, p25, median, p75, max }, and a formatValue that includes the unit. Rows share one scale (linear or log, with an optional domain and ticks) and an optional goal. It fills its container's width, so give the container a width.",
  "meta": {
    "version": "2026.10.26",
    "released": "2026-10-26",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
