{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rating",
  "title": "Rating",
  "description": "Star rating on Base UI RadioGroup with a hover preview, half steps and a read-only mode for any fraction. Icons fill from the left and pop in turn on a spring.",
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/rating/rating.tsx",
      "content": "\"use client\";\n\nimport { Radio } from \"@base-ui/react/radio\";\nimport { RadioGroup } from \"@base-ui/react/radio-group\";\nimport { useAnimate } from \"motion/react\";\nimport { useRef, useState, type CSSProperties, type PointerEvent, type ReactNode } from \"react\";\nimport { bouncy, ease, snappy, stagger, useSafeReducedMotion } from \"../lib/motion\";\n\nexport type RatingProps = {\n  /** The rating, 0 for none (controlled). */\n  value?: number;\n  /** The starting rating (uncontrolled). Default 0. */\n  defaultValue?: number;\n  /** Called when someone picks a rating (or clears it, with `clearable`). */\n  onValueChange?: (value: number) => void;\n  /** Called with the rating under the pointer while previewing, and `null` when it leaves. */\n  onHoverChange?: (value: number | null) => void;\n  /** How many icons. Default 5. */\n  max?: number;\n  /** Allow half steps: each icon has a left and a right half. Default false. */\n  allowHalf?: boolean;\n  /** Choosing the current rating again clears it to 0. Default false. */\n  clearable?: boolean;\n  /**\n   * Display only: any fraction (4.3 fills 30% of the fifth star), no focus stops, read as\n   * one image (\"Rated 4.3 out of 5\"). Default false.\n   */\n  readOnly?: boolean;\n  disabled?: boolean;\n  /** 16px (`sm`, review rows), 20px (`md`, default) or 28px (`lg`, a rating prompt). */\n  size?: \"sm\" | \"md\" | \"lg\";\n  /**\n   * Your own icon: an SVG that paints with `currentColor` (a heart, a flame). It is drawn twice,\n   * an empty layer and a filled one clipped to the rating. Default: a rounded star.\n   */\n  icon?: ReactNode;\n  /** Each step's accessible name. Default `4 stars`, `4.5 stars`. */\n  getLabel?: (value: number) => string;\n  /** Submits the value with a form under this name. */\n  name?: string;\n  /** Accessible name for the group, e.g. \"Rate this recipe\". Or point `aria-labelledby` at a visible label. */\n  \"aria-label\"?: string;\n  \"aria-labelledby\"?: string;\n  /** Appended to the root's classes. Set `--rating` (fill) and `--rating-empty` here to recolour. */\n  className?: string;\n  style?: CSSProperties;\n};\n\nconst SIZES = {\n  sm: { icon: \"size-4\", pad: \"px-px\", ring: \"rounded-control-2xs\" },\n  md: { icon: \"size-5\", pad: \"px-0.5\", ring: \"rounded-control-2xs\" },\n  lg: { icon: \"size-7\", pad: \"px-2\", ring: \"rounded-control-xs\" },\n} as const;\n\nconst Star = () => (\n  <svg viewBox=\"0 0 24 24\" fill=\"currentColor\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinejoin=\"round\" aria-hidden>\n    <path d=\"M12 2.9l2.73 5.53 6.1.89-4.41 4.3 1.04 6.07L12 16.82l-5.46 2.87 1.04-6.07-4.41-4.3 6.1-.89L12 2.9z\" />\n  </svg>\n);\n\nconst defaultLabel = (v: number) => `${v} ${v === 1 ? \"star\" : \"stars\"}`;\n\n// Eased's focus ring, drawn on the icon around whichever of its halves has focus.\nconst RING =\n  \"[transition:box-shadow_var(--eased-duration-focus)_var(--eased-ease-standard)] group-has-[:focus-visible]:shadow-[0_0_0_2px_var(--eased-bg),0_0_0_4px_var(--eased-ring)]\";\n\n/**\n * A rating on Base UI's RadioGroup: one tab stop, arrow keys step through the values (halves\n * too, with `allowHalf`), and it submits with a form. Pointing previews the rating without\n * committing it; choosing one fills the icons from the left, each popping on the bouncy spring\n * 35ms after the one before. `readOnly` turns it into a display of any fraction.\n */\nexport function Rating({\n  value: controlled,\n  defaultValue = 0,\n  onValueChange,\n  onHoverChange,\n  max = 5,\n  allowHalf = false,\n  clearable = false,\n  readOnly = false,\n  disabled = false,\n  size = \"md\",\n  icon,\n  getLabel = defaultLabel,\n  name,\n  className = \"\",\n  style,\n  ...aria\n}: RatingProps) {\n  const [own, setOwn] = useState(defaultValue);\n  const value = controlled ?? own;\n  const [hover, setHover] = useState<number | null>(null);\n  const reduced = useSafeReducedMotion();\n  const [scope, animate] = useAnimate<HTMLDivElement>();\n  const icons = useRef<(HTMLSpanElement | null)[]>([]);\n  const s = SIZES[size];\n  const glyph = icon ?? <Star />;\n  const shown = hover ?? value;\n\n  const preview = (next: number | null) => {\n    setHover(next);\n    onHoverChange?.(next);\n  };\n\n  // The pop: icons that gain fill rise to 1.2 and settle on the bouncy spring, left to right.\n  // A lower rating only nudges its new last icon. Interruptible; skipped under reduced motion.\n  const pop = (from: number, to: number) => {\n    if (reduced) return;\n    const els = icons.current;\n    const run = (i: number, peak: number, delay: number) => {\n      const el = els[i];\n      if (!el) return;\n      animate(el, { scale: peak }, { duration: 0.1, ease: ease.enter, delay }).then(() => animate(el, { scale: 1 }, bouncy));\n    };\n    if (to > from) {\n      const first = Math.floor(from);\n      for (let i = first; i < Math.ceil(to); i++) run(i, 1.2, (i - first) * stagger.item);\n    } else if (to > 0) {\n      const el = els[Math.ceil(to) - 1];\n      if (el) animate(el, { scale: [0.9, 1] }, snappy);\n    }\n  };\n\n  const commit = (next: number) => {\n    if (next === value) return;\n    pop(value, next);\n    if (controlled === undefined) setOwn(next);\n    onValueChange?.(next);\n  };\n\n  const fillOf = (i: number) => Math.min(1, Math.max(0, shown - i));\n\n  const visual = (i: number) => {\n    const fill = fillOf(i);\n    return (\n      <span className={`relative flex ${s.icon} [&_svg]:size-full`}>\n        <span className=\"flex size-full text-(--rating-empty,var(--eased-line))\">{glyph}</span>\n        <span\n          className=\"absolute inset-0 flex text-(--rating,var(--eased-warning)) [transition:clip-path_150ms_var(--eased-ease-standard)] reduced:transition-none\"\n          style={{ clipPath: `inset(0 ${(1 - fill) * 100}% 0 0)` }}\n        >\n          {glyph}\n        </span>\n      </span>\n    );\n  };\n\n  if (readOnly) {\n    const label = aria[\"aria-label\"] ?? `Rated ${Math.round(value * 10) / 10} out of ${max}`;\n    return (\n      <span role=\"img\" aria-label={label} className={`inline-flex shrink-0 items-center ${className}`} style={style}>\n        {Array.from({ length: max }, (_, i) => (\n          <span key={i} className={`flex ${s.pad} first:ps-0 last:pe-0`}>\n            {visual(i)}\n          </span>\n        ))}\n      </span>\n    );\n  }\n\n  const onPointerMove = (v: number) => (e: PointerEvent) => {\n    if (e.pointerType === \"mouse\" && !disabled && hover !== v) preview(v);\n  };\n\n  return (\n    <RadioGroup<number>\n      {...aria}\n      ref={scope}\n      value={value}\n      onValueChange={(next) => commit(next)}\n      name={name}\n      disabled={disabled}\n      onPointerLeave={() => hover !== null && preview(null)}\n      // Keys show the real value, not whatever the resting pointer last previewed.\n      onKeyDown={() => hover !== null && preview(null)}\n      className={`inline-flex shrink-0 items-center select-none ${disabled ? \"cursor-not-allowed opacity-50\" : \"\"} ${className}`}\n      style={style}\n    >\n      {Array.from({ length: max }, (_, i) => {\n        const steps = allowHalf ? [i + 0.5, i + 1] : [i + 1];\n        return (\n          // The padding (not a gap) spaces the icons, so the pointer never falls between them.\n          <span key={i} className={`group relative flex ${s.pad} ${size === \"lg\" ? \"py-2\" : \"py-1\"} first:ps-0 last:pe-0`}>\n            <span\n              ref={(el) => {\n                icons.current[i] = el;\n              }}\n              className={`flex ${s.ring} ${RING}`}\n            >\n              {visual(i)}\n            </span>\n            {steps.map((v, k) => (\n                <Radio.Root\n                  key={v}\n                  value={v}\n                  aria-label={getLabel(v)}\n                  onPointerMove={onPointerMove(v)}\n                  onClick={() => {\n                    if (clearable && v === value) commit(0);\n                  }}\n                  className={`absolute inset-y-0 outline-none ${\n                    steps.length === 1 ? \"inset-x-0\" : k === 0 ? \"start-0 w-1/2\" : \"end-0 w-1/2\"\n                  } ${disabled ? \"cursor-not-allowed\" : \"cursor-pointer\"}`}\n                />\n              ))}\n          </span>\n        );\n      })}\n    </RadioGroup>\n  );\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/rating/rating.tsx"
    }
  ],
  "meta": {
    "industries": [
      "ecommerce",
      "travel",
      "health",
      "education",
      "media"
    ]
  },
  "docs": "Import Rating from components/eased/rating/rating. The Eased tokens were merged into your global CSS; nothing to import. Dark mode follows .dark or data-theme=\"dark\".",
  "type": "registry:ui"
}