{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "gauge",
  "type": "registry:ui",
  "title": "Gauge",
  "description": "Show a value against a known range.",
  "categories": [
    "data"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/gauge/gauge.tsx",
      "type": "registry:component",
      "target": "@components/xzero/gauge/gauge.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { AnimatePresence, animate, motion, useInView, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform, type Variants } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./gauge.module.css\";\n\ntype GaugeTone = \"accent\" | \"success\" | \"warning\" | \"danger\";\n/** A band that starts at `from`: the highest band the value reaches sets the tone and names the state, so it never rests on color alone. */\nexport interface GaugeThreshold { from: number; tone: GaugeTone; label: string; }\nexport interface GaugeProps { value: number; min?: number; max?: number; label: string; detail?: string; tone?: GaugeTone; thresholds?: GaugeThreshold[]; }\n\n/** Copy enters from the side the value moved toward: a rise comes up from below, a fall drops from above. */\nconst rise: Variants = { hidden: (direction: number) => ({ opacity: 0, y: `${.3 * direction}em`, filter: `blur(${motionTokens.blur.soft}px)` }), shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } }, gone: (direction: number) => ({ opacity: 0, y: `${-.3 * direction}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] } }) };\n// Same keys as `rise` so the settled style is identical whichever branch renders on the server.\nconst fade: Variants = { hidden: { opacity: 0, y: 0, filter: \"blur(0px)\" }, shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } }, gone: { opacity: 0, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } } };\n/** The first fill is slower and never overshoots, like a ring closing when the view opens. */\nconst reveal = { ...motionTokens.spring.smooth, visualDuration: motionTokens.duration.considered * 1.6 };\n\n/** New copy rises in while the old copy leaves, popped out of flow so the line never holds both. */\nfunction Swap({ text, direction = 1 }: { text: string; direction?: number }) {\n  const reduceMotion = !!useReducedMotion();\n  return <span className={styles.swap}><AnimatePresence mode=\"popLayout\" initial={false} custom={direction}><motion.span key={text} className={styles.text} custom={direction} variants={reduceMotion ? fade : rise} initial=\"hidden\" animate=\"shown\" exit=\"gone\">{text}</motion.span></AnimatePresence></span>;\n}\n\n/** A 270 degree ring open at the bottom, drawn clockwise from its lower left end. Redrawn from the sweep every frame so the round cap stays exact. */\nconst radius = 42, start = -135, span = 270;\nfunction arcFor(sweep: number) {\n  const end = start + span * Math.min(1, Math.max(0, sweep));\n  const at = (angle: number) => `${(50 + radius * Math.sin((angle * Math.PI) / 180)).toFixed(3)} ${(50 - radius * Math.cos((angle * Math.PI) / 180)).toFixed(3)}`;\n  return `M ${at(start)} A ${radius} ${radius} 0 ${end - start > 180 ? 1 : 0} 1 ${at(end)}`;\n}\nconst track = arcFor(1);\n\nexport function Gauge({ value, min = 0, max = 100, label, detail, tone = \"accent\", thresholds }: GaugeProps) {\n  const safeMax = max > min ? max : min + 1;\n  const percentage = Math.min(1, Math.max(0, (value - min) / (safeMax - min)));\n  const displayValue = Math.round(percentage * 100);\n  const bands = [...thresholds ?? []].sort((a, b) => a.from - b.from);\n  const bandAt = (at: number) => bands.filter(threshold => at >= threshold.from - 1e-6).pop();\n  const band = bandAt(value);\n  const ref = useRef<HTMLElement>(null);\n  const sizer = useRef<HTMLSpanElement>(null);\n  const inView = useInView(ref, { once: true, amount: .5 });\n  const reduceMotion = !!useReducedMotion();\n  const sweep = useMotionValue(0);\n  const count = useMotionValue(0);\n  const digitsWidth = useMotionValue<number | \"auto\">(\"auto\");\n  const [filled, setFilled] = useState(false);\n  const [live, setLive] = useState(band);\n  const path = useTransform(sweep, arcFor);\n  // The cap fades in over the first sliver instead of popping in as a dot.\n  const arcOpacity = useTransform(sweep, current => Math.min(1, Math.max(0, current / .02)));\n  const digits = useTransform(count, current => String(Math.round(Math.min(100, Math.max(0, current)))));\n  const [moved, setMoved] = useState({ value: displayValue, direction: 1 });\n  if (moved.value !== displayValue) setMoved({ value: displayValue, direction: displayValue < moved.value ? -1 : 1 });\n\n  // The ring fills once it is seen, then springs to each new value with a little life; the count follows without overshooting.\n  useEffect(() => {\n    // Reduced motion lands on the value at once; the state appears a frame later, after hydration has matched the server.\n    if (reduceMotion) { sweep.jump(percentage); count.jump(percentage * 100); const frame = requestAnimationFrame(() => setFilled(true)); return () => cancelAnimationFrame(frame); }\n    if (!inView) return;\n    const arc = animate(sweep, percentage, filled ? motionTokens.spring.morph : reveal);\n    const number = animate(count, percentage * 100, { ...(filled ? motionTokens.spring.smooth : reveal), onComplete: () => setFilled(true) });\n    return () => { arc.stop(); number.stop(); };\n  }, [count, filled, inView, percentage, reduceMotion, sweep]);\n\n  // The state arrives as the first fill lands. After that it follows the number on screen, so the color and label change exactly as the count crosses a threshold.\n  useMotionValueEvent(count, \"change\", current => {\n    const next = bandAt(min + (current / 100) * (safeMax - min));\n    const changed = next?.from !== live?.from || next?.label !== live?.label;\n    if (filled) { if (changed) setLive(next); }\n    else if (inView && Math.abs(current - percentage * 100) < 1) { if (changed) setLive(next); setFilled(true); }\n  });\n  const shown = reduceMotion || !filled ? band : live;\n  const activeTone = shown?.tone ?? tone;\n\n  // Digits are right aligned in a box sized to the target number, so the ones column never jumps while counting and the group recentres on a spring.\n  useEffect(() => {\n    const node = sizer.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    let measured: string | null = null;\n    const observer = new ResizeObserver(([entry]) => {\n      if (!entry) return;\n      const next = entry.borderBoxSize?.[0]?.inlineSize ?? node.offsetWidth;\n      if (measured !== null && measured !== node.textContent && !reduceMotion) animate(digitsWidth, next, motionTokens.spring.smooth);\n      else digitsWidth.jump(next);\n      measured = node.textContent;\n    });\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, [digitsWidth, reduceMotion]);\n\n  return <figure ref={ref} className={[styles.figure, styles[activeTone]].join(\" \")} aria-label={`${label}: ${value} of ${safeMax}${band ? `, ${band.label}` : \"\"}`}>\n    <div className={styles.visual} role=\"meter\" aria-label={label} aria-valuemin={min} aria-valuemax={safeMax} aria-valuenow={Math.min(safeMax, Math.max(min, value))} aria-valuetext={`${displayValue}%${band ? `, ${band.label}` : \"\"}`}>\n      <svg viewBox=\"0 0 100 90\" aria-hidden=\"true\" focusable=\"false\"><path className={styles.track} d={track} /><motion.path className={styles.arc} d={path} style={{ opacity: arcOpacity }} /></svg>\n      <div className={styles.readout} aria-hidden=\"true\">\n        <span className={styles.number}><motion.span className={styles.digits} style={{ width: digitsWidth }}>{digits}</motion.span><span className={styles.unit}>%</span><span ref={sizer} className={styles.sizer}>{displayValue}</span></span>\n        {thresholds && <span className={styles.status}><Swap text={filled ? shown?.label ?? \"\" : \"\"} direction={moved.direction} /></span>}\n      </div>\n    </div>\n    <figcaption><strong><Swap text={label} /></strong>{detail && <span className={styles.detail}><Swap text={detail} direction={moved.direction} /></span>}</figcaption>\n  </figure>;\n}\n\nexport default Gauge;\n"
    },
    {
      "path": "registry/components/gauge/gauge.module.css",
      "type": "registry:component",
      "target": "@components/xzero/gauge/gauge.module.css",
      "content": ".figure { display: grid; justify-items: center; min-width: 0; gap: 10px; margin: 0; text-align: center; }.figure figcaption { display: grid; gap: 3px; min-width: 0; }.figure strong { font-size: var(--text-sm); font-weight: 500; }.detail { color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; }\n.accent { --gauge-color: var(--accent); --gauge-text: var(--accent-strong); }.success { --gauge-color: var(--success); --gauge-text: var(--success); }.warning { --gauge-color: var(--warning); --gauge-text: var(--warning); }.danger { --gauge-color: var(--danger); --gauge-text: var(--danger); }\n\n/* An activity style ring: a thick stroke with round caps over a faint tint of its own color. The stroke scales with the ring. */\n.visual { position: relative; width: min(100%, 176px); aspect-ratio: 100 / 90; }\n.visual svg { display: block; width: 100%; height: 100%; overflow: visible; }\n.track, .arc { fill: none; stroke-width: 9; stroke-linecap: round; transition: stroke var(--duration-standard) var(--ease-standard); }\n.track { stroke: color-mix(in oklab, var(--gauge-color) 13%, transparent); }\n.arc { stroke: var(--gauge-color); }\n\n/* The readout sits on the ring's true centre, which is the middle of a square as wide as the ring. */\n.readout { position: absolute; top: 0; left: 0; display: grid; width: 100%; aspect-ratio: 1; place-content: center; justify-items: center; gap: 4px; pointer-events: none; }\n.number { position: relative; display: inline-flex; align-items: baseline; color: var(--foreground); font-family: var(--font-display); font-size: var(--text-3xl); font-weight: 500; letter-spacing: var(--tracking-display); line-height: 1; font-variant-numeric: tabular-nums; }\n.digits { display: inline-flex; justify-content: flex-end; white-space: nowrap; }\n.unit { margin-left: 1px; color: var(--text-secondary); font-family: var(--font-body); font-size: .5em; letter-spacing: 0; }\n.sizer { position: absolute; top: 0; left: 0; visibility: hidden; white-space: nowrap; pointer-events: none; }\n.status { min-width: 100px; min-height: 1.4em; color: var(--gauge-text); font-size: var(--text-xs); font-weight: 500; line-height: 1.4; transition: color var(--duration-standard) var(--ease-standard); }\n\n/* Label, status and detail copy swap in place: the outgoing line is popped out of flow while the new one rises in. */\n.swap { position: relative; display: block; }\n.swap > .text { display: inline-block; }\n@media (prefers-reduced-motion: reduce) { .track, .arc, .status { transition: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/gauge",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "data",
      "chart"
    ],
    "docs": "https://ui.xzero.co.uk/components/gauge",
    "markdown": "https://ui.xzero.co.uk/components/gauge/markdown"
  }
}