{
  "$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://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/gauge/gauge.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useInView,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useTransform,\n  type Variants,\n} from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\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 {\n  from: number;\n  tone: GaugeTone;\n  label: string;\n}\nexport interface GaugeProps {\n  value: number;\n  min?: number;\n  max?: number;\n  label: string;\n  detail?: string;\n  tone?: GaugeTone;\n  thresholds?: GaugeThreshold[];\n}\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 = {\n  hidden: (direction: number) => ({\n    opacity: 0,\n    y: `${0.3 * direction}em`,\n    filter: `blur(${motionTokens.blur.soft}px)`,\n  }),\n  shown: {\n    opacity: 1,\n    y: 0,\n    filter: \"blur(0px)\",\n    transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] },\n  },\n  gone: (direction: number) => ({\n    opacity: 0,\n    y: `${-0.3 * direction}em`,\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n    transition: { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.standard] },\n  }),\n};\n// Same keys as `rise` so the settled style is identical whichever branch renders on the server.\nconst fade: Variants = {\n  hidden: { opacity: 0, y: 0, filter: \"blur(0px)\" },\n  shown: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } },\n  gone: { opacity: 0, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.instant } },\n};\n/** Each tone sets the ring colour and the status text colour the readout reads. */\nconst tones = {\n  accent: \"[--gauge-color:var(--accent)] [--gauge-text:var(--accent-ink)]\",\n  success: \"[--gauge-color:var(--green)] [--gauge-text:var(--green)]\",\n  warning: \"[--gauge-color:var(--orange)] [--gauge-text:var(--orange)]\",\n  danger: \"[--gauge-color:var(--red)] [--gauge-text:var(--red)]\",\n} as const;\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. */\nconst ring =\n  \"fill-none [stroke-width:9] [stroke-linecap:round] transition-[stroke] duration-standard ease-standard motion-reduce:transition-none\";\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 (\n    <span className=\"relative block\">\n      <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n        <motion.span\n          key={text}\n          className=\"inline-block\"\n          custom={direction}\n          variants={reduceMotion ? fade : rise}\n          initial=\"hidden\"\n          animate=\"shown\"\n          exit=\"gone\"\n        >\n          {text}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\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,\n  start = -135,\n  span = 270;\nfunction arcFor(sweep: number) {\n  const end = start + span * Math.min(1, Math.max(0, sweep));\n  const at = (angle: number) =>\n    `${(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: 0.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 / 0.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) {\n      sweep.jump(percentage);\n      count.jump(percentage * 100);\n      const frame = requestAnimationFrame(() => setFilled(true));\n      return () => cancelAnimationFrame(frame);\n    }\n    if (!inView) return;\n    const arc = animate(sweep, percentage, filled ? motionTokens.spring.morph : reveal);\n    const number = animate(count, percentage * 100, {\n      ...(filled ? motionTokens.spring.smooth : reveal),\n      onComplete: () => setFilled(true),\n    });\n    return () => {\n      arc.stop();\n      number.stop();\n    };\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) {\n      if (changed) setLive(next);\n    } else if (inView && Math.abs(current - percentage * 100) < 1) {\n      if (changed) setLive(next);\n      setFilled(true);\n    }\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)\n        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 (\n    <figure\n      ref={ref}\n      className={cn(\"m-0 grid min-w-0 justify-items-center gap-[10px] text-center\", tones[activeTone])}\n      aria-label={`${label}: ${value} of ${safeMax}${band ? `, ${band.label}` : \"\"}`}\n    >\n      <div\n        className=\"relative aspect-[100/90] w-[min(100%,176px)]\"\n        role=\"meter\"\n        aria-label={label}\n        aria-valuemin={min}\n        aria-valuemax={safeMax}\n        aria-valuenow={Math.min(safeMax, Math.max(min, value))}\n        aria-valuetext={`${displayValue}%${band ? `, ${band.label}` : \"\"}`}\n      >\n        <svg className=\"block size-full overflow-visible\" viewBox=\"0 0 100 90\" aria-hidden=\"true\" focusable=\"false\">\n          <path className={cn(ring, \"stroke-[color-mix(in_oklab,var(--gauge-color)_13%,transparent)]\")} d={track} />\n          <motion.path className={cn(ring, \"stroke-(color:--gauge-color)\")} d={path} style={{ opacity: arcOpacity }} />\n        </svg>\n        {/* The readout sits on the ring's true centre, which is the middle of a square as wide as the ring. */}\n        <div\n          className=\"pointer-events-none absolute top-0 left-0 grid aspect-square w-full place-content-center justify-items-center gap-1\"\n          aria-hidden=\"true\"\n        >\n          <span className=\"relative inline-flex items-baseline font-display text-(length:--text-3xl) leading-none font-medium tracking-display text-ink tabular-nums\">\n            <motion.span className=\"inline-flex justify-end whitespace-nowrap\" style={{ width: digitsWidth }}>\n              {digits}\n            </motion.span>\n            <span className=\"ml-px font-body text-[.5em] tracking-[0] text-ink-2\">%</span>\n            <span ref={sizer} className=\"pointer-events-none invisible absolute top-0 left-0 whitespace-nowrap\">\n              {displayValue}\n            </span>\n          </span>\n          {thresholds && (\n            <span className=\"min-h-[1.4em] min-w-[100px] text-(length:--text-xs) leading-[1.4] font-medium text-(color:--gauge-text) transition-[color] duration-standard ease-standard motion-reduce:transition-none\">\n              <Swap text={filled ? (shown?.label ?? \"\") : \"\"} direction={moved.direction} />\n            </span>\n          )}\n        </div>\n      </div>\n      <figcaption className=\"grid min-w-0 gap-[3px]\">\n        <strong className=\"text-(length:--text-sm) font-medium\">\n          <Swap text={label} />\n        </strong>\n        {detail && (\n          <span className=\"text-(length:--text-xs) text-ink-3 tabular-nums\">\n            <Swap text={detail} direction={moved.direction} />\n          </span>\n        )}\n      </figcaption>\n    </figure>\n  );\n}\n\nexport default Gauge;\n",
      "type": "registry:component",
      "target": "@components/iris/gauge/gauge.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/gauge",
  "meta": {
    "kind": "component",
    "group": "Charts",
    "export": "Gauge",
    "tags": [
      "data",
      "chart"
    ],
    "docs": "https://irisui.dev/components/gauge"
  }
}
