{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rating",
  "title": "Rating",
  "description": "Star rating with a sweeping hover fill, half stars, and a springy pop with sparkles on select.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/new-york/rating/rating.tsx",
      "content": "\"use client\"\n\nimport { useState } from \"react\"\nimport { motion, useReducedMotion } from \"motion/react\"\nimport { cn } from \"@/lib/utils\"\n\nexport interface RatingProps {\n  /** Controlled rating. */\n  value?: number\n  /** Initial rating when uncontrolled. Default: 0 */\n  defaultValue?: number\n  /** Called with the new rating when the user picks one. */\n  onValueChange?: (value: number) => void\n  /** Number of stars. Default: 5 */\n  max?: number\n  /** Allow half-star ratings. Default: true */\n  allowHalf?: boolean\n  /** Star size in px. Default: 28 */\n  size?: number\n  /** Fill color (any CSS color). Default: \"#f59e0b\" */\n  color?: string\n  /** Display only, no interaction. Default: false */\n  readOnly?: boolean\n  /** Accessible label. Default: \"Rating\" */\n  \"aria-label\"?: string\n  className?: string\n}\n\nconst STAR = \"M12 2.5l2.94 5.96 6.58.96-4.76 4.64 1.12 6.55L12 17.52l-5.88 3.09 1.12-6.55L2.48 9.42l6.58-.96L12 2.5z\"\nconst SPARKS = [0, 60, 120, 180, 240, 300].map((deg) => (deg * Math.PI) / 180)\n\nconst Star = ({ className, style }: { className?: string; style?: React.CSSProperties }) => (\n  <svg viewBox=\"0 0 24 24\" className={className} style={style} fill=\"currentColor\" stroke=\"currentColor\" strokeWidth={1} strokeLinejoin=\"round\">\n    <path d={STAR} />\n  </svg>\n)\n\nexport function Rating({\n  value,\n  defaultValue = 0,\n  onValueChange,\n  max = 5,\n  allowHalf = true,\n  size = 28,\n  color = \"#f59e0b\",\n  readOnly = false,\n  \"aria-label\": ariaLabel = \"Rating\",\n  className,\n}: RatingProps) {\n  const reduced = useReducedMotion()\n  const [internal, setInternal] = useState(defaultValue)\n  const [hover, setHover] = useState<number | null>(null)\n  const [pop, setPop] = useState<{ index: number; n: number } | null>(null)\n  const current = value ?? internal\n  const shown = readOnly ? current : (hover ?? current)\n\n  const [prev, setPrev] = useState(shown)\n  const [from, setFrom] = useState(shown)\n  if (shown !== prev) {\n    setFrom(prev)\n    setPrev(shown)\n  }\n  const rising = shown >= from\n\n  const unit = allowHalf ? 0.5 : 1\n\n  const commit = (next: number) => {\n    const clamped = Math.min(max, Math.max(0, next))\n    if (value === undefined) setInternal(clamped)\n    onValueChange?.(clamped)\n    if (clamped > 0) setPop((p) => ({ index: Math.ceil(clamped) - 1, n: (p?.n ?? 0) + 1 }))\n  }\n\n  const valueAt = (e: React.MouseEvent, i: number) => {\n    const rect = e.currentTarget.getBoundingClientRect()\n    const fraction = (e.clientX - rect.left) / rect.width\n    return allowHalf && fraction < 0.5 ? i + 0.5 : i + 1\n  }\n\n  const onKeyDown = (e: React.KeyboardEvent) => {\n    const delta = { ArrowRight: unit, ArrowUp: unit, ArrowLeft: -unit, ArrowDown: -unit }[e.key]\n    if (delta) commit(current + delta)\n    else if (e.key === \"Home\") commit(0)\n    else if (e.key === \"End\") commit(max)\n    else return\n    e.preventDefault()\n  }\n\n  const interactive = !readOnly\n  const gap = Math.round(size * 0.15)\n\n  return (\n    <div\n      role={interactive ? \"slider\" : \"img\"}\n      tabIndex={interactive ? 0 : undefined}\n      aria-label={interactive ? ariaLabel : `${ariaLabel}: ${current} out of ${max}`}\n      aria-valuenow={interactive ? current : undefined}\n      aria-valuemin={interactive ? 0 : undefined}\n      aria-valuemax={interactive ? max : undefined}\n      aria-valuetext={interactive ? `${current} out of ${max}` : undefined}\n      onKeyDown={interactive ? onKeyDown : undefined}\n      onPointerLeave={interactive ? () => setHover(null) : undefined}\n      className={cn(\n        \"inline-flex items-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-4 focus-visible:ring-offset-background\",\n        className\n      )}\n      style={{ gap }}\n    >\n      {Array.from({ length: max }, (_, i) => {\n        const fill = Math.min(1, Math.max(0, shown - i))\n        const steps = rising ? i - Math.floor(from) : Math.ceil(from) - 1 - i\n        const popping = pop?.index === i\n        return (\n          <motion.span\n            key={i}\n            className={cn(\"relative block\", interactive && \"cursor-pointer\")}\n            style={{ width: size, height: size }}\n            onPointerMove={interactive ? (e) => setHover(valueAt(e, i)) : undefined}\n            onClick={interactive ? (e) => commit(valueAt(e, i)) : undefined}\n            whileHover={interactive && !reduced ? { scale: 1.1 } : undefined}\n            transition={{ type: \"spring\", stiffness: 500, damping: 20 }}\n          >\n            <motion.span\n              key={popping ? pop.n : \"still\"}\n              className=\"absolute inset-0\"\n              initial={popping && !reduced ? { scale: 0.6, rotate: -12 } : false}\n              animate={{ scale: 1, rotate: 0 }}\n              transition={{ type: \"spring\", stiffness: 520, damping: 12 }}\n            >\n              <Star className=\"absolute inset-0 size-full text-muted-foreground/25\" />\n              <motion.span\n                className=\"absolute inset-y-0 left-0 overflow-hidden\"\n                initial={false}\n                animate={{ width: `${fill * 100}%` }}\n                transition={\n                  reduced\n                    ? { duration: 0 }\n                    : { type: \"spring\", stiffness: 600, damping: 45, delay: Math.max(0, steps) * 0.035 }\n                }\n              >\n                <Star className=\"block max-w-none\" style={{ width: size, height: size, color }} />\n              </motion.span>\n            </motion.span>\n\n            {popping && !reduced && (\n              <span key={`spark-${pop.n}`} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\">\n                {SPARKS.map((angle, s) => (\n                  <motion.span\n                    key={s}\n                    className=\"absolute left-1/2 top-1/2 size-1 rounded-full\"\n                    style={{ background: color, marginLeft: -2, marginTop: -2 }}\n                    initial={{ x: 0, y: 0, opacity: 1, scale: 1 }}\n                    animate={{\n                      x: Math.cos(angle) * size * 0.85,\n                      y: Math.sin(angle) * size * 0.85,\n                      opacity: 0,\n                      scale: 0.3,\n                    }}\n                    transition={{ duration: 0.55, ease: [0.2, 0.8, 0.3, 1] }}\n                  />\n                ))}\n              </span>\n            )}\n          </motion.span>\n        )\n      })}\n    </div>\n  )\n}\n",
      "type": "registry:component"
    }
  ],
  "docs": "Docs, live playground and every prop: https://trytweenly.vercel.app/docs/components/rating",
  "type": "registry:component"
}