{
  "$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://mcp.xzero.co.uk/r/xzero-motion-tokens.json"
  ],
  "files": [
    {
      "path": "registry/components/rating/rating.tsx",
      "type": "registry:component",
      "target": "@components/xzero/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 styles from \"./rating.module.css\";\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 = { type: \"spring\", stiffness: snappyRoot ** 2, damping: 2 * (1 - motionTokens.spring.snappy.bounce) * snappyRoot, mass: 1 } 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\n/** Changed words roll in the direction the value moved; unchanged words stay put. */\nconst wordMotion: Variants = {\n  enter: (direction: number) => ({ opacity: 0, y: `${direction * .3}em`, filter: `blur(${motionTokens.blur.soft}px)` }),\n  center: { opacity: 1, y: 0, filter: \"blur(0px)\", transition: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] } },\n  exit: (direction: number) => ({ opacity: 0, y: `${direction * -.25}em`, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }),\n};\n\nfunction RollingText({ text, direction, reduced }: { text: string; direction: number; reduced: boolean }) {\n  const words = text.split(/(\\s+)/);\n  return <span className={styles.roll}>\n    <span className={styles.srOnly}>{text}</span>\n    <span aria-hidden=\"true\"><AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n      {words.map((word, index) => <motion.span key={`${index}:${word}`} className={styles.word} custom={direction} variants={wordMotion} initial={reduced ? false : \"enter\"} animate=\"center\" exit={reduced ? undefined : \"exit\"}>{word}</motion.span>)}\n    </AnimatePresence></span>\n  </span>;\n}\n\nconst clip = (fraction: number) => `inset(0 ${Math.min(100, Math.max(0, (1 - fraction) * 100))}% 0 0)`;\n\ninterface GlyphProps { fill: number; kept: number; delay: number; soft: boolean; pressed: boolean; pop: { key: number; delay: number } | null; reduced: boolean }\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 <motion.span className={styles.lift} initial={false} animate={{ scale: pressed && !reduced ? .86 : soft ? .9 : 1 }} transition={reduced ? still : motionTokens.spring.snappy} aria-hidden=\"true\">\n    <motion.span className={styles.pop} style={{ scale }}>\n      <span className={styles.base}><Star strokeWidth={1.75} /></span>\n      <motion.span className={styles.kept} style={{ clipPath: keptClip }}><Star strokeWidth={1.75} /></motion.span>\n      <motion.span className={styles.fill} style={{ clipPath: fillClip }}><Star strokeWidth={1.75} /></motion.span>\n    </motion.span>\n  </motion.span>;\n}\n\ntype Gesture = { start: number; last: number; scrub: boolean; touch: boolean };\n\nexport function Rating({ label, value, defaultValue = 0, onChange, max = 5, readOnly = false, disabled = false, description, size = \"md\", className, allowHalf = false, allowClear = true, showValue = true, valueLabels, emptyLabel = \"Not rated\", name }: RatingProps) {\n  const generatedId = useId();\n  const labelId = `${generatedId}-label`;\n  const descriptionId = `${generatedId}-description`;\n  const reduced = useReducedMotion() ?? false;\n  const step = allowHalf ? .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) return allowHalf && x < rect.left + rect.width / 2 ? index + .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) { try { event.currentTarget.setPointerCapture(event.pointerId); } catch { /* capture is a nicety */ } setPreview(at); }\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) { active.last = at; active.scrub = true; }\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)) 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> = { ArrowRight: currentValue + step, ArrowUp: currentValue + step, ArrowLeft: currentValue - step, ArrowDown: currentValue - step, Home: step, End: max };\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) { next = 0; announce = true; }\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) => <button\n    key={next}\n    ref={node => { radios.current[Math.round(next / step) - 1] = node; }}\n    type=\"button\"\n    role=\"radio\"\n    className={[styles.hit, half ? styles.half : \"\"].filter(Boolean).join(\" \")}\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  return <div className={[styles.field, styles[size], className].filter(Boolean).join(\" \")} data-disabled={disabled || undefined}>\n    <span id={labelId} className={styles.label}>{label}</span>\n    <div className={styles.row}>\n      <div\n        ref={group}\n        className={styles.stars}\n        role={readOnly ? \"img\" : \"radiogroup\"}\n        aria-labelledby={readOnly ? undefined : labelId}\n        aria-label={readOnly ? `${label}: ${currentValue > 0 ? `${currentValue} out of ${max}` : emptyLabel}` : undefined}\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 = pulsing && index < Math.ceil(pulsing.value) && (pulsing.wave || index === Math.ceil(pulsing.value) - 1);\n          return <span key={`${generatedId}-${index}`} ref={node => { cells.current[index] = node; }} className={styles.cell}>\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={popping ? { key: pulsing.key, delay: pulsing.wave ? index * motionTokens.stagger.item : 0 } : null}\n              reduced={reduced}\n            />\n            {!readOnly && allowHalf && radio(index + .5, true)}\n            {!readOnly && radio(index + 1, false)}\n          </span>;\n        })}\n      </div>\n      {showValue && <span className={styles.value} aria-hidden=\"true\" data-empty={displayValue === 0 || undefined}>\n        {[0, ...steps].map(next => <span key={next} className={styles.sizer}>{textFor(next)}</span>)}\n        <RollingText text={textFor(displayValue)} direction={direction} reduced={reduced} />\n      </span>}\n    </div>\n    {description && <span id={descriptionId} className={styles.description}><RollingText text={description} direction={direction} reduced={reduced} /></span>}\n    {!readOnly && <span className={styles.srOnly} aria-live=\"polite\">{announcement}</span>}\n    {name && <input type=\"hidden\" name={name} value={currentValue} />}\n  </div>;\n}\n"
    },
    {
      "path": "registry/components/rating/rating.module.css",
      "type": "registry:component",
      "target": "@components/xzero/rating/rating.module.css",
      "content": ".field { display: grid; justify-items: start; gap: 6px; min-width: 0; }\n.label { color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); letter-spacing: var(--tracking-body); }\n.row { display: flex; flex-wrap: wrap; align-items: center; column-gap: 8px; row-gap: 2px; max-width: 100%; }\n/* Cells are wider than their stars; the negative inline margin lines the first star up with the label. */\n.stars { --cell: 36px; --glyph: 24px; display: inline-flex; gap: 2px; margin-inline: calc((var(--glyph) - var(--cell)) / 2); touch-action: pan-y; user-select: none; -webkit-user-select: none; }\n.sm .stars { --cell: 28px; --glyph: 18px; }\n.lg .stars { --cell: 44px; --glyph: 30px; }\n.cell { position: relative; display: grid; width: var(--cell); height: var(--cell); flex: none; place-items: center; border-radius: calc(var(--radius-control) - 6px); }\n/* The radio covers the cell; with half stars a second radio takes the leading half. */\n.hit { position: absolute; inset: 0; margin: 0; border: 0; border-radius: inherit; padding: 0; background: transparent; cursor: pointer; -webkit-tap-highlight-color: transparent; }\n.hit:focus-visible { outline: none; }\n.half { z-index: 1; inset-inline-end: 50%; border-start-end-radius: 0; border-end-end-radius: 0; }\n.hit:disabled { cursor: default; }\n/* The lift answers press and softening; the pop inside carries the commit impulse, so the two transforms never fight. */\n.lift, .pop { display: grid; width: var(--glyph); height: var(--glyph); pointer-events: none; }\n.base, .kept, .fill { display: grid; grid-area: 1 / 1; }\n.base svg, .kept svg, .fill svg { width: var(--glyph); height: var(--glyph); }\n.base svg { fill: transparent; stroke: var(--border-strong); transition: fill var(--duration-standard) var(--ease-standard), stroke var(--duration-standard) var(--ease-standard); }\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.stars[data-rated] .base svg { fill: var(--border); stroke: var(--border); }\n.kept { color: color-mix(in oklab, var(--warning) 30%, transparent); }\n.fill { color: var(--warning); }\n.kept svg, .fill svg { fill: currentColor; stroke: currentColor; }\n.value { display: inline-grid; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); letter-spacing: var(--tracking-body); font-variant-numeric: tabular-nums; white-space: nowrap; transition: color var(--duration-fast) var(--ease-standard); }\n.value[data-empty] { color: var(--text-muted); }\n.value > * { grid-area: 1 / 1; }\n.sizer { visibility: hidden; }\n.description { color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); letter-spacing: var(--tracking-body); font-variant-numeric: tabular-nums; }\n.roll { position: relative; display: block; }\n.word { display: inline-block; white-space: pre; }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n.field[data-disabled] .stars, .field[data-disabled] .value { opacity: .5; }\n@media (prefers-reduced-motion: reduce) { .base svg, .value { transition: none; } }\n"
    }
  ],
  "docs": "Docs and live preview: https://ui.xzero.co.uk/components/rating",
  "meta": {
    "tier": "free",
    "kind": "component",
    "tags": [
      "input",
      "feedback"
    ],
    "docs": "https://ui.xzero.co.uk/components/rating",
    "markdown": "https://ui.xzero.co.uk/components/rating/markdown"
  }
}