{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rating",
  "type": "registry:ui",
  "registryDependencies": [],
  "dependencies": ["@radix-ui/react-use-controllable-state", "motion"],
  "devDependencies": [],
  "files": [
    {
      "path": "rating.tsx",
      "content": "\"use client\";\n\nimport { useControllableState } from \"@radix-ui/react-use-controllable-state\";\nimport { motion, useReducedMotion } from \"motion/react\";\nimport type {\n  ComponentPropsWithoutRef,\n  KeyboardEvent,\n  MouseEvent,\n  ReactElement,\n  ReactNode,\n} from \"react\";\nimport {\n  Children,\n  cloneElement,\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useRef,\n  useState,\n} from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst STAR_POINTS =\n  \"9 1.75 11.24 6.289 16.25 7.017 12.625 10.551 13.481 15.54 9 13.185 4.519 15.54 5.375 10.551 1.75 7.017 6.76 6.289 9 1.75\";\n\nconst POP_SPRING = {\n  type: \"spring\",\n  stiffness: 300,\n  damping: 7,\n  mass: 0.9,\n} as const;\nconst STAGGER_SECONDS = 0.025;\n\ntype StarIconProps = { className?: string; size?: number };\n\nfunction StarIcon({ className, size = 24 }: StarIconProps) {\n  return (\n    <svg\n      className={className}\n      fill=\"none\"\n      height={size}\n      stroke=\"currentColor\"\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      strokeWidth={1.5}\n      viewBox=\"0 0 18 18\"\n      width={size}\n      xmlns=\"http://www.w3.org/2000/svg\"\n    >\n      <polygon points={STAR_POINTS} />\n    </svg>\n  );\n}\n\n// Reused across every RatingButton instance — a plain element with no\n// per-instance state, so allocating it once avoids a fresh object per render.\nconst DEFAULT_ICON = <StarIcon />;\nconst DEFAULT_SIZE = 20;\n\ntype RatingContextValue = {\n  value: number;\n  readOnly: boolean;\n  size: number;\n  hoverValue: number | null;\n  focusedStar: number | null;\n  handleValueChange: (\n    event: MouseEvent<HTMLButtonElement> | KeyboardEvent<HTMLButtonElement>,\n    value: number\n  ) => void;\n  handleKeyDown: (event: KeyboardEvent<HTMLButtonElement>) => void;\n  setHoverValue: (value: number | null) => void;\n  setFocusedStar: (value: number | null) => void;\n};\n\nconst RatingContext = createContext<RatingContextValue | null>(null);\n\nconst useRating = () => {\n  const context = useContext(RatingContext);\n  if (!context) {\n    throw new Error(\"useRating must be used within a Rating component\");\n  }\n  return context;\n};\n\nexport type RatingButtonProps = {\n  /** Icon element to render. Must accept size and className. @default <StarIcon /> */\n  icon?: ReactElement<StarIconProps>;\n  /** Icon size in pixels. Falls back to the parent Rating's size. @default 20 */\n  size?: number;\n  /** Optional class names applied to the button. */\n  className?: string;\n  /** Position in the scale. Injected automatically by Rating from child order — you don't need to pass it yourself. */\n  index?: number;\n};\n\n// Renders a single star. Must be used inside Rating — reads shared state\n// from context rather than taking a value prop directly.\nexport const RatingButton = ({\n  index: providedIndex,\n  size: sizeProp,\n  className,\n  icon = DEFAULT_ICON,\n}: RatingButtonProps) => {\n  const {\n    value,\n    readOnly,\n    size: contextSize,\n    hoverValue,\n    focusedStar,\n    handleValueChange,\n    handleKeyDown,\n    setHoverValue,\n    setFocusedStar,\n  } = useRating();\n  const size = sizeProp ?? contextSize;\n  const reduceMotion = useReducedMotion();\n\n  const index = providedIndex ?? 0;\n  const isActive = index < (hoverValue ?? focusedStar ?? value ?? 0);\n  const isChecked = value === index + 1;\n  const tabIndex = readOnly\n    ? -1\n    : isChecked || (value === 0 && index === 0)\n      ? 0\n      : -1;\n\n  const handleClick = useCallback(\n    (event: MouseEvent<HTMLButtonElement>) => {\n      handleValueChange(event, isChecked ? 0 : index + 1);\n    },\n    [handleValueChange, index, isChecked]\n  );\n\n  const handleMouseEnter = useCallback(() => {\n    if (!readOnly) {\n      setHoverValue(index + 1);\n    }\n  }, [readOnly, setHoverValue, index]);\n\n  const handleFocus = useCallback(() => {\n    setFocusedStar(index + 1);\n  }, [setFocusedStar, index]);\n\n  const handleBlur = useCallback(() => {\n    setFocusedStar(null);\n  }, [setFocusedStar]);\n\n  return (\n    // biome-ignore lint/a11y/useSemanticElements: a native <input type=\"radio\"> can't host the icon/motion content each star renders\n    <button\n      aria-checked={isChecked}\n      aria-label={`Rate ${index + 1} star${index === 0 ? \"\" : \"s\"}`}\n      className={cn(\n        \"rounded-full p-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--ic-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--ic-card)]\",\n        readOnly ? \"cursor-default\" : \"cursor-pointer\",\n        className\n      )}\n      disabled={readOnly}\n      onBlur={handleBlur}\n      onClick={handleClick}\n      onFocus={handleFocus}\n      onKeyDown={handleKeyDown}\n      onMouseEnter={handleMouseEnter}\n      role=\"radio\"\n      tabIndex={tabIndex}\n      type=\"button\"\n    >\n      <motion.span\n        animate={{ scale: isActive ? 1 : 0.75 }}\n        className=\"inline-flex\"\n        transition={\n          reduceMotion\n            ? { duration: 0 }\n            : { ...POP_SPRING, delay: index * STAGGER_SECONDS }\n        }\n        whileHover={readOnly || reduceMotion ? undefined : { scale: 1.18 }}\n        whileTap={readOnly || reduceMotion ? undefined : { scale: 0.82 }}\n      >\n        {cloneElement(icon, {\n          size,\n          className: cn(\n            \"transition-colors duration-200 ease-out\",\n            isActive\n              ? \"fill-current text-[var(--ic-primary)]\"\n              : \"text-[var(--ic-border)]\"\n          ),\n        })}\n      </motion.span>\n    </button>\n  );\n};\n\nexport type RatingProps = Omit<\n  ComponentPropsWithoutRef<\"div\">,\n  \"defaultValue\" | \"onChange\"\n> & {\n  /** One RatingButton per star. Their count is the scale. */\n  children?: ReactNode;\n  /** Controlled rating value. Pair with onValueChange. */\n  value?: number;\n  /** Initial rating for uncontrolled usage. @default 0 */\n  defaultValue?: number;\n  /** Called with the new value on click or arrow key. */\n  onValueChange?: (value: number) => void;\n  /** Lower-level change handler that also receives the originating mouse or keyboard event. */\n  onChange?: (\n    event: MouseEvent<HTMLButtonElement> | KeyboardEvent<HTMLButtonElement>,\n    value: number\n  ) => void;\n  /** Renders every RatingButton as a native disabled button, for showing an existing rating without interaction. @default false */\n  readOnly?: boolean;\n  /** Icon size in pixels for all RatingButton children. @default 20 */\n  size?: number;\n};\n\n// Compound star rating root. Manages the current value, icon size, and\n// shared interaction state for its RatingButton children — tap a star to\n// set the rating instantly, drag your focus with the Arrow keys, or tap the\n// already-selected star again to clear it.\nexport const Rating = ({\n  value: controlledValue,\n  onValueChange: controlledOnValueChange,\n  defaultValue = 0,\n  onChange,\n  readOnly = false,\n  size = DEFAULT_SIZE,\n  className,\n  children,\n  ...props\n}: RatingProps) => {\n  const [hoverValue, setHoverValue] = useState<number | null>(null);\n  const [focusedStar, setFocusedStar] = useState<number | null>(null);\n  const containerRef = useRef<HTMLDivElement>(null);\n  const [value, onValueChange] = useControllableState({\n    defaultProp: defaultValue,\n    prop: controlledValue,\n    onChange: controlledOnValueChange,\n  });\n\n  // Children.toArray drops null/false/undefined entries and normalizes\n  // keys, so conditionally rendered RatingButton children never leave a\n  // gap in the index sequence or inflate the Home/End/Arrow-key total.\n  const validChildren = Children.toArray(children).filter(\n    Boolean\n  ) as ReactElement<RatingButtonProps>[];\n  const total = validChildren.length;\n\n  const handleValueChange = useCallback(\n    (\n      event: MouseEvent<HTMLButtonElement> | KeyboardEvent<HTMLButtonElement>,\n      newValue: number\n    ) => {\n      if (!readOnly) {\n        onChange?.(event, newValue);\n        onValueChange?.(newValue);\n      }\n    },\n    [readOnly, onChange, onValueChange]\n  );\n\n  const handleKeyDown = useCallback(\n    (event: KeyboardEvent<HTMLButtonElement>) => {\n      if (readOnly || total === 0) {\n        return;\n      }\n\n      let newValue = focusedStar !== null ? focusedStar : (value ?? 0);\n\n      switch (event.key) {\n        case \"ArrowRight\":\n          if (event.shiftKey || event.metaKey) {\n            newValue = total;\n          } else {\n            newValue = Math.min(total, newValue + 1);\n          }\n          break;\n        case \"ArrowLeft\":\n          if (event.shiftKey || event.metaKey) {\n            newValue = 1;\n          } else {\n            newValue = Math.max(1, newValue - 1);\n          }\n          break;\n        default:\n          return;\n      }\n\n      event.preventDefault();\n      setFocusedStar(newValue);\n      handleValueChange(event, newValue);\n    },\n    [focusedStar, value, total, readOnly, handleValueChange]\n  );\n\n  useEffect(() => {\n    if (focusedStar !== null && containerRef.current) {\n      const buttons = containerRef.current.querySelectorAll(\"button\");\n      buttons[focusedStar - 1]?.focus();\n    }\n  }, [focusedStar]);\n\n  const contextValue: RatingContextValue = {\n    value: value ?? 0,\n    readOnly,\n    size,\n    hoverValue,\n    focusedStar,\n    handleValueChange,\n    handleKeyDown,\n    setHoverValue,\n    setFocusedStar,\n  };\n\n  return (\n    <RatingContext.Provider value={contextValue}>\n      <div\n        aria-label=\"Rating\"\n        aria-readonly={readOnly}\n        className={cn(\"inline-flex items-center gap-0.5\", className)}\n        onMouseLeave={() => setHoverValue(null)}\n        ref={containerRef}\n        role=\"radiogroup\"\n        {...props}\n      >\n        {validChildren.map((child, index) => cloneElement(child, { index }))}\n      </div>\n    </RatingContext.Provider>\n  );\n};\n",
      "type": "registry:ui"
    }
  ],
  "title": "Rating",
  "description": "Compound star rating built from Rating and RatingButton — hover to preview, click or use arrow keys to set the value, with configurable size on the root, a springy scale pop, and color fade that ripples across the stars."
}
