{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "bar-chart",
  "type": "registry:ui",
  "title": "Bar chart",
  "description": "Compare one measure across days and scrub any bar for its value.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/bar-chart/bar-chart.tsx",
      "type": "registry:component",
      "target": "@components/xzero/bar-chart/bar-chart.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type KeyboardEvent, type PointerEvent } from \"react\";\nimport { AnimatePresence, animate, cancelFrame, frame, motion, useInView, useMotionValue, usePresence, useReducedMotion, useTransform, type MotionValue, type Variants } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./bar-chart.module.css\";\n\nexport interface BarChartDatum {\n  /** Stable identity, such as an ISO date. A bar whose key survives a data change keeps its place, so switching ranges morphs instead of redrawing. */\n  key: string;\n  /** Full label for the headline and screen readers, such as \"Tue, Sep 15\". */\n  label: string;\n  /** Short label under the bar, such as \"T\" or \"Sep 14\". Leave it out to keep the axis quiet. */\n  axisLabel?: string;\n  value: number;\n}\n\n/** Use a bar chart for one measure across a run of days, weeks, or months, when people compare each period against the rest and against the average. */\nexport interface BarChartProps {\n  data: BarChartDatum[];\n  /** What is measured, such as \"Active minutes\". Names the chart for assistive technology. */\n  label: string;\n  /** The range on show, such as \"Sep 15–21, 2026\". It rests under the headline. */\n  period: string;\n  /** Unit after each value, such as \"min\". */\n  unit?: string;\n  /** Headline label at rest, above the average. */\n  averageLabel?: string;\n  /** Headline label while a bar is scrubbed. */\n  valueLabel?: string;\n  /** Header of the first column in the data table read by screen readers. */\n  categoryLabel?: string;\n  /** Draws the average as a reference line that springs to each new range. */\n  showAverage?: boolean;\n  /** Plot height in pixels. */\n  height?: number;\n  formatValue?: (value: number) => string;\n}\n\nconst { spring, duration, ease, blur, stagger } = motionTokens;\n/** Motion drops inherited velocity on time-defined springs, so values that retarget mid-flight run the same springs written as stiffness and damping. */\nconst physical = ({ visualDuration, bounce }: { visualDuration: number; bounce: number }, restDelta = .01) => { const root = (2 * Math.PI) / (visualDuration * 1.2); return { type: \"spring\" as const, stiffness: root * root, damping: 2 * (1 - bounce) * root, restDelta, restSpeed: restDelta * 2 }; };\nconst settle = physical(spring.smooth);\nconst grow = physical(spring.morph);\nconst glide = physical(spring.snappy);\n/** The zoom runs in fractions of the plot, so it rests far below a pixel. */\nconst zoom = physical(spring.smooth, .00001);\nconst collapse = physical({ visualDuration: duration.standard, bounce: 0 });\nconst fadeFast = { duration: duration.instant, ease: [...ease.standard] } as const;\n/** Starts animations on the next frame, so the render that caused them never swallows a spring's first frames. Returns a cleanup. */\nfunction soon(start: () => { stop: () => void }) {\n  let controls: { stop: () => void } | null = null;\n  const run = () => { controls = start(); };\n  frame.update(run);\n  return () => { cancelFrame(run); controls?.stop(); };\n}\n\n/** Room above the top gridline, the share of each slot a bar fills, its widest size, and the radius of its data end. */\nconst TOP = 12, FILL = .58, MAX_BAR = 28, RADIUS = 4;\nconst grouped = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 0 });\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\n\n/** Clean gridlines: the smallest step of 1, 2, 3, or 5 that covers the data in four rows or fewer, so shared steps survive a range change. */\nfunction scaleFor(max: number) {\n  const safe = Math.max(max, 1);\n  const magnitude = 10 ** Math.floor(Math.log10(safe / 4));\n  for (const factor of [1, 2, 3, 5, 10]) {\n    const step = factor * magnitude, rows = Math.ceil(safe / step);\n    if (rows <= 4) return { top: rows * step, ticks: Array.from({ length: rows + 1 }, (_, row) => row * step) };\n  }\n  return { top: safe, ticks: [0, safe] };\n}\n\n/** A column that grows from a square baseline to a softly rounded data end. */\nfunction barPath(x: number, w: number, h: number, base: number) {\n  if (w <= 0 || h <= .01) return `M${x.toFixed(2)} ${base}H${(x + Math.max(w, 0)).toFixed(2)}Z`;\n  const r = Math.min(RADIUS, w / 2, h), top = base - h, f = (value: number) => value.toFixed(2);\n  return `M${f(x)} ${base}V${f(top + r)}A${f(r)} ${f(r)} 0 0 1 ${f(x + r)} ${f(top)}H${f(x + w - r)}A${f(r)} ${f(r)} 0 0 1 ${f(x + w)} ${f(top + r)}V${base}Z`;\n}\n\n/** Slots count back from the latest bar, so the newest day stays pinned right while a longer range zooms out to the left. */\nfunction usePlace(place: number, reduced: boolean) {\n  const value = useMotionValue(place);\n  useEffect(() => {\n    if (value.get() === place) return;\n    if (reduced) { value.jump(place); return; }\n    return soon(() => animate(value, place, settle));\n  }, [place, reduced, value]);\n  return value;\n}\n\ntype Frame = { width: MotionValue<number>; unit: MotionValue<number> };\nconst centerOf = ({ width, unit }: Frame, place: number) => width.get() - (place + .5) * width.get() * unit.get();\n\nfunction Bar({ id, place, value, delay, shown, active, frame, scale, height, reduced }: { id: string; place: number; value: number; delay: number; shown: boolean; active: boolean; frame: Frame; scale: MotionValue<number>; height: number; reduced: boolean }) {\n  const [isPresent, safeToRemove] = usePresence();\n  const amount = useMotionValue(0);\n  const slot = usePlace(place, reduced);\n  const [firstDelay] = useState(delay);\n  const grown = useRef(false);\n  const remove = useRef(safeToRemove);\n  useEffect(() => { remove.current = safeToRemove; }, [safeToRemove]);\n\n  // A bar grows once it is seen, each a beat after its neighbour; later values settle without a delay.\n  useEffect(() => {\n    if (!isPresent) return;\n    if (reduced) { amount.jump(value); grown.current = true; return; }\n    if (!shown) return;\n    const first = !grown.current;\n    grown.current = true;\n    return soon(() => animate(amount, value, first ? { ...grow, delay: firstDelay } : settle));\n  }, [amount, firstDelay, isPresent, reduced, shown, value]);\n\n  // A leaving bar sinks into the baseline, faster than a bar grows, while the zoom carries it out of the plot.\n  useEffect(() => {\n    if (isPresent) return;\n    if (reduced) { remove.current?.(); return; }\n    return soon(() => animate(amount, 0, { ...collapse, onComplete: () => remove.current?.() }));\n  }, [amount, isPresent, reduced]);\n\n  const d = useTransform(() => {\n    const w = frame.width.get() * frame.unit.get(), size = Math.min(w * FILL, MAX_BAR), center = centerOf(frame, slot.get());\n    return barPath(center - size / 2, size, (amount.get() / scale.get()) * (height - TOP), height);\n  });\n  return <motion.path className={styles.bar} data-key={id} data-active={active || undefined} d={d} />;\n}\n\n/** A value on the axis: its gridline and label ride the scale, so a taller range slides them down while new rows fade in above. */\nfunction useRowY(value: number, scale: MotionValue<number>, height: number) {\n  return useTransform(() => Math.round(height - (value / scale.get()) * (height - TOP)) + .5);\n}\n\nfunction Gridline({ value, scale, height }: { value: number; scale: MotionValue<number>; height: number }) {\n  const y = useRowY(value, scale, height);\n  if (value === 0) return null;\n  return <motion.line className={styles.grid} x1={0} x2=\"100%\" y1={y} y2={y} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: fadeFast }} transition={{ duration: duration.standard, ease: [...ease.standard] }} />;\n}\n\nfunction TickLabel({ value, scale, height, avoid, format }: { value: number; scale: MotionValue<number>; height: number; avoid: MotionValue<number> | null; format: (value: number) => string }) {\n  const y = useRowY(value, scale, height);\n  // A tick label makes way where the average label sits beside it.\n  const opacity = useTransform(() => avoid ? clamp((Math.abs(y.get() - avoid.get()) - 10) / 6, 0, 1) : 1);\n  return <motion.span className={styles.tick} style={{ y }} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: fadeFast }} transition={{ duration: duration.standard, ease: [...ease.standard] }}>\n    <motion.span className={styles.tickText} style={{ opacity }}>{format(value)}</motion.span>\n  </motion.span>;\n}\n\nfunction AxisLabel({ text, size, shift, place, frame, reduced }: { text: string; size: number; shift: number; place: number; frame: Frame; reduced: boolean }) {\n  const slot = usePlace(place, reduced);\n  // Centred under its bar and carried by the zoom. A label at either end keeps the nudge that holds it inside the settled plot.\n  const x = useTransform(() => centerOf(frame, slot.get()) - size / 2 + shift);\n  return <motion.span className={styles.axisLabel} style={{ x }} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: fadeFast }} transition={{ duration: duration.standard, ease: [...ease.standard] }}>{text}</motion.span>;\n}\n\n/** Axis labels that fit once the range has settled: every label, else every second, third, and so on, counted back from the latest so the newest date always shows and spacing stays even. */\nfunction fittingLabels(data: BarChartDatum[], width: number, sizes: Record<string, number>) {\n  const fitting = new Map<string, number>();\n  if (!width) return fitting;\n  const count = Math.max(1, data.length);\n  const labels = data.flatMap((item, index) => {\n    const size = item.axisLabel ? sizes[item.axisLabel] : undefined;\n    if (size === undefined) return [];\n    const natural = width - (data.length - 1 - index + .5) * (width / count) - size / 2;\n    return [{ key: item.key, size, natural, left: clamp(natural, 0, Math.max(0, width - size)) }];\n  }).reverse();\n  for (let stride = 1; stride <= labels.length; stride++) {\n    const picked = labels.filter((_, rank) => rank % stride === 0);\n    if (stride === labels.length || picked.every((label, rank) => rank === 0 || label.left + label.size + 10 <= picked[rank - 1]!.left)) {\n      picked.forEach(label => fitting.set(label.key, label.left - label.natural));\n      break;\n    }\n  }\n  return fitting;\n}\n\n/** Changed text rises from a soft blur in the direction it moved, and leaves a little faster than it arrives. */\nconst rise: Variants = {\n  hidden: (direction: number) => ({ opacity: 0, y: `${.3 * direction}em`, filter: `blur(${blur.soft}px)` }),\n  shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: duration.standard, ease: [...ease.enter] } },\n  gone: (direction: number) => ({ opacity: 0, y: `${-.3 * direction}em`, filter: `blur(${blur.subtle}px)`, transition: { duration: duration.instant, ease: [...ease.standard] } }),\n};\nconst fade: Variants = { hidden: { opacity: 0, y: 0, filter: \"blur(0px)\" }, shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: duration.fast } }, gone: { opacity: 0, y: 0, filter: \"blur(0px)\", transition: { duration: duration.instant } } };\n\nfunction Swap({ text, direction, reduced }: { text: string; direction: number; reduced: boolean }) {\n  return <span className={styles.swap}><AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n    <motion.span key={text} className={styles.swapText} custom={direction} variants={reduced ? fade : rise} initial=\"hidden\" animate=\"shown\" exit=\"gone\">{text}</motion.span>\n  </AnimatePresence></span>;\n}\n\n/** A number rolls digit by digit, keyed by place from the right, so unchanged digits hold still. Its width springs to fit, so the unit beside it glides instead of jumping. */\nfunction Roll({ text, direction, reduced }: { text: string; direction: number; reduced: boolean }) {\n  const sizer = useRef<HTMLSpanElement>(null);\n  const width = useMotionValue<number | \"auto\">(\"auto\");\n  useLayoutEffect(() => {\n    const node = sizer.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    let measured: string | null = null;\n    // Only a new value springs; a late web font or a resize jumps straight to fit.\n    const observer = new ResizeObserver(() => {\n      const next = node.offsetWidth;\n      if (measured !== null && measured !== node.textContent && !reduced) animate(width, next, grow);\n      else width.jump(next);\n      measured = node.textContent;\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [reduced, width]);\n  const chars = [...text];\n  const variants = reduced ? fade : rise;\n  return <motion.span className={styles.roll} style={{ width }}>\n    <span ref={sizer} className={styles.sizer} aria-hidden=\"true\">{text}</span>\n    <span className={styles.digits}><AnimatePresence mode=\"popLayout\" initial={false} custom={direction} anchorX=\"right\">\n      {chars.map((char, index) => <motion.span key={`p${chars.length - index}`} className={styles.digit} custom={direction} variants={variants} initial=\"hidden\" animate=\"shown\" exit=\"gone\">\n        <AnimatePresence mode=\"popLayout\" initial={false} custom={direction} anchorX=\"right\">\n          <motion.span key={char} className={styles.digit} custom={direction} variants={variants} initial=\"hidden\" animate=\"shown\" exit=\"gone\">{char}</motion.span>\n        </AnimatePresence>\n      </motion.span>)}\n    </AnimatePresence></span>\n  </motion.span>;\n}\n\nconst subscribeNothing = () => () => {};\n/** Reduced motion is only known in the browser, so the first client render matches the server before it takes effect. */\nfunction useReducedMotionSafe() {\n  const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);\n  return !!useReducedMotion() && hydrated;\n}\n\nexport function BarChart({ data, label, period, unit = \"\", averageLabel = \"Daily average\", valueLabel = \"Total\", categoryLabel = \"Day\", showAverage = true, height = 176, formatValue = value => grouped.format(value) }: BarChartProps) {\n  const reduced = useReducedMotionSafe();\n  const figure = useRef<HTMLElement>(null);\n  const plot = useRef<HTMLDivElement>(null);\n  const inView = useInView(figure, { once: true, amount: .35 });\n  const titleId = useId();\n  const count = Math.max(data.length, 1), last = data.length - 1;\n  const average = data.length ? data.reduce((sum, item) => sum + item.value, 0) / data.length : 0;\n  const peak = data.reduce((max, item) => Math.max(max, item.value), 0);\n  const { top, ticks } = useMemo(() => scaleFor(peak), [peak]);\n  const signature = data.map(item => item.key).join(\"|\");\n  const suffix = unit ? ` ${unit}` : \"\";\n\n  // Bars that join an existing chart grow outward from the ones that stayed; the first reveal runs left to right.\n  const [known, setKnown] = useState(() => ({ signature, keys: new Set(data.map(item => item.key)), fresh: new Map<string, number>(), changes: 0 }));\n  const [active, setActive] = useState<number | null>(null);\n  if (known.signature !== signature) {\n    const incoming = data.map((item, index) => ({ key: item.key, place: last - index })).filter(item => !known.keys.has(item.key)).sort((a, b) => a.place - b.place);\n    const step = Math.min(stagger.item, .32 / Math.max(1, incoming.length));\n    setKnown({ signature, keys: new Set(data.map(item => item.key)), fresh: new Map(incoming.map((item, rank) => [item.key, rank * step])), changes: known.changes + 1 });\n    setActive(null);\n  }\n  const revealStep = Math.min(stagger.item, .36 / count);\n  const index = active === null ? null : Math.min(active, last);\n  const scrubbing = index !== null && last >= 0;\n  const shownValue = scrubbing ? data[index]!.value : average;\n  // Headline copy moves the way the data did: a larger value rises from below, a later day arrives from below too.\n  const [trend, setTrend] = useState({ value: shownValue, index, valueWay: 1, indexWay: 1 });\n  if (trend.value !== shownValue || trend.index !== index) setTrend({ value: shownValue, index, valueWay: shownValue === trend.value ? trend.valueWay : shownValue > trend.value ? 1 : -1, indexWay: index === null || trend.index === null ? 1 : Math.sign(index - trend.index) || 1 });\n\n  const width = useMotionValue(0);\n  const unitShare = useMotionValue(1 / count);\n  const scale = useMotionValue(top);\n  const mean = useMotionValue(0);\n  const cursor = useMotionValue(0);\n  const frame = useMemo(() => ({ width, unit: unitShare }), [width, unitShare]);\n\n  // The chart draws in its own pixels; the width is read before paint and followed on resize. Axis labels wait for it, then thin out where they would collide.\n  const [plotWidth, setPlotWidth] = useState(0);\n  const [labelSizes, setLabelSizes] = useState<Record<string, number>>({});\n  const measure = useRef<HTMLSpanElement>(null);\n  const labelTexts = [...new Set(data.map(item => item.axisLabel).filter((text): text is string => !!text))].join(\"\\n\");\n  useLayoutEffect(() => {\n    const node = plot.current, ruler = measure.current;\n    if (!node || !ruler) return;\n    const read = () => {\n      width.set(node.clientWidth);\n      setPlotWidth(node.clientWidth);\n      const sizes: Record<string, number> = {};\n      ruler.querySelectorAll<HTMLElement>(\"[data-text]\").forEach(item => { sizes[item.dataset.text ?? \"\"] = item.offsetWidth; });\n      setLabelSizes(current => Object.keys(sizes).every(text => current[text] === sizes[text]) ? current : sizes);\n    };\n    read();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(read);\n    observer.observe(node);\n    observer.observe(ruler);\n    return () => observer.disconnect();\n  }, [labelTexts, width]);\n  const shownLabels = useMemo(() => fittingLabels(data, plotWidth, labelSizes), [data, plotWidth, labelSizes]);\n\n  // A new range zooms the time axis around the latest bar, rescales the gridlines, and moves the average, all on springs that can be retargeted mid-flight.\n  useEffect(() => {\n    if (reduced) { unitShare.jump(1 / count); scale.jump(top); return; }\n    return soon(() => { const controls = [animate(unitShare, 1 / count, zoom), animate(scale, top, settle)]; return { stop: () => controls.forEach(control => control.stop()) }; });\n  }, [count, reduced, scale, top, unitShare]);\n  // The average rises with the first bars, then springs to each new range.\n  const meanStarted = useRef(false);\n  useEffect(() => {\n    if (reduced) { mean.jump(average); return; }\n    if (!inView) return;\n    const first = !meanStarted.current;\n    meanStarted.current = true;\n    return soon(() => animate(mean, average, first ? { ...settle, delay: .12 } : settle));\n  }, [average, inView, mean, reduced]);\n\n  // The cursor appears on the first scrubbed bar and glides between the ones after it.\n  const wasScrubbing = useRef(false);\n  useEffect(() => {\n    if (index === null) { wasScrubbing.current = false; return; }\n    const place = last - index;\n    if (reduced || !wasScrubbing.current) cursor.jump(place);\n    wasScrubbing.current = true;\n    if (reduced) return;\n    const controls = animate(cursor, place, glide);\n    return () => controls.stop();\n  }, [cursor, index, last, reduced]);\n\n  const cursorX = useTransform(() => Math.round(centerOf(frame, cursor.get())) + .5);\n  const meanY = useTransform(() => Math.round(height - (mean.get() / scale.get()) * (height - TOP)) + .5);\n  const meanText = useTransform(() => `Avg ${formatValue(Math.round(mean.get()))}`);\n\n  const pointAt = (clientX: number) => {\n    const rect = plot.current?.getBoundingClientRect();\n    if (!rect?.width) return last;\n    const place = Math.floor(((rect.right - clientX) / rect.width) * count);\n    return clamp(last - place, 0, last);\n  };\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (event.key === \"Escape\" && scrubbing) { event.preventDefault(); setActive(null); return; }\n    const from = index ?? last + 1, page = Math.min(7, count);\n    const next = ({ ArrowLeft: from - 1, ArrowDown: from - 1, ArrowRight: index === null ? last : from + 1, ArrowUp: index === null ? last : from + 1, PageDown: from - page, PageUp: from + page, Home: 0, End: last } as Record<string, number>)[event.key];\n    if (next === undefined || last < 0) return;\n    event.preventDefault();\n    setActive(clamp(next, 0, last));\n  };\n  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (event.pointerType !== \"mouse\") event.currentTarget.setPointerCapture?.(event.pointerId);\n    setActive(pointAt(event.clientX));\n  };\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => { if (event.pointerType === \"mouse\" || event.buttons) setActive(pointAt(event.clientX)); };\n  const release = (event: PointerEvent<HTMLDivElement>) => { if (event.pointerType !== \"mouse\") setActive(null); };\n\n  const scrubbed = scrubbing ? data[index] : null;\n  const reading = scrubbed ?? data[last];\n  const valueText = reading ? `${reading.label}: ${formatValue(reading.value)}${suffix}` : \"No data\";\n  const highest = data.reduce<BarChartDatum | null>((best, item) => !best || item.value > best.value ? item : best, null);\n  const lowest = data.reduce<BarChartDatum | null>((best, item) => !best || item.value < best.value ? item : best, null);\n  const summary = `${label}, ${period}. ${averageLabel} ${formatValue(Math.round(average))}${suffix}.${highest ? ` Highest ${highest.label}, ${formatValue(highest.value)}${suffix}.` : \"\"}${lowest && lowest !== highest ? ` Lowest ${lowest.label}, ${formatValue(lowest.value)}${suffix}.` : \"\"}`;\n  const shown = inView || reduced;\n  const avoid = showAverage ? meanY : null;\n\n  return <figure ref={figure} className={styles.figure} aria-labelledby={titleId}>\n    <figcaption id={titleId} className={styles.srOnly}>{label}, {period}</figcaption>\n    <div className={styles.header} aria-hidden=\"true\">\n      <span className={styles.kind}><Swap text={scrubbing ? valueLabel : averageLabel} direction={1} reduced={reduced} /></span>\n      <span className={styles.value}><Roll text={formatValue(scrubbing ? data[index]!.value : Math.round(average))} direction={trend.valueWay} reduced={reduced} />{unit && <span className={styles.unit}>{unit}</span>}</span>\n      <span className={styles.when}><Swap text={scrubbed ? scrubbed.label : period} direction={scrubbing ? trend.indexWay : 1} reduced={reduced} /></span>\n    </div>\n    <div className={styles.chart} data-scrubbing={scrubbing || undefined}>\n      <div ref={plot} className={styles.plot} style={{ height }}>\n        <svg className={styles.svg} width=\"100%\" height={height} role=\"img\" aria-label={summary}>\n          <AnimatePresence initial={false}>{ticks.map(value => <Gridline key={value} value={value} scale={scale} height={height} />)}</AnimatePresence>\n          <motion.line className={styles.cursor} x1={cursorX} x2={cursorX} y1={TOP} y2={height} initial={false} animate={{ opacity: scrubbing ? 1 : 0 }} transition={reduced ? { duration: 0 } : fadeFast} />\n          <AnimatePresence initial={false}>\n            {data.map((item, at) => <Bar key={item.key} id={item.key} place={last - at} value={item.value} delay={known.fresh.get(item.key) ?? at * revealStep} shown={shown} active={index === at} frame={frame} scale={scale} height={height} reduced={reduced} />)}\n          </AnimatePresence>\n          <line className={styles.baseline} x1={0} x2=\"100%\" y1={height - .5} y2={height - .5} />\n          {showAverage && <motion.line className={styles.mean} x1={0} x2=\"100%\" y1={meanY} y2={meanY} initial={false} animate={{ opacity: shown ? 1 : 0 }} transition={reduced ? { duration: 0 } : { duration: duration.standard, delay: .12 }} />}\n        </svg>\n      </div>\n      <div className={styles.gutter} aria-hidden=\"true\">\n        <AnimatePresence initial={false}>{ticks.map(value => <TickLabel key={value} value={value} scale={scale} height={height} avoid={avoid} format={formatValue} />)}</AnimatePresence>\n        {showAverage && <motion.span className={styles.meanLabel} style={{ y: meanY }} initial={false} animate={{ opacity: shown ? 1 : 0 }} transition={reduced ? { duration: 0 } : { duration: duration.standard, delay: .12 }}><motion.span className={styles.tickText}>{meanText}</motion.span></motion.span>}\n      </div>\n      <div className={styles.axis} aria-hidden=\"true\">\n        <AnimatePresence initial={false}>\n          {data.map((item, at) => item.axisLabel && shownLabels.has(item.key) ? <AxisLabel key={`${item.key}:${item.axisLabel}`} text={item.axisLabel} size={labelSizes[item.axisLabel] ?? 0} shift={shownLabels.get(item.key) ?? 0} place={last - at} frame={frame} reduced={reduced} /> : null)}\n        </AnimatePresence>\n        <span ref={measure} className={styles.measure}>{labelTexts.split(\"\\n\").filter(Boolean).map(text => <span key={text} data-text={text}>{text}</span>)}</span>\n      </div>\n      {/* The scrubber lies over the plot: hover or drag across it, or focus it and use the arrow keys. Vertical swipes still scroll the page. */}\n      <div className={styles.scrubber} role=\"slider\" tabIndex={0} aria-label={`${label}, explore by ${categoryLabel.toLowerCase()}`} aria-orientation=\"horizontal\" aria-valuemin={1} aria-valuemax={Math.max(1, data.length)} aria-valuenow={(index ?? last) + 1} aria-valuetext={valueText}\n        onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={release} onPointerCancel={() => setActive(null)} onPointerLeave={event => { if (event.pointerType === \"mouse\") setActive(null); }} onKeyDown={onKeyDown} onBlur={() => setActive(null)}\n        onFocus={event => { if (event.currentTarget.matches(\":focus-visible\") && last >= 0) setActive(current => current ?? last); }} />\n    </div>\n    <div className={styles.srOnly}><table>\n      <caption>{label}, {period}</caption>\n      <thead><tr><th scope=\"col\">{categoryLabel}</th><th scope=\"col\">{label}</th></tr></thead>\n      <tbody>{data.map(item => <tr key={item.key}><th scope=\"row\">{item.label}</th><td>{formatValue(item.value)}{suffix}</td></tr>)}</tbody>\n    </table></div>\n    <p className={styles.srOnly} aria-live=\"polite\" aria-atomic=\"true\">{known.changes ? `${period}. ${averageLabel} ${formatValue(Math.round(average))}${suffix}.` : \"\"}</p>\n  </figure>;\n}\n\nexport default BarChart;\n"
    },
    {
      "path": "registry/components/bar-chart/bar-chart.module.css",
      "type": "registry:component",
      "target": "@components/xzero/bar-chart/bar-chart.module.css",
      "content": ".figure { display: grid; min-width: 0; gap: var(--space-5); margin: 0; color: var(--foreground); }\n\n/* The headline is the readout: the average at rest, the scrubbed bar while exploring. Each line keeps its height, so swaps never move the plot. */\n.header { display: grid; min-width: 0; gap: 2px; }\n.kind, .when { display: block; min-height: calc(1em * var(--leading-body)); font-size: var(--text-sm); line-height: var(--leading-body); }\n.kind { color: var(--text-secondary); }\n.when { color: var(--text-muted); }\n.value { display: flex; min-width: 0; align-items: baseline; gap: 6px; font-family: var(--font-display); font-size: var(--text-3xl); font-weight: 500; letter-spacing: var(--tracking-display); line-height: var(--leading-display); font-variant-numeric: tabular-nums; }\n.unit { color: var(--text-secondary); font-family: var(--font-body); font-size: var(--text-base); font-weight: 400; letter-spacing: var(--tracking-body); }\n.swap { position: relative; display: block; min-width: 0; }\n.swapText { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }\n/* A rolling number: outgoing digits are popped out of flow and anchored right, and the clip hides a digit still rising into a new place. */\n.roll { position: relative; display: inline-flex; justify-content: flex-end; overflow-x: clip; overflow-y: visible; white-space: pre; }\n.digits { position: relative; display: inline-flex; flex: none; }\n.digit { position: relative; display: inline-block; white-space: pre; }\n.sizer { position: absolute; top: 0; left: 0; visibility: hidden; white-space: pre; pointer-events: none; }\n\n/* Plot, right-hand value axis, and the date axis beneath. */\n.chart { position: relative; display: grid; grid-template-columns: minmax(0, 1fr) 52px; grid-template-rows: auto 26px; min-width: 0; }\n.plot { position: relative; grid-area: 1 / 1; min-width: 0; }\n.svg { display: block; overflow: hidden; }\n.grid { stroke: var(--border); stroke-width: 1; shape-rendering: crispEdges; }\n.baseline { stroke: var(--border-strong); stroke-width: 1; shape-rendering: crispEdges; }\n.cursor { stroke: var(--border-strong); stroke-width: 1; shape-rendering: crispEdges; }\n.mean { stroke: var(--text-secondary); stroke-width: 1; stroke-dasharray: 3 3; shape-rendering: crispEdges; }\n/* The series is drawn in a soft step of the accent; the scrubbed bar takes the full accent while its neighbours step back. */\n.bar { fill: color-mix(in oklab, var(--accent) 62%, var(--surface)); transition: fill var(--duration-fast) var(--ease-standard); }\n.chart[data-scrubbing] .bar { fill: color-mix(in oklab, var(--accent) 24%, var(--surface)); }\n/* On a dark surface a lighter mix keeps the hue clear instead of turning muddy. */\n:global([data-theme=\"dark\"]) .bar { fill: color-mix(in oklab, var(--accent) 76%, var(--surface)); }\n:global([data-theme=\"dark\"]) .chart[data-scrubbing] .bar { fill: color-mix(in oklab, var(--accent) 34%, var(--surface)); }\n:global([data-theme=\"dark\"]) .chart .bar[data-active] { fill: var(--accent); }\n.chart .bar[data-active] { fill: var(--accent); }\n\n.gutter { position: relative; grid-area: 1 / 2; min-width: 0; }\n.tick, .meanLabel { position: absolute; top: 0; left: 10px; display: block; height: 0; white-space: nowrap; pointer-events: none; }\n.tickText { display: block; transform: translateY(-50%); color: var(--text-muted); font-size: var(--text-xs); line-height: 1; font-variant-numeric: tabular-nums; }\n.meanLabel .tickText { color: var(--foreground); font-weight: 500; }\n\n.axis { position: relative; grid-area: 2 / 1; min-width: 0; overflow: hidden; }\n.axisLabel { position: absolute; top: 8px; left: 0; color: var(--text-muted); font-size: var(--text-xs); line-height: 1; white-space: nowrap; font-variant-numeric: tabular-nums; }\n\n/* The scrubber covers the plot and its axes. Horizontal drags scrub, vertical swipes still scroll the page. */\n.scrubber { position: absolute; inset: 0; border-radius: calc(var(--radius-control) / 2); outline: none; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; }\n\n.srOnly { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }\n@media (prefers-reduced-motion: reduce) { .bar { transition: none; } }\n.measure { position: absolute; top: 0; left: 0; display: flex; visibility: hidden; pointer-events: none; }\n.measure > span { flex: none; font-size: var(--text-xs); line-height: 1; white-space: nowrap; font-variant-numeric: tabular-nums; }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/bar-chart",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "chart",
      "bar",
      "data"
    ],
    "docs": "https://ui.xzero.co.uk/components/bar-chart",
    "markdown": "https://ui.xzero.co.uk/components/bar-chart/markdown"
  }
}