{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "streamgraph",
  "type": "registry:ui",
  "title": "Streamgraph",
  "description": "Layered streams on a wiggle baseline that morph between ranges, with a layer you can isolate and read week by week.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/streamgraph/streamgraph.tsx",
      "type": "registry:component",
      "target": "@components/xzero/streamgraph/streamgraph.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties, type KeyboardEvent, type PointerEvent, type Ref } from \"react\";\nimport { animate, motion, useInView, useMotionValue, useReducedMotion, useSpring } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./streamgraph.module.css\";\n\nexport interface StreamgraphSeries {\n  /** Stable identity. A layer keeps its place and color across data changes, so switching datasets morphs it. */\n  key: string;\n  /** Name in the legend, the direct label, the tooltip, and the data table. */\n  label: string;\n  /** Any CSS color. Defaults to the chart series for the first four layers (the first in the accent), then neutral steps. */\n  color?: string;\n}\n\nexport interface StreamgraphDatum {\n  /** Stable identity, such as an ISO week. */\n  key: string;\n  /** Full label for the tooltip and screen readers, such as \"Week of Sep 14\". */\n  label: string;\n  /** Short label on the time axis. Leave it out to keep the axis quiet at that point. */\n  axisLabel?: string;\n  /** One value per series key. Missing values count as zero; negatives are treated as zero. */\n  values: Record<string, number | undefined>;\n}\n\n/** Use a streamgraph for how the mix of several parts changes over time, when the overall shape matters more than exact values. */\nexport interface StreamgraphProps {\n  data: StreamgraphDatum[];\n  /** Layers from the centre out: the first runs through the middle of the stream, the rest alternate above and below it. */\n  series: StreamgraphSeries[];\n  /** What is measured, such as \"Support tickets by topic\". Names the chart for assistive technology. */\n  label: string;\n  /** Unit after each value, such as \"tickets\". */\n  unit?: string;\n  /** Plot height in pixels. The width follows the container. */\n  height?: number;\n  /** \"wiggle\" minimises layer slopes for a flowing stream, \"silhouette\" centres the stack, \"zero\" stacks from a flat baseline. */\n  offset?: \"wiggle\" | \"silhouette\" | \"zero\";\n  formatValue?: (value: number, series: StreamgraphSeries) => string;\n  /** Controlled hidden layer keys. A hidden layer thins to nothing and the rest reflow. */\n  hiddenSeries?: string[];\n  defaultHiddenSeries?: string[];\n  onHiddenSeriesChange?: (hidden: string[]) => void;\n  /** Called as the reading moves, and with nulls when it leaves. */\n  onActiveChange?: (index: number | null, seriesKey: string | null) => void;\n  /** Layer toggles above the plot. */\n  legend?: boolean;\n  /** Names each layer inside its widest stretch when it is thick enough to hold the text. */\n  directLabels?: boolean;\n  /** Header of the first column in the screen reader table. */\n  categoryLabel?: string;\n  emptyLabel?: string;\n  ref?: Ref<HTMLElement>;\n  className?: string;\n}\n\ntype Hover = { index: number; key: string | null; y: number | null };\ntype Frame = { lower: number[][]; upper: number[][]; toY: (value: number) => number; center: number[] };\n\nconst { spring } = motionTokens;\nconst physical = ({ visualDuration, bounce }: { visualDuration: number; bounce: number }, restDelta = .0005) => { 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 swell = physical({ visualDuration: .9, bounce: 0 });\nconst glide = physical(spring.snappy, .01);\nconst follow = { stiffness: glide.stiffness, damping: glide.damping, restDelta: .01 };\n\n/** Samples across the plot: every layer is resampled onto the same grid, so datasets of any length morph into each other. */\nconst N = 161;\nconst PAD = 10, GAP = 14;\n/** Neutral steps by layer. Stacked from the centre out, neighbours always sit at least ten steps apart; the accent is kept for the layer in focus. */\n/** The first four layers take the chart series in order (the first in the accent); later layers fall back to neutral steps that alternate in lightness. */\nconst SHADES = [30, 18, 24, 14];\nconst colorOf = (item: StreamgraphSeries, index: number) => item.color ?? (index < 4 ? `var(--series-${index + 1})` : `color-mix(in oklab, var(--foreground) ${SHADES[(index - 4) % SHADES.length]}%, var(--surface))`);\n/** The first layer sits in the middle of the stream and the rest alternate above and below it; a flat baseline stacks them in order. */\nfunction stackOrder(count: number, mode: StreamgraphProps[\"offset\"]) {\n  const order = Array.from({ length: count }, (_, index) => index);\n  if (mode === \"zero\") return order;\n  return order.reduce<number[]>((list, index) => index === 0 ? [0] : index % 2 ? [...list, index] : [index, ...list], []);\n}\nconst grouped = new Intl.NumberFormat(\"en-US\", { maximumFractionDigits: 1 });\nconst clamp = (value: number, low: number, high: number) => Math.min(high, Math.max(low, value));\n\n/** Resamples values onto the shared grid with monotone cubic tangents, so curves never swing past the data or below zero. */\nfunction resample(raw: number[]): number[] {\n  const values = raw.map(value => Math.max(0, value || 0));\n  const out = new Array<number>(N);\n  if (!values.length) return out.fill(0);\n  if (values.length === 1) return out.fill(values[0]!);\n  const last = values.length - 1;\n  const slopes = values.slice(1).map((value, index) => value - values[index]!);\n  const tangents = values.map((_, index) => index === 0 || index === last ? 0 : (Math.sign(slopes[index - 1]!) + Math.sign(slopes[index]!)) * Math.min(Math.abs(slopes[index - 1]!), Math.abs(slopes[index]!), Math.abs(slopes[index - 1]! + slopes[index]!) / 4) || 0);\n  if (last > 1) { tangents[0] = (3 * slopes[0]! - tangents[1]!) / 2; tangents[last] = (3 * slopes[last - 1]! - tangents[last - 1]!) / 2; } else { tangents[0] = tangents[1] = slopes[0]!; }\n  for (let k = 0; k < N; k++) {\n    const at = (k / (N - 1)) * last, seg = Math.min(last - 1, Math.floor(at)), t = at - seg, t2 = t * t, t3 = t2 * t;\n    const a = values[seg]!, b = values[seg + 1]!;\n    const value = (2 * t3 - 3 * t2 + 1) * a + (t3 - 2 * t2 + t) * tangents[seg]! + (3 * t2 - 2 * t3) * b + (t3 - t2) * tangents[seg + 1]!;\n    out[k] = clamp(value, Math.min(a, b), Math.max(a, b));\n  }\n  return out;\n}\n\n/** The baseline under the stack. Wiggle follows Byron and Wattenberg, then removes its linear drift so the stream stays level. */\nfunction baseline(layers: number[][], mode: StreamgraphProps[\"offset\"]): number[] {\n  const g = new Array<number>(N).fill(0);\n  const totals = g.map((_, j) => layers.reduce((sum, layer) => sum + layer[j]!, 0));\n  if (mode === \"zero\" || !layers.length) return g;\n  if (mode === \"silhouette\") return totals.map(total => -total / 2);\n  for (let j = 1; j < N; j++) {\n    let s1 = 0, s2 = 0, below = 0;\n    for (const layer of layers) {\n      const change = layer[j]! - layer[j - 1]!;\n      s1 += layer[j]!;\n      s2 += (below + change / 2) * layer[j]!;\n      below += change;\n    }\n    g[j] = g[j - 1]! - (s1 ? s2 / s1 : 0);\n  }\n  // Least squares line through the centre of the stream, subtracted so it never tilts off the plot.\n  const centers = g.map((value, j) => value + totals[j]! / 2);\n  const mx = (N - 1) / 2, my = centers.reduce((sum, value) => sum + value, 0) / N;\n  let num = 0, den = 0;\n  centers.forEach((value, j) => { num += (j - mx) * (value - my); den += (j - mx) ** 2; });\n  const slope = den ? num / den : 0;\n  return g.map((value, j) => value - (my + slope * (j - mx)));\n}\n\nconst subscribeNothing = () => () => {};\nfunction useReducedMotionSafe() {\n  const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);\n  return !!useReducedMotion() && hydrated;\n}\n\n/** Axis labels that fit: at most one per 72 pixels, counted back from the latest. */\nfunction axisPicks(data: StreamgraphDatum[], width: number) {\n  const labeled = data.flatMap((item, index) => item.axisLabel ? [index] : []);\n  if (!width || !labeled.length) return [];\n  const stride = Math.ceil(labeled.length / Math.max(2, Math.floor(width / 72)));\n  return labeled.reverse().filter((_, rank) => rank % stride === 0).reverse();\n}\n\nexport function Streamgraph({ data, series, label, unit = \"\", height = 260, offset = \"wiggle\", formatValue, hiddenSeries, defaultHiddenSeries, onHiddenSeriesChange, onActiveChange, legend = true, directLabels = true, categoryLabel = \"Date\", emptyLabel = \"No data for this range\", ref, className }: StreamgraphProps) {\n  const reduced = useReducedMotionSafe();\n  const figure = useRef<HTMLElement>(null);\n  const plot = useRef<HTMLDivElement>(null);\n  const tip = useRef<HTMLDivElement>(null);\n  const paths = useRef(new Map<string, SVGPathElement>());\n  const labels = useRef(new Map<string, SVGTextElement>());\n  useImperativeHandle(ref, () => figure.current as HTMLElement);\n  const inView = useInView(figure, { once: true, amount: .3 });\n  const last = data.length - 1;\n  const empty = data.length === 0;\n\n  const [ownHidden, setOwnHidden] = useState<string[]>(defaultHiddenSeries ?? []);\n  const hidden = hiddenSeries ?? ownHidden;\n  const toggle = (key: string) => {\n    const next = hidden.includes(key) ? hidden.filter(item => item !== key) : [...hidden, key];\n    if (hiddenSeries === undefined) setOwnHidden(next);\n    onHiddenSeriesChange?.(next);\n  };\n  const visible = series.filter(item => !hidden.includes(item.key));\n\n  // Each layer becomes a dense curve on the shared grid; the painter blends from what is on screen to it.\n  const signature = `${series.map(item => item.key).join(\",\")}|${data.map(item => series.map(line => item.values[line.key] ?? 0).join(\",\")).join(\";\")}`;\n  const targets = useMemo(() => new Map(series.map(line => [line.key, resample(data.map(item => item.values[line.key] ?? 0))])), [signature]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  const [hover, setHover] = useState<Hover | null>(null);\n  const [seenSignature, setSeenSignature] = useState(signature);\n  if (seenSignature !== signature) { setSeenSignature(signature); setHover(null); }\n  const index = hover && !empty ? Math.min(hover.index, last) : null;\n  const [legendKey, setLegendKey] = useState<string | null>(null);\n  const wanted = hover?.key ?? legendKey;\n  const activeKey = wanted && visible.some(item => item.key === wanted) ? wanted : null;\n\n  const width = useRef(0);\n  const [plotWidth, setPlotWidth] = useState(0);\n  const shapes = useRef(new Map<string, number[]>());\n  const presence = useRef(new Map<string, { value: number; stop: (() => void) | null }>());\n  const frame = useRef<Frame | null>(null);\n  const spots = useRef(new Map<string, number>());\n  const grow = useMotionValue(0);\n  const live = useRef({ series, offset });\n  useLayoutEffect(() => { live.current = { series, offset }; });\n\n  // One writer for every drawn attribute: morphs, toggles, the first swell, and resizes all repaint through here.\n  const paint = useCallback(() => {\n    const w = width.current;\n    if (!w) return;\n    const { series: order, offset: mode } = live.current;\n    const layers = order.map(line => { const shape = shapes.current.get(line.key); const p = presence.current.get(line.key)?.value ?? 1; return shape ? shape.map(value => value * p) : new Array<number>(N).fill(0); });\n    const stack = stackOrder(order.length, mode);\n    const g = baseline(stack.map(i => layers[i]!), mode);\n    const lower: number[][] = new Array(order.length), upper: number[][] = new Array(order.length);\n    let running = g.slice();\n    for (const i of stack) { const layer = layers[i]!; lower[i] = running; running = running.map((value, j) => value + layer[j]!); upper[i] = running; }\n    let lo = Infinity, hi = -Infinity;\n    for (let j = 0; j < N; j++) { lo = Math.min(lo, g[j]!); hi = Math.max(hi, running[j]!); }\n    if (!Number.isFinite(lo) || hi - lo < 1e-9) { lo = -1; hi = 1; }\n    const span = hi - lo, s = grow.get();\n    const center = g.map((value, j) => (value + running[j]!) / 2);\n    const toY = (value: number) => PAD + (1 - (value - lo) / span) * (height - PAD * 2);\n    const yAt = (value: number, j: number) => toY(center[j]! + (value - center[j]!) * s);\n    frame.current = { lower, upper, toY, center };\n    const xs = Array.from({ length: N }, (_, j) => (j / (N - 1)) * w);\n    order.forEach((line, i) => {\n      const top = upper[i]!, bottom = lower[i]!;\n      const node = paths.current.get(line.key);\n      if (node) {\n        let d = \"\";\n        for (let j = 0; j < N; j++) d += `${j ? \"L\" : \"M\"}${xs[j]!.toFixed(1)},${yAt(top[j]!, j).toFixed(1)}`;\n        for (let j = N - 1; j >= 0; j--) d += `L${xs[j]!.toFixed(1)},${yAt(bottom[j]!, j).toFixed(1)}`;\n        node.setAttribute(\"d\", `${d}Z`);\n      }\n      // The label sits where its layer is thickest, and holds its spot until another stretch is clearly thicker.\n      const text = labels.current.get(line.key);\n      if (!text) return;\n      let best = 4;\n      for (let j = 4; j < N - 4; j++) if (top[j]! - bottom[j]! > top[best]! - bottom[best]!) best = j;\n      const held = spots.current.get(line.key);\n      const j = held !== undefined && top[held]! - bottom[held]! >= (top[best]! - bottom[best]!) * .8 ? held : best;\n      spots.current.set(line.key, j);\n      const thickness = (top[j]! - bottom[j]!) / span * (height - PAD * 2) * s;\n      const room = text.getComputedTextLength?.() ?? 60;\n      const x = clamp(xs[j]!, room / 2 + 6, w - room / 2 - 6);\n      text.setAttribute(\"x\", x.toFixed(1));\n      text.setAttribute(\"y\", yAt((top[j]! + bottom[j]!) / 2, j).toFixed(1));\n      text.style.opacity = thickness >= 19 && room + 16 < w ? \"1\" : \"0\";\n    });\n  }, [grow, height]);\n\n  useLayoutEffect(() => {\n    const node = plot.current;\n    if (!node) return;\n    const read = () => { width.current = node.clientWidth; setPlotWidth(node.clientWidth); paint(); };\n    read();\n    if (typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(read);\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [paint]);\n\n  // New data blends every layer from the shape on screen; a layer that arrives grows out of the one below it.\n  const morph = useRef<{ stop: () => void } | null>(null);\n  useEffect(() => {\n    const moves: { key: string; from: number[]; to: number[] }[] = [];\n    for (const [key, to] of targets) {\n      const from = shapes.current.get(key);\n      if (!from || reduced) { shapes.current.set(key, to); continue; }\n      moves.push({ key, from, to });\n    }\n    for (const key of [...shapes.current.keys()]) if (!targets.has(key)) shapes.current.delete(key);\n    morph.current?.stop();\n    if (!moves.length) { paint(); return; }\n    morph.current = animate(0, 1, { ...settle, onUpdate: t => { for (const move of moves) shapes.current.set(move.key, move.from.map((value, j) => value + (move.to[j]! - value) * t)); paint(); }, onComplete: () => { for (const move of moves) shapes.current.set(move.key, move.to); paint(); } });\n    return () => morph.current?.stop();\n  }, [paint, reduced, targets]);\n\n  // A hidden layer thins to nothing while the others close over it.\n  const hiddenKey = hidden.join(\"|\");\n  useEffect(() => {\n    for (const line of series) {\n      const to = hidden.includes(line.key) ? 0 : 1;\n      const track = presence.current.get(line.key);\n      if (!track) { presence.current.set(line.key, { value: to, stop: null }); continue; }\n      if (track.value === to) continue;\n      track.stop?.();\n      if (reduced) { track.value = to; track.stop = null; continue; }\n      const controls = animate(track.value, to, { ...settle, onUpdate: value => { track.value = value; paint(); } });\n      track.stop = () => controls.stop();\n    }\n    paint();\n  }, [hiddenKey, paint, reduced, series.length]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  // The first time the chart is seen, the stream swells out of a hairline along its centre.\n  useEffect(() => {\n    if (empty) return;\n    if (reduced) { grow.jump(1); paint(); return; }\n    if (!inView || grow.get() >= 1) return;\n    const controls = animate(grow, 1, { ...swell, onUpdate: paint });\n    return () => controls.stop();\n  }, [empty, grow, inView, paint, reduced]);\n\n  // The crosshair glides between points; the tooltip follows on its own spring and never leaves the plot.\n  const cursor = useMotionValue(0);\n  const cursorSpring = useSpring(cursor, follow);\n  const tipX = useMotionValue(0), tipY = useMotionValue(0);\n  const tipSpringX = useSpring(tipX, follow), tipSpringY = useSpring(tipY, follow);\n  const wasActive = useRef(false);\n  useLayoutEffect(() => {\n    const bubble = tip.current, w = width.current;\n    if (index === null || !bubble || !w) { wasActive.current = false; return; }\n    const x = last > 0 ? (index / last) * w : w / 2;\n    let y = hover?.y ?? null;\n    const f = frame.current;\n    if (y === null && f && activeKey) {\n      const i = series.findIndex(line => line.key === activeKey), j = Math.round((x / w) * (N - 1));\n      if (i >= 0) y = f.toY((f.upper[i]![j]! + f.lower[i]![j]!) / 2);\n    }\n    const tw = bubble.offsetWidth, th = bubble.offsetHeight;\n    let left = x + GAP;\n    if (left + tw > w) left = x - GAP - tw;\n    const top = clamp((y ?? height / 2) - th / 2, 0, Math.max(0, height - th));\n    const instant = !wasActive.current || reduced;\n    cursor.set(x); tipX.set(clamp(left, 0, Math.max(0, w - tw))); tipY.set(top);\n    if (instant) { cursorSpring.jump(x); tipSpringX.jump(tipX.get()); tipSpringY.jump(top); }\n    wasActive.current = true;\n  });\n\n  const onActive = useRef(onActiveChange);\n  useLayoutEffect(() => { onActive.current = onActiveChange; });\n  useEffect(() => { onActive.current?.(index, activeKey); }, [index, activeKey]);\n\n  // The layer under the pointer, found in the frame that is on screen.\n  const read = (clientX: number, clientY: number): Hover | null => {\n    const rect = plot.current?.getBoundingClientRect(), f = frame.current;\n    if (!rect?.width || empty) return null;\n    const share = clamp((clientX - rect.left) / rect.width, 0, 1), y = clientY - rect.top;\n    const at = last > 0 ? Math.round(share * last) : 0, j = Math.round(share * (N - 1));\n    let key: string | null = null;\n    if (f) series.forEach((line, i) => { if (hidden.includes(line.key)) return; const top = f.toY(f.upper[i]![j]!), bottom = f.toY(f.lower[i]![j]!); if (y >= top - 1 && y <= bottom + 1) key = line.key; });\n    return { index: at, key, y };\n  };\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => { if (event.pointerType === \"mouse\" || event.buttons) setHover(read(event.clientX, event.clientY)); };\n  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => { if (event.pointerType !== \"mouse\") event.currentTarget.setPointerCapture?.(event.pointerId); setHover(read(event.clientX, event.clientY)); };\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {\n    if (empty) return;\n    if (event.key === \"Escape\" && hover) { event.preventDefault(); setHover(null); return; }\n    const from = index ?? last;\n    const keys = visible.map(line => line.key), at = activeKey ? keys.indexOf(activeKey) : -1;\n    const page = Math.max(1, Math.round(data.length / 6));\n    const moves: Record<string, () => Hover> = {\n      ArrowLeft: () => ({ index: clamp(from - 1, 0, last), key: activeKey, y: null }),\n      ArrowRight: () => ({ index: clamp(index === null ? last : from + 1, 0, last), key: activeKey, y: null }),\n      PageDown: () => ({ index: clamp(from - page, 0, last), key: activeKey, y: null }),\n      PageUp: () => ({ index: clamp(from + page, 0, last), key: activeKey, y: null }),\n      Home: () => ({ index: 0, key: activeKey, y: null }),\n      End: () => ({ index: last, key: activeKey, y: null }),\n      ArrowUp: () => ({ index: from, key: keys[at < 0 ? keys.length - 1 : Math.min(keys.length - 1, at + 1)] ?? null, y: null }),\n      ArrowDown: () => ({ index: from, key: keys[at < 0 ? 0 : Math.max(0, at - 1)] ?? null, y: null }),\n    };\n    const move = moves[event.key];\n    if (!move) return;\n    event.preventDefault();\n    setHover(move());\n  };\n\n  const format = (value: number, line: StreamgraphSeries) => `${formatValue ? formatValue(value, line) : grouped.format(value)}${unit ? ` ${unit}` : \"\"}`;\n  const reading = index === null ? null : data[index];\n  const total = (datum: StreamgraphDatum) => visible.reduce((sum, line) => sum + Math.max(0, datum.values[line.key] ?? 0), 0);\n  const announce = reading ? `${reading.label}. ${activeKey ? `${series.find(line => line.key === activeKey)?.label} ${format(reading.values[activeKey] ?? 0, series.find(line => line.key === activeKey)!)}, ` : \"\"}${visible.length} layers, ${grouped.format(total(reading))}${unit ? ` ${unit}` : \"\"} in total` : \"\";\n  const summary = empty ? `${label}. ${emptyLabel}.` : `${label}, ${data[0]!.label} to ${data[last]!.label}. ${visible.map(line => `${line.label}, latest ${format(data[last]!.values[line.key] ?? 0, line)}`).join(\". \")}.`;\n  const picks = axisPicks(data, plotWidth);\n  const tipLeft = reduced ? tipX : tipSpringX, tipTop = reduced ? tipY : tipSpringY;\n\n  return <figure ref={figure} className={[styles.figure, className].filter(Boolean).join(\" \")} aria-label={label}>\n    {legend && series.length > 1 && <div className={styles.legend} role=\"group\" aria-label={`${label} layers`} onPointerLeave={() => setLegendKey(null)}>\n      {series.map((line, at) => <button key={line.key} type=\"button\" className={styles.toggle} aria-pressed={!hidden.includes(line.key)} data-active={activeKey === line.key || undefined} data-dim={(activeKey !== null && activeKey !== line.key) || undefined} onClick={() => toggle(line.key)}\n        onPointerEnter={event => { if (event.pointerType === \"mouse\") setLegendKey(line.key); }} onFocus={event => { if (event.currentTarget.matches(\":focus-visible\")) setLegendKey(line.key); }} onBlur={() => setLegendKey(null)}\n        style={{ \"--series\": colorOf(line, at) } as CSSProperties}>\n        <span className={styles.swatch} aria-hidden=\"true\" />\n        <span className={styles.toggleLabel}>{line.label}</span>\n      </button>)}\n    </div>}\n    <div className={styles.chart} data-active={index !== null || undefined} data-layer={activeKey ? true : undefined}>\n      <div ref={plot} className={styles.plot} style={{ height }} role=\"group\" tabIndex={empty ? -1 : 0} aria-roledescription=\"stream graph\" aria-label={`${label}. Use left and right arrows to move through time, up and down to change layer.`}\n        onPointerMove={onPointerMove} onPointerDown={onPointerDown} onPointerUp={event => { if (event.pointerType !== \"mouse\") setHover(null); }} onPointerCancel={() => setHover(null)} onPointerLeave={event => { if (event.pointerType === \"mouse\") setHover(null); }}\n        onKeyDown={onKeyDown} onBlur={() => setHover(null)} onFocus={event => { if (event.currentTarget.matches(\":focus-visible\") && !empty) setHover(current => current ?? { index: last, key: null, y: null }); }}>\n        <svg className={styles.svg} width=\"100%\" height={height} aria-hidden=\"true\" focusable=\"false\">\n          {series.map((line, at) => <path key={line.key} ref={node => { if (node) paths.current.set(line.key, node); else paths.current.delete(line.key); }} className={styles.layer} data-active={activeKey === line.key || undefined} data-custom={line.color ? true : undefined} style={{ \"--series\": colorOf(line, at) } as CSSProperties} />)}\n          <motion.line className={styles.crosshair} x1={0} x2={0} y1={0} y2={height} style={{ x: reduced ? cursor : cursorSpring }} />\n          {directLabels && series.map(line => <text key={line.key} ref={node => { if (node) labels.current.set(line.key, node); else labels.current.delete(line.key); }} className={styles.label} data-active={activeKey === line.key || undefined} data-custom={line.color ? true : undefined} data-hidden={hidden.includes(line.key) || undefined} data-dim={(activeKey !== null && activeKey !== line.key) || undefined} textAnchor=\"middle\" dominantBaseline=\"central\">{line.label}</text>)}\n        </svg>\n        <motion.div ref={tip} className={styles.tooltip} style={{ x: tipLeft, y: tipTop }} aria-hidden=\"true\">\n          <p className={styles.tipTitle}>{reading?.label ?? \"\"}</p>\n          {stackOrder(series.length, offset).reverse().map(at => series[at]!).filter(line => !hidden.includes(line.key)).map(line => <p key={line.key} className={styles.tipRow} data-active={activeKey === line.key || undefined} data-dim={(activeKey !== null && activeKey !== line.key) || undefined} style={{ \"--series\": colorOf(line, series.indexOf(line)) } as CSSProperties}>\n            <span className={styles.tipSwatch} />\n            <span className={styles.tipName}>{line.label}</span>\n            <span className={styles.tipValue}>{reading ? format(reading.values[line.key] ?? 0, line) : \"\"}</span>\n          </p>)}\n        </motion.div>\n        {(empty || visible.length === 0) && <p className={styles.message}>{empty ? emptyLabel : \"Choose a layer to show\"}</p>}\n      </div>\n      <div className={styles.axis} aria-hidden=\"true\">\n        {picks.map(at => { const share = last > 0 ? at / last : .5; const item = data[at]!; return <span key={`${item.key}`} className={styles.axisLabel} style={{ left: `${share * 100}%`, translate: `${-share * 100}% 0` }}>{item.axisLabel}</span>; })}\n      </div>\n    </div>\n    <p className={styles.srOnly} aria-live=\"polite\" aria-atomic=\"true\">{announce}</p>\n    <p className={styles.srOnly}>{summary}</p>\n    {!empty && <div className={styles.srOnly}><table>\n      <caption>{label}</caption>\n      <thead><tr><th scope=\"col\">{categoryLabel}</th>{series.map(line => <th key={line.key} scope=\"col\">{line.label}</th>)}</tr></thead>\n      <tbody>{data.map(item => <tr key={item.key}><th scope=\"row\">{item.label}</th>{series.map(line => <td key={line.key}>{format(item.values[line.key] ?? 0, line)}</td>)}</tr>)}</tbody>\n    </table></div>}\n  </figure>;\n}\n\nexport default Streamgraph;\n"
    },
    {
      "path": "registry/components/streamgraph/streamgraph.module.css",
      "type": "registry:component",
      "target": "@components/xzero/streamgraph/streamgraph.module.css",
      "content": ".figure { display: grid; min-width: 0; gap: var(--space-4); margin: 0; color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }\n\n/* Series colors (--series-1 to --series-4) come from registry/foundation.css. */\n\n/* Layer toggles: a rect swatch mirrors the area it names. Hover or focus previews that layer in the plot. */\n.legend { display: flex; flex-wrap: wrap; gap: 2px 4px; min-width: 0; margin-inline: -8px; }\n.toggle { display: inline-flex; min-height: 32px; align-items: center; gap: 8px; padding: 0 10px 0 8px; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-secondary); cursor: pointer; font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), opacity var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }\n.toggle:active { transform: scale(.97); }\n.toggle[data-active] { color: var(--foreground); background: color-mix(in oklab, var(--foreground) 5%, transparent); }\n.toggle[data-dim] { opacity: .56; }\n.toggle[aria-pressed=\"false\"] { color: var(--text-muted); }\n.toggleLabel { white-space: nowrap; }\n.toggle[aria-pressed=\"false\"] .toggleLabel { opacity: .72; text-decoration: line-through; text-decoration-color: color-mix(in oklab, currentColor 55%, transparent); }\n.swatch { width: 10px; height: 10px; flex: none; border-radius: 3px; background: var(--series); transition: transform var(--duration-standard) var(--ease-spring), opacity var(--duration-fast) var(--ease-standard); }\n.toggle[aria-pressed=\"false\"] .swatch { opacity: .35; transform: scale(.6); }\n@media (hover: hover) and (pointer: fine) { .toggle:hover { color: var(--foreground); } }\n\n.chart { position: relative; display: grid; grid-template-rows: auto 26px; min-width: 0; }\n.plot { position: relative; min-width: 0; outline: none; touch-action: pan-y; user-select: none; -webkit-user-select: none; -webkit-tap-highlight-color: transparent; cursor: crosshair; }\n.svg { display: block; overflow: visible; }\n\n/* A 2px surface stroke is the gap between neighbouring layers. */\n.layer { fill: color-mix(in oklab, var(--series) 82%, var(--surface)); stroke: var(--surface); stroke-width: 2; stroke-linejoin: round; transition: opacity var(--duration-fast) var(--ease-standard), fill var(--duration-standard) var(--ease-standard); }\n.chart[data-layer] .layer:not([data-active]) { opacity: .34; }\n.layer[data-active] { fill: var(--series); }\n\n.crosshair { stroke: var(--foreground); stroke-opacity: .5; stroke-width: 1; shape-rendering: crispEdges; opacity: 0; transition: opacity var(--duration-instant) var(--ease-standard); pointer-events: none; }\n.chart[data-active] .crosshair { opacity: 1; }\n\n.label { fill: var(--foreground); font-size: var(--text-xs); font-weight: 500; paint-order: stroke; stroke: var(--surface); stroke-width: 3px; stroke-opacity: .85; stroke-linejoin: round; pointer-events: none; transition: opacity var(--duration-standard) var(--ease-standard), fill var(--duration-fast) var(--ease-standard); }\n.label[data-hidden] { opacity: 0 !important; }\n.chart[data-layer] .label[data-dim] { opacity: 0 !important; }\n\n.tooltip { position: absolute; top: 0; left: 0; z-index: 1; display: grid; min-width: 156px; max-width: min(256px, 100%); gap: 4px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface-raised); box-shadow: var(--shadow-floating); opacity: 0; pointer-events: none; scale: .96; transition: opacity var(--duration-fast) var(--ease-standard), scale var(--duration-fast) var(--ease-standard); }\n.chart[data-active] .tooltip { opacity: 1; scale: 1; }\n.tipTitle { margin: 0 0 2px; color: var(--text-secondary); font-size: var(--text-xs); line-height: var(--leading-body); white-space: nowrap; font-variant-numeric: tabular-nums; }\n.tipRow { display: flex; min-width: 0; align-items: center; gap: 8px; margin: 0; font-size: var(--text-sm); line-height: var(--leading-body); transition: opacity var(--duration-fast) var(--ease-standard); }\n.tipRow[data-dim] { opacity: .5; }\n.tipSwatch { width: 10px; height: 2.5px; flex: none; border-radius: 2px; background: var(--series); }\n.tipName { min-width: 0; flex: 1; overflow: hidden; color: var(--text-secondary); text-overflow: ellipsis; white-space: nowrap; }\n.tipRow[data-active] .tipName { color: var(--foreground); }\n.tipValue { flex: none; margin-left: 8px; color: var(--foreground); font-weight: 500; font-variant-numeric: tabular-nums; white-space: nowrap; }\n\n.message { position: absolute; inset: 0; display: grid; place-items: center; margin: 0; color: var(--text-muted); font-size: var(--text-sm); pointer-events: none; }\n\n.axis { position: relative; min-width: 0; }\n.axisLabel { position: absolute; top: 8px; color: var(--text-muted); font-size: var(--text-xs); line-height: 1; white-space: nowrap; font-variant-numeric: tabular-nums; transition: opacity var(--duration-standard) var(--ease-standard); }\n/* New labels fade in while the stream morphs to a new range instead of popping in a single frame. */\n@starting-style { .axisLabel { opacity: 0; } }\n\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n@media (prefers-reduced-motion: reduce) {\n  .toggle, .swatch, .layer, .crosshair, .label, .tooltip, .tipRow, .axisLabel { transition: none; }\n}\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/streamgraph",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "data",
      "chart",
      "new"
    ],
    "docs": "https://ui.xzero.co.uk/components/streamgraph",
    "markdown": "https://ui.xzero.co.uk/components/streamgraph/markdown"
  }
}