{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rating",
  "type": "registry:ui",
  "title": "Rating",
  "description": "A simple, accessible way to capture a numeric opinion.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "lucide-react",
    "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/rating/rating.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport type { KeyboardEvent, MouseEvent, PointerEvent } from \"react\";\nimport { Star } from \"lucide-react\";\nimport { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform } from \"motion/react\";\nimport type { Variants } from \"motion/react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport interface RatingProps {\n  label: string;\n  value?: number;\n  defaultValue?: number;\n  /** Receives the committed value; 0 means the rating was cleared. */\n  onChange?: (value: number) => void;\n  max?: number;\n  readOnly?: boolean;\n  disabled?: boolean;\n  description?: string;\n  size?: \"sm\" | \"md\" | \"lg\";\n  className?: string;\n  /** Pointer, touch, and arrow keys move in half stars. */\n  allowHalf?: boolean;\n  /** Choosing the current value again clears it; Backspace and Delete clear from the keyboard. */\n  allowClear?: boolean;\n  /** Show the word or number for the previewed value beside the stars. */\n  showValue?: boolean;\n  /** One word per whole star. Five whole stars default to \"Poor\" through \"Excellent\"; anything else reads \"4 out of 5\". */\n  valueLabels?: readonly string[];\n  /** Shown beside empty stars. */\n  emptyLabel?: string;\n  /** Submits the value with a surrounding form. */\n  name?: string;\n}\n\nconst defaultWords = [\"Poor\", \"Fair\", \"Good\", \"Great\", \"Excellent\"] as const;\n/** spring.snappy in physical terms. Time-defined springs drop initial velocity, and a pop is nothing but velocity. */\nconst snappyRoot = (2 * Math.PI) / (motionTokens.spring.snappy.visualDuration * 1.2);\nconst popSpring = {\n  type: \"spring\",\n  stiffness: snappyRoot ** 2,\n  damping: 2 * (1 - motionTokens.spring.snappy.bounce) * snappyRoot,\n  mass: 1,\n} as const;\n/** Scale per second handed to a resting star: it peaks near 1.18, then the spring settles it back to 1. */\nconst popVelocity = 9;\n/** Commits closer together than this pop only the chosen star, so held arrow keys stay calm. */\nconst waveGap = 260;\nconst still = { duration: 0 } as const;\n/** Each cell is wider than its star; the stars' negative inline margin lines the first star up with the label. */\nconst sizes = {\n  sm: \"[--cell:28px] [--glyph:18px]\",\n  md: \"[--cell:36px] [--glyph:24px]\",\n  lg: \"[--cell:44px] [--glyph:30px]\",\n} as const;\n/** The radio covers the cell; with half stars a second radio takes the leading half. */\nconst hit =\n  \"absolute inset-0 m-0 cursor-pointer rounded-[inherit] border-0 bg-transparent p-0 [-webkit-tap-highlight-color:transparent] focus-visible:outline-none disabled:cursor-default\";\nconst layer = \"grid [grid-area:1/1] [&_svg]:size-(--glyph)\";\n\n/** Changed words roll in the direction the value moved; unchanged words stay put. */\nconst wordMotion: Variants = {\n  enter: (direction: number) => ({\n    opacity: 0,\n    y: `${direction * 0.3}em`,\n    filter: `blur(${motionTokens.blur.soft}px)`,\n  }),\n  center: {\n    opacity: 1,\n    y: 0,\n    filter: \"blur(0px)\",\n    transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] },\n  },\n  exit: (direction: number) => ({\n    opacity: 0,\n    y: `${direction * -0.25}em`,\n    filter: `blur(${motionTokens.blur.subtle}px)`,\n    transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] },\n  }),\n};\n\nfunction RollingText({ text, direction, reduced }: { text: string; direction: number; reduced: boolean }) {\n  const words = text.split(/(\\s+)/);\n  return (\n    <span className=\"relative block\">\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden=\"true\">\n        <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n          {words.map((word, index) => (\n            <motion.span\n              key={`${index}:${word}`}\n              className=\"inline-block whitespace-pre\"\n              custom={direction}\n              variants={wordMotion}\n              initial={reduced ? false : \"enter\"}\n              animate=\"center\"\n              exit={reduced ? undefined : \"exit\"}\n            >\n              {word}\n            </motion.span>\n          ))}\n        </AnimatePresence>\n      </span>\n    </span>\n  );\n}\n\nconst clip = (fraction: number) => `inset(0 ${Math.min(100, Math.max(0, (1 - fraction) * 100))}% 0 0)`;\n\ninterface GlyphProps {\n  fill: number;\n  kept: number;\n  delay: number;\n  soft: boolean;\n  pressed: boolean;\n  pop: { key: number; delay: number } | null;\n  reduced: boolean;\n}\n\n/** One star in three layers: the empty outline, a faint mark of the committed value, and the live fill that sweeps left to right. */\nfunction StarGlyph({ fill, kept, delay, soft, pressed, pop, reduced }: GlyphProps) {\n  const fillValue = useMotionValue(fill);\n  const keptValue = useMotionValue(kept);\n  const scale = useMotionValue(1);\n  const fillClip = useTransform(fillValue, clip);\n  const keptClip = useTransform(keptValue, clip);\n  const targets = useRef({ fill, kept, pop: pop?.key });\n  useEffect(() => {\n    if (targets.current.fill === fill) return;\n    targets.current.fill = fill;\n    animate(fillValue, fill, reduced ? still : { ...motionTokens.spring.snappy, delay });\n  }, [fill, delay, reduced, fillValue]);\n  useEffect(() => {\n    if (targets.current.kept === kept) return;\n    targets.current.kept = kept;\n    animate(keptValue, kept, reduced ? still : motionTokens.spring.smooth);\n  }, [kept, reduced, keptValue]);\n  const popKey = pop?.key;\n  const popDelay = pop?.delay ?? 0;\n  useEffect(() => {\n    if (popKey === undefined || targets.current.pop === popKey) return;\n    targets.current.pop = popKey;\n    if (!reduced) animate(scale, 1, { ...popSpring, velocity: popVelocity, delay: popDelay });\n  }, [popKey, popDelay, reduced, scale]);\n  return (\n    <motion.span\n      className=\"pointer-events-none grid size-(--glyph)\"\n      initial={false}\n      animate={{ scale: pressed && !reduced ? 0.86 : soft ? 0.9 : 1 }}\n      transition={reduced ? still : motionTokens.spring.snappy}\n      aria-hidden=\"true\"\n    >\n      <motion.span className=\"pointer-events-none grid size-(--glyph)\" style={{ scale }}>\n        {/* Once a value shows, empty stars soften from an outline into a quiet solid, so a partly filled star reads as one shape. */}\n        <span\n          className={cn(\n            layer,\n            \"[&_svg]:fill-transparent [&_svg]:stroke-line-strong [&_svg]:transition-[fill,stroke] [&_svg]:duration-standard [&_svg]:ease-standard group-data-rated/stars:[&_svg]:fill-line group-data-rated/stars:[&_svg]:stroke-line motion-reduce:[&_svg]:transition-none\",\n          )}\n        >\n          <Star strokeWidth={1.75} />\n        </span>\n        <motion.span\n          className={cn(\n            layer,\n            \"text-[color-mix(in_oklab,var(--orange)_30%,transparent)] [&_svg]:fill-current [&_svg]:stroke-current\",\n          )}\n          style={{ clipPath: keptClip }}\n        >\n          <Star strokeWidth={1.75} />\n        </motion.span>\n        <motion.span\n          className={cn(layer, \"text-orange [&_svg]:fill-current [&_svg]:stroke-current\")}\n          style={{ clipPath: fillClip }}\n        >\n          <Star strokeWidth={1.75} />\n        </motion.span>\n      </motion.span>\n    </motion.span>\n  );\n}\n\ntype Gesture = { start: number; last: number; scrub: boolean; touch: boolean };\n\nexport function Rating({\n  label,\n  value,\n  defaultValue = 0,\n  onChange,\n  max = 5,\n  readOnly = false,\n  disabled = false,\n  description,\n  size = \"md\",\n  className,\n  allowHalf = false,\n  allowClear = true,\n  showValue = true,\n  valueLabels,\n  emptyLabel = \"Not rated\",\n  name,\n}: RatingProps) {\n  const generatedId = useId();\n  const labelId = `${generatedId}-label`;\n  const descriptionId = `${generatedId}-description`;\n  const reduced = useReducedMotion() ?? false;\n  const step = allowHalf ? 0.5 : 1;\n  const normalize = (next: number) => Math.max(0, Math.min(max, Math.round(next / step) * step));\n  const [internalValue, setInternalValue] = useState(defaultValue);\n  const [preview, setPreview] = useState<number | null>(null);\n  const [pressed, setPressed] = useState<number | null>(null);\n  const [pulse, setPulse] = useState<{ key: number; value: number; wave: boolean } | null>(null);\n  const [announcement, setAnnouncement] = useState(\"\");\n  const currentValue = normalize(value ?? internalValue);\n  const displayValue = preview ?? currentValue;\n  // Remember where the shown value came from, so the fill sweeps away from it and the words roll the same way.\n  const [sweep, setSweep] = useState({ from: displayValue, to: displayValue });\n  if (sweep.to !== displayValue) setSweep({ from: sweep.to, to: displayValue });\n  const direction = sweep.to >= sweep.from ? 1 : -1;\n  const cells = useRef<(HTMLSpanElement | null)[]>([]);\n  const radios = useRef<(HTMLButtonElement | null)[]>([]);\n  const group = useRef<HTMLDivElement>(null);\n  const gesture = useRef<Gesture | null>(null);\n  // After a pointer commit the hovered preview steps aside until the pointer reaches a different value.\n  const heldAt = useRef<number | null>(null);\n  const suppressClickUntil = useRef(0);\n  const lastCommit = useRef(0);\n  const interactive = !readOnly && !disabled;\n\n  const words = valueLabels ?? (max === 5 && !allowHalf ? defaultWords : undefined);\n  const wordFor = (next: number) => (Number.isInteger(next) ? words?.[next - 1] : undefined);\n  const textFor = (next: number) => (next <= 0 ? emptyLabel : (wordFor(next) ?? `${next} out of ${max}`));\n  const spokenFor = (next: number) => [wordFor(next), `${next} of ${max} stars`].filter(Boolean).join(\", \");\n  const steps = Array.from({ length: Math.round(max / step) }, (_, index) => (index + 1) * step);\n\n  /** `time` is the event's timeStamp, which shares performance.now()'s clock. */\n  const commit = (next: number, time: number, { toggle = false, announce = true } = {}) => {\n    if (!interactive) return;\n    let chosen = normalize(next);\n    if (toggle && allowClear && chosen === currentValue) chosen = 0;\n    if (!allowClear) chosen = Math.max(step, chosen);\n    if (preview !== null) heldAt.current = preview;\n    setPreview(null);\n    setPulse({ key: time, value: chosen, wave: time - lastCommit.current > waveGap });\n    lastCommit.current = time;\n    if (announce) setAnnouncement(chosen === 0 ? \"Rating cleared\" : spokenFor(chosen));\n    if (chosen === currentValue) return;\n    setInternalValue(chosen);\n    onChange?.(chosen);\n  };\n\n  /** Maps a pointer position to a value; dragging past the first star clears when that is allowed. */\n  const valueAt = (x: number) => {\n    for (let index = 0; index < max; index += 1) {\n      const rect = cells.current[index]?.getBoundingClientRect();\n      if (!rect) continue;\n      if (index === 0 && x < rect.left - rect.width / 2) return allowClear ? 0 : step;\n      if (x <= rect.right + 1 || index === max - 1)\n        return allowHalf && x < rect.left + rect.width / 2 ? index + 0.5 : index + 1;\n    }\n    return currentValue;\n  };\n  const starOf = (next: number) => (next > 0 ? Math.ceil(next) - 1 : null);\n\n  const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {\n    if (!interactive || event.button !== 0) return;\n    const at = valueAt(event.clientX);\n    const touch = event.pointerType !== \"mouse\";\n    gesture.current = { start: at, last: at, scrub: false, touch };\n    // Touch keeps following the finger once it leaves the stars; a mouse keeps its hover path.\n    if (touch) {\n      try {\n        event.currentTarget.setPointerCapture(event.pointerId);\n      } catch {\n        /* capture is a nicety */\n      }\n      setPreview(at);\n    }\n    setPressed(starOf(at));\n  };\n  const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {\n    if (!interactive) return;\n    const at = valueAt(event.clientX);\n    const active = gesture.current;\n    if (active) {\n      if (at !== active.last) {\n        active.last = at;\n        active.scrub = true;\n      }\n      setPressed(starOf(at));\n      if (active.touch || active.scrub) setPreview(at);\n      return;\n    }\n    if (event.pointerType !== \"mouse\") return;\n    if (heldAt.current === at) return;\n    heldAt.current = null;\n    setPreview(at);\n  };\n  const endGesture = (event: PointerEvent<HTMLDivElement>, cancelled = false) => {\n    const active = gesture.current;\n    gesture.current = null;\n    setPressed(null);\n    if (!active) return;\n    if (!cancelled && (active.touch || active.scrub)) {\n      const at = valueAt(event.clientX);\n      // The browser may still send a click for this press; the pointer already made the choice.\n      suppressClickUntil.current = event.timeStamp + 500;\n      commit(at, event.timeStamp, { toggle: !active.scrub });\n      heldAt.current = active.touch ? null : at;\n      // A drag that began on another star leaves focus behind; keep it on the checked radio.\n      if (active.scrub && group.current?.contains(document.activeElement))\n        radios.current[Math.max(0, Math.round(normalize(at) / step) - 1)]?.focus({ preventScroll: true });\n    }\n    if (active.touch) setPreview(null);\n  };\n  const onPointerLeave = (event: PointerEvent<HTMLDivElement>) => {\n    if (gesture.current?.touch) return;\n    gesture.current = null;\n    heldAt.current = null;\n    setPressed(null);\n    if (event.pointerType === \"mouse\") setPreview(null);\n  };\n  const onClick = (event: MouseEvent<HTMLButtonElement>, next: number) => {\n    if (event.detail > 0 && event.timeStamp < suppressClickUntil.current) return;\n    commit(next, event.timeStamp, { toggle: true });\n  };\n  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, own: number) => {\n    const moves: Record<string, number> = {\n      ArrowRight: currentValue + step,\n      ArrowUp: currentValue + step,\n      ArrowLeft: currentValue - step,\n      ArrowDown: currentValue - step,\n      Home: step,\n      End: max,\n    };\n    let next: number | undefined = moves[event.key];\n    let announce = false;\n    if (event.key === \" \" || event.key === \"Enter\") next = own;\n    if ((event.key === \"Backspace\" || event.key === \"Delete\") && allowClear) {\n      next = 0;\n      announce = true;\n    }\n    if (/^[1-9]$/.test(event.key) && Number(event.key) <= max) next = Number(event.key);\n    if (next === undefined) return;\n    event.preventDefault();\n    const chosen = next === 0 ? 0 : Math.max(step, Math.min(max, next));\n    commit(chosen, event.timeStamp, { announce });\n    radios.current[Math.max(0, Math.round(chosen / step) - 1)]?.focus();\n  };\n\n  const tabStop = currentValue > 0 ? currentValue : step;\n  const pulsing = pulse && pulse.value === currentValue && currentValue > 0 ? pulse : null;\n  const radio = (next: number, half: boolean) => (\n    <button\n      key={next}\n      ref={(node) => {\n        radios.current[Math.round(next / step) - 1] = node;\n      }}\n      type=\"button\"\n      role=\"radio\"\n      className={half ? `${hit} z-1 end-1/2 rounded-e-none` : hit}\n      aria-checked={currentValue === next}\n      aria-label={spokenFor(next)}\n      tabIndex={interactive && next === tabStop ? 0 : -1}\n      disabled={disabled}\n      onClick={(event) => onClick(event, next)}\n      onKeyDown={(event) => onKeyDown(event, next)}\n    />\n  );\n\n  return (\n    <div\n      className={cn(\"group/rating grid min-w-0 justify-items-start gap-1.5\", className)}\n      data-disabled={disabled || undefined}\n    >\n      <span id={labelId} className=\"text-sm leading-body font-medium tracking-body text-ink\">\n        {label}\n      </span>\n      <div className=\"flex max-w-full flex-wrap items-center gap-x-2 gap-y-0.5\">\n        <div\n          ref={group}\n          className={cn(\n            \"group/stars mx-[calc((var(--glyph)_-_var(--cell))/2)] inline-flex touch-pan-y gap-0.5 select-none group-data-disabled/rating:opacity-50\",\n            sizes[size],\n          )}\n          role={readOnly ? \"img\" : \"radiogroup\"}\n          aria-labelledby={readOnly ? undefined : labelId}\n          aria-label={\n            readOnly ? `${label}: ${currentValue > 0 ? `${currentValue} out of ${max}` : emptyLabel}` : undefined\n          }\n          aria-describedby={description && !readOnly ? descriptionId : undefined}\n          aria-disabled={disabled || undefined}\n          data-rated={displayValue > 0 || undefined}\n          onPointerDown={onPointerDown}\n          onPointerMove={onPointerMove}\n          onPointerUp={(event) => endGesture(event)}\n          onPointerCancel={(event) => endGesture(event, true)}\n          onPointerLeave={onPointerLeave}\n        >\n          {Array.from({ length: max }, (_, index) => {\n            const fill = Math.max(0, Math.min(1, displayValue - index));\n            const order = sweep.to > sweep.from ? index - Math.floor(sweep.from) : Math.ceil(sweep.from) - 1 - index;\n            const popping =\n              pulsing && index < Math.ceil(pulsing.value) && (pulsing.wave || index === Math.ceil(pulsing.value) - 1);\n            return (\n              <span\n                key={`${generatedId}-${index}`}\n                ref={(node) => {\n                  cells.current[index] = node;\n                }}\n                className=\"relative grid size-(--cell) flex-none place-items-center rounded-[calc(var(--radius-control)_-_6px)]\"\n              >\n                <StarGlyph\n                  fill={fill}\n                  kept={Math.max(0, Math.min(1, currentValue - index))}\n                  delay={Math.max(0, order) * motionTokens.stagger.item}\n                  soft={displayValue > 0 && fill === 0}\n                  pressed={pressed === index}\n                  pop={\n                    popping ? { key: pulsing.key, delay: pulsing.wave ? index * motionTokens.stagger.item : 0 } : null\n                  }\n                  reduced={reduced}\n                />\n                {!readOnly && allowHalf && radio(index + 0.5, true)}\n                {!readOnly && radio(index + 1, false)}\n              </span>\n            );\n          })}\n        </div>\n        {showValue && (\n          <span\n            className=\"inline-grid text-sm leading-body tracking-body whitespace-nowrap text-ink-2 tabular-nums transition-[color] duration-fast ease-standard *:[grid-area:1/1] data-empty:text-ink-3 group-data-disabled/rating:opacity-50 motion-reduce:transition-none\"\n            aria-hidden=\"true\"\n            data-empty={displayValue === 0 || undefined}\n          >\n            {[0, ...steps].map((next) => (\n              <span key={next} className=\"invisible\">\n                {textFor(next)}\n              </span>\n            ))}\n            <RollingText text={textFor(displayValue)} direction={direction} reduced={reduced} />\n          </span>\n        )}\n      </div>\n      {description && (\n        <span id={descriptionId} className=\"text-xs leading-body tracking-body text-ink-3 tabular-nums\">\n          <RollingText text={description} direction={direction} reduced={reduced} />\n        </span>\n      )}\n      {!readOnly && (\n        <span className=\"sr-only\" aria-live=\"polite\">\n          {announcement}\n        </span>\n      )}\n      {name && <input type=\"hidden\" name={name} value={currentValue} />}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/iris/rating/rating.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/rating",
  "meta": {
    "kind": "component",
    "group": "Inputs",
    "export": "Rating",
    "tags": [
      "input",
      "feedback"
    ],
    "docs": "https://irisui.dev/components/rating"
  }
}
