{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "star-rating",
  "title": "Star Rating",
  "description": "An animated star rating input with hover-preview wave, pop and sparkle burst on commit, rolling value label, and fractional read-only display",
  "dependencies": [
    "framer-motion"
  ],
  "files": [
    {
      "path": "app/registry/star-rating/star-rating.tsx",
      "content": "/**\n * Spectrum UI — StarRating\n *\n * An animated star rating input. Hovering previews the rating with a spring\n * fill and a small scale wave around the pointer, clicking commits with a pop\n * and a tiny amber sparkle burst, and an optional rolling value label tracks\n * the current or previewed rating. Works controlled or uncontrolled, supports\n * fractional read-only display, honors prefers-reduced-motion, and exposes\n * radiogroup semantics with full keyboard support.\n *\n * Dependencies: framer-motion, @/lib/utils\n *\n * @example\n * <StarRating showValue onValueChange={(value) => save(value)} />\n */\n\n\"use client\"\n\nimport React, { useCallback, useEffect, useRef, useState } from \"react\"\nimport {\n  AnimatePresence,\n  motion,\n  useReducedMotion,\n  type Transition,\n} from \"framer-motion\"\nimport { cn } from \"@/lib/utils\"\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface StarRatingProps {\n  /** Controlled rating value. Leave undefined for uncontrolled usage */\n  value?: number\n  /** Initial rating when uncontrolled. Default 0 */\n  defaultValue?: number\n  /** Fires with the next rating on every commit */\n  onValueChange?: (value: number) => void\n  /** Number of stars rendered. Default 5 */\n  max?: number\n  /** Visual size of the stars. Default \"md\" */\n  size?: \"sm\" | \"md\" | \"lg\"\n  /** Clicking the committed star clears the rating to 0. Default true */\n  allowClear?: boolean\n  /** Display-only mode; supports fractional values like 4.3. Default false */\n  readOnly?: boolean\n  /** Show a rolling \"4/5\" value label next to the stars. Default false */\n  showValue?: boolean\n  /** Accessible name of the rating group. Default \"Rating\" */\n  label?: string\n  className?: string\n}\n\n// ─── Constants ───────────────────────────────────────────────────────────────\n\nconst SPARKLE_COUNT = 5\nconst BURST_DURATION = 0.5\n\n/** Snappy micro spring for the hover wave and star fills */\nconst FILL_SPRING: Transition = { type: \"spring\", stiffness: 500, damping: 30 }\n/** Tight tween for un-fills so stars never overshoot past zero scale */\nconst UNFILL_TWEEN: Transition = { duration: 0.15, ease: \"easeOut\" }\n/** Spring for the rolling value label digits */\nconst VALUE_SPRING: Transition = { type: \"spring\", stiffness: 400, damping: 30 }\n/** Celebratory squash-and-stretch pop on commit */\nconst POP_KEYFRAMES = [1, 0.7, 1.3, 1]\nconst POP_TRANSITION: Transition = {\n  duration: 0.45,\n  times: [0, 0.25, 0.6, 1],\n  ease: \"easeOut\",\n}\n/** Hovered star scale and the falloff applied to its direct neighbors */\nconst WAVE_PRIMARY_SCALE = 1.2\nconst WAVE_NEIGHBOR_SCALE = 1.08\n\n// pad keeps every star's hit area at least 24px square\nconst SIZES = {\n  sm: { icon: 16, pad: \"p-1\", text: \"text-xs\", value: \"ml-1.5\" },\n  md: { icon: 22, pad: \"p-0.5\", text: \"text-sm\", value: \"ml-2\" },\n  lg: { icon: 28, pad: \"p-0.5\", text: \"text-sm\", value: \"ml-2.5\" },\n} as const\n\nconst STAR_PATH =\n  \"M12 2L14.65 8.36L21.51 8.91L16.28 13.39L17.88 20.09L12 16.5L6.12 20.09L7.72 13.39L2.49 8.91L9.35 8.36Z\"\n\nconst valueVariants = {\n  enter: (direction: number) => ({ y: direction * 12, opacity: 0 }),\n  center: { y: 0, opacity: 1 },\n  exit: (direction: number) => ({ y: direction * -12, opacity: 0 }),\n}\n\nfunction formatValue(value: number) {\n  return Number.isInteger(value) ? String(value) : value.toFixed(1)\n}\n\n// ─── Star icon ───────────────────────────────────────────────────────────────\n\nfunction Star({\n  size,\n  filled = false,\n  className,\n}: {\n  size: number\n  filled?: boolean\n  className?: string\n}) {\n  return (\n    <svg\n      viewBox=\"0 0 24 24\"\n      width={size}\n      height={size}\n      fill={filled ? \"currentColor\" : \"none\"}\n      stroke=\"currentColor\"\n      strokeWidth=\"2\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      aria-hidden=\"true\"\n      className={cn(\"block\", className)}\n    >\n      <path d={STAR_PATH} />\n    </svg>\n  )\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nexport function StarRating({\n  value: valueProp,\n  defaultValue = 0,\n  onValueChange,\n  max = 5,\n  size = \"md\",\n  allowClear = true,\n  readOnly = false,\n  showValue = false,\n  label = \"Rating\",\n  className,\n}: StarRatingProps) {\n  const shouldReduceMotion = useReducedMotion()\n  const [internalValue, setInternalValue] = useState(defaultValue)\n  const [hovered, setHovered] = useState<number | null>(null)\n  const [burst, setBurst] = useState<{ key: number; index: number } | null>(\n    null,\n  )\n  const starRefs = useRef<(HTMLButtonElement | null)[]>([])\n\n  const value = valueProp ?? internalValue\n  const { icon, pad: padClass, text: textClass, value: valueGapClass } = SIZES[size]\n  // Hovering previews the rating; pointer leave falls back to the commit\n  const previewValue = !readOnly && hovered !== null ? hovered : value\n  // Reserve the widest rendering so the \"/max\" suffix never shifts as digits roll\n  const valueWidthCh = Math.max(\n    String(max).length,\n    formatValue(previewValue).length,\n  )\n\n  // Digits roll up while the preview rises and back down while it falls\n  const previousPreviewRef = useRef(previewValue)\n  const direction = previewValue >= previousPreviewRef.current ? 1 : -1\n  useEffect(() => {\n    previousPreviewRef.current = previewValue\n  }, [previewValue])\n\n  const commitValue = useCallback(\n    (next: number) => {\n      if (valueProp === undefined) setInternalValue(next)\n      onValueChange?.(next)\n    },\n    [valueProp, onValueChange],\n  )\n\n  const handleSelect = useCallback(\n    (starValue: number) => {\n      const next = allowClear && starValue === value ? 0 : starValue\n      commitValue(next)\n      if (next > 0 && !shouldReduceMotion) {\n        setBurst((previous) => ({\n          key: (previous?.key ?? 0) + 1,\n          index: starValue - 1,\n        }))\n      }\n    },\n    [allowClear, value, commitValue, shouldReduceMotion],\n  )\n\n  const handleKeyDown = useCallback(\n    (event: React.KeyboardEvent<HTMLDivElement>) => {\n      let next: number\n      switch (event.key) {\n        case \"ArrowRight\":\n        case \"ArrowUp\":\n          next = Math.min(value + 1, max)\n          break\n        case \"ArrowLeft\":\n        case \"ArrowDown\":\n          next = Math.max(value - 1, 1)\n          break\n        case \"Home\":\n          next = 1\n          break\n        case \"End\":\n          next = max\n          break\n        default:\n          return\n      }\n      event.preventDefault()\n      if (next !== value) commitValue(next)\n      starRefs.current[next - 1]?.focus()\n    },\n    [value, max, commitValue],\n  )\n\n  // Hovered star scales up, direct neighbors follow at a falloff — a wave\n  const getScale = (index: number) => {\n    if (shouldReduceMotion || readOnly || hovered === null) return 1\n    const distance = Math.abs(index + 1 - hovered)\n    if (distance === 0) return WAVE_PRIMARY_SCALE\n    if (distance === 1) return WAVE_NEIGHBOR_SCALE\n    return 1\n  }\n\n  // Roving tabindex: the committed star is tabbable, else the first star\n  const focusIndex = value >= 1 && value <= max ? Math.round(value) - 1 : 0\n\n  return (\n    <div\n      role={readOnly ? \"img\" : \"radiogroup\"}\n      aria-label={\n        readOnly ? `${label}: ${formatValue(value)} out of ${max}` : label\n      }\n      onKeyDown={readOnly ? undefined : handleKeyDown}\n      onPointerLeave={readOnly ? undefined : () => setHovered(null)}\n      className={cn(\"inline-flex select-none items-center\", className)}\n    >\n      <div className=\"flex items-center\">\n        {Array.from({ length: max }).map((_, index) => {\n          const starValue = index + 1\n\n          if (readOnly) {\n            // clip-path clips the filled overlay at subpixel precision, so\n            // fractional values render crisply at any icon size\n            const fillPercent =\n              Math.max(0, Math.min(1, value - index)) * 100\n            return (\n              <span key={index} className={cn(\"relative block\", padClass)}>\n                <span\n                  className=\"relative block\"\n                  style={{ width: icon, height: icon }}\n                >\n                  <Star\n                    size={icon}\n                    className=\"text-neutral-300 dark:text-neutral-700\"\n                  />\n                  {fillPercent > 0 && (\n                    <span\n                      className=\"absolute inset-0 text-amber-400\"\n                      style={{\n                        clipPath: `inset(0 ${100 - fillPercent}% 0 0)`,\n                      }}\n                    >\n                      <Star size={icon} filled />\n                    </span>\n                  )}\n                </span>\n              </span>\n            )\n          }\n\n          const filled = starValue <= previewValue\n          const isBursting = burst?.index === index\n\n          return (\n            <button\n              key={index}\n              ref={(node) => {\n                starRefs.current[index] = node\n              }}\n              type=\"button\"\n              role=\"radio\"\n              aria-checked={starValue === value}\n              aria-label={`${starValue} ${starValue === 1 ? \"star\" : \"stars\"}`}\n              tabIndex={index === focusIndex ? 0 : -1}\n              onClick={() => handleSelect(starValue)}\n              onPointerEnter={(event) => {\n                // Touch taps emulate hover but never fire a matching leave,\n                // which would strand the preview — only track mouse pointers\n                if (event.pointerType === \"mouse\") setHovered(starValue)\n              }}\n              className={cn(\n                \"relative touch-manipulation rounded-md outline-hidden\",\n                \"transition-transform duration-150 ease-out active:scale-90 motion-reduce:transition-none motion-reduce:active:scale-100\",\n                \"focus-visible:ring-1 focus-visible:ring-neutral-950 dark:focus-visible:ring-neutral-300\",\n                padClass,\n              )}\n            >\n              <motion.span\n                className=\"relative block\"\n                style={{ width: icon, height: icon }}\n                initial={false}\n                animate={\n                  isBursting\n                    ? { scale: POP_KEYFRAMES }\n                    : { scale: getScale(index) }\n                }\n                transition={\n                  isBursting\n                    ? POP_TRANSITION\n                    : shouldReduceMotion\n                      ? { duration: 0 }\n                      : FILL_SPRING\n                }\n              >\n                <Star\n                  size={icon}\n                  className=\"text-neutral-300 dark:text-neutral-700\"\n                />\n                <motion.span\n                  className=\"absolute inset-0 text-amber-400\"\n                  initial={false}\n                  animate={{\n                    scale: filled ? 1 : 0,\n                    opacity: filled ? 1 : 0,\n                  }}\n                  transition={\n                    shouldReduceMotion\n                      ? { duration: 0 }\n                      : filled\n                        ? FILL_SPRING\n                        : UNFILL_TWEEN\n                  }\n                >\n                  <Star size={icon} filled />\n                </motion.span>\n              </motion.span>\n\n              {isBursting && (\n                <span\n                  aria-hidden=\"true\"\n                  className=\"pointer-events-none absolute inset-0 flex items-center justify-center\"\n                >\n                  {Array.from({ length: SPARKLE_COUNT }).map(\n                    (_, sparkleIndex) => {\n                      const angle =\n                        (sparkleIndex / SPARKLE_COUNT) * Math.PI * 2 -\n                        Math.PI / 2\n                      const distance = icon * 1.2\n                      const dotSize = Math.max(2, Math.round(icon * 0.16))\n                      const burstKey = burst.key\n                      return (\n                        <motion.span\n                          key={`sparkle-${burstKey}-${sparkleIndex}`}\n                          className=\"absolute rounded-full bg-amber-400\"\n                          style={{ width: dotSize, height: dotSize }}\n                          initial={{ x: 0, y: 0, scale: 0, opacity: 1 }}\n                          animate={{\n                            x: Math.cos(angle) * distance,\n                            y: Math.sin(angle) * distance,\n                            scale: [0, 1, 0.3],\n                            opacity: [1, 1, 0],\n                          }}\n                          transition={{\n                            duration: BURST_DURATION,\n                            ease: \"easeOut\",\n                          }}\n                          // Only clear if a newer burst hasn't replaced this one\n                          onAnimationComplete={\n                            sparkleIndex === 0\n                              ? () =>\n                                  setBurst((current) =>\n                                    current?.key === burstKey ? null : current,\n                                  )\n                              : undefined\n                          }\n                        />\n                      )\n                    },\n                  )}\n                </span>\n              )}\n            </button>\n          )\n        })}\n      </div>\n\n      {showValue && (\n        <span\n          className={cn(\n            \"inline-flex font-medium tabular-nums text-neutral-500 dark:text-neutral-400\",\n            textClass,\n            valueGapClass,\n          )}\n          aria-hidden=\"true\"\n        >\n          <span\n            className=\"relative inline-flex justify-end overflow-hidden\"\n            style={{ minWidth: `${valueWidthCh}ch` }}\n          >\n            <AnimatePresence\n              mode=\"popLayout\"\n              initial={false}\n              custom={direction}\n            >\n              <motion.span\n                key={formatValue(previewValue)}\n                className=\"inline-block\"\n                custom={direction}\n                variants={valueVariants}\n                initial=\"enter\"\n                animate=\"center\"\n                exit=\"exit\"\n                transition={shouldReduceMotion ? { duration: 0 } : VALUE_SPRING}\n              >\n                {formatValue(previewValue)}\n              </motion.span>\n            </AnimatePresence>\n          </span>\n          <span>/{max}</span>\n        </span>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/spectrumui/star-rating.tsx"
    }
  ],
  "type": "registry:component"
}
