{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "rating",
  "title": "Rating",
  "description": "A star rating that works both ways: as an input that collects a score, and read-only as a display of one. Reach for it wherever a number between 0 and 5 is really a row of stars: the \"rate this\" step after a purchase, delivery or booking; a product, app-store or seller review form; a CSAT or satisfaction question at the end of a support ticket or chat; a post-call or post-session feedback prompt; a difficulty, quality or priority score on an internal form; and, in read-only mode, the average beside a listing, a product card, a search result, a testimonial or a review summary (an average like 3.7 fills 70% of the fourth star, so the display is not rounded to a whole one). Common asks it answers: \"star rating\", \"rating component\", \"rating input\", \"react star rating\", \"five star rating\", \"half star rating\", \"review stars\", \"star rating readonly\", \"average rating display\", \"feedback rating component\", \"shadcn rating\", \"shadcn star rating\", \"react-rating alternative\", \"rate this product component\", \"CSAT stars\". Official shadcn/ui has no rating or star item of any kind — its slider is a range control with a thumb on a track, which is a different shape of answer — so the usual fallback is a row of buttons with no shared value semantics, and that is what makes it inaccessible. This one is a real slider: focusable with a focus-visible ring, arrow keys raise and lower the score in either axis, Home clears it to 0 and End maxes it out, and it exposes aria-valuemin/valuemax/valuenow plus a spoken aria-valuetext (\"3.5 out of 5 stars\") so a screen reader announces the score rather than counting buttons; read-only mode drops the slider role and renders as a labelled image instead, which is the correct semantic for a number you cannot change, and the individual stars stay aria-hidden either way because the value is announced once, not five times. Set allowHalf to take half stars — click the left half of a star, or step by 0.5 from the keyboard. Works controlled or uncontrolled through a plain number value with onValueChange (a number, not a string, so there is nothing to parse), forwards a ref, and posts through a hidden input in native forms via `name`. `max` changes the number of stars for a 3- or 10-point scale, `size` sets the pixel size, and `disabled` keeps the slider semantics while refusing input. Theme-aware via shadcn tokens — filled stars use the primary color, so it follows light and dark mode — with lucide-react as its only dependency, for the star icon.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/rating.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Star } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\ninterface RatingProps\n  extends Omit<\n    React.ComponentPropsWithoutRef<\"div\">,\n    \"onChange\" | \"defaultValue\" | \"children\"\n  > {\n  /** Controlled value from 0 to max. Pair with onValueChange. */\n  value?: number\n  /** Initial value when uncontrolled (default 0). */\n  defaultValue?: number\n  /** Fires with the new rating when the user picks one. */\n  onValueChange?: (value: number) => void\n  /** Number of stars (default 5). */\n  max?: number\n  /** Let the user pick half stars (keyboard step and the left half of a star). */\n  allowHalf?: boolean\n  /** Show the stars without letting the user change them — e.g. an average score. */\n  readOnly?: boolean\n  /** Disable input and dim the control. */\n  disabled?: boolean\n  /** Star size in pixels (default 20). */\n  size?: number\n  /** Render a hidden input with this name so the value posts in a native form. */\n  name?: string\n  /** Accessible name for the control, e.g. \"Rate this product\". */\n  \"aria-label\"?: string\n}\n\nfunction clamp(n: number, min: number, max: number) {\n  return Math.min(max, Math.max(min, n))\n}\n\n// Drop a trailing \".0\" so labels read \"4\" and \"3.5\", not \"4.0\".\nfunction format(n: number) {\n  return Number.isInteger(n) ? String(n) : n.toFixed(1)\n}\n\nexport const Rating = React.forwardRef<HTMLDivElement, RatingProps>(\n  function Rating(\n    {\n      className,\n      value,\n      defaultValue,\n      onValueChange,\n      max = 5,\n      allowHalf = false,\n      readOnly = false,\n      disabled = false,\n      size = 20,\n      name,\n      \"aria-label\": ariaLabel,\n      ...props\n    },\n    ref\n  ) {\n    const innerRef = React.useRef<HTMLDivElement>(null)\n    React.useImperativeHandle(ref, () => innerRef.current as HTMLDivElement)\n\n    const isControlled = value !== undefined\n    const [internal, setInternal] = React.useState(() => defaultValue ?? 0)\n    const selected = clamp(isControlled ? (value as number) : internal, 0, max)\n    const [hover, setHover] = React.useState<number | null>(null)\n\n    const isSlider = !readOnly // slider semantics even while disabled\n    const canInput = !readOnly && !disabled // pointer + keyboard active\n    const step = allowHalf ? 0.5 : 1\n    // What the stars paint right now: a hover preview wins while pointing.\n    const shown = hover ?? selected\n\n    function commit(next: number) {\n      const clamped = clamp(next, 0, max)\n      if (!isControlled) setInternal(clamped)\n      if (clamped !== selected) onValueChange?.(clamped)\n      innerRef.current?.focus()\n    }\n\n    // Value under the pointer within a given star (1-indexed), honoring allowHalf.\n    function valueFromPointer(e: React.MouseEvent<HTMLSpanElement>, index: number) {\n      if (!allowHalf) return index\n      const { left, width } = e.currentTarget.getBoundingClientRect()\n      return e.clientX - left < width / 2 ? index - 0.5 : index\n    }\n\n    function handleKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {\n      let next = selected\n      switch (e.key) {\n        case \"ArrowRight\":\n        case \"ArrowUp\":\n          next = selected + step\n          break\n        case \"ArrowLeft\":\n        case \"ArrowDown\":\n          next = selected - step\n          break\n        case \"Home\":\n          next = 0\n          break\n        case \"End\":\n          next = max\n          break\n        default:\n          return\n      }\n      e.preventDefault()\n      commit(next)\n    }\n\n    const valueText = `${format(selected)} out of ${max} stars`\n\n    return (\n      <div\n        ref={innerRef}\n        role={isSlider ? \"slider\" : \"img\"}\n        aria-label={ariaLabel ?? (isSlider ? \"Rating\" : valueText)}\n        aria-valuemin={isSlider ? 0 : undefined}\n        aria-valuemax={isSlider ? max : undefined}\n        aria-valuenow={isSlider ? selected : undefined}\n        aria-valuetext={isSlider ? valueText : undefined}\n        aria-disabled={disabled || undefined}\n        tabIndex={canInput ? 0 : undefined}\n        onKeyDown={canInput ? handleKeyDown : undefined}\n        onPointerLeave={canInput ? () => setHover(null) : undefined}\n        className={cn(\n          \"inline-flex items-center gap-0.5 rounded-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n          disabled && \"cursor-not-allowed opacity-50\",\n          className\n        )}\n        {...props}\n      >\n        {Array.from({ length: max }, (_, i) => {\n          const index = i + 1\n          // Fraction of this star to fill: 1 = full, 0.5 = left half, 0 = empty.\n          const fill = clamp(shown - i, 0, 1)\n          return (\n            <span\n              key={index}\n              className={cn(\"relative inline-flex\", canInput && \"cursor-pointer\")}\n              onPointerMove={\n                canInput ? (e) => setHover(valueFromPointer(e, index)) : undefined\n              }\n              onClick={canInput ? (e) => commit(valueFromPointer(e, index)) : undefined}\n            >\n              <Star\n                size={size}\n                aria-hidden=\"true\"\n                className=\"fill-transparent text-muted-foreground/40\"\n              />\n              {fill > 0 && (\n                <span\n                  className=\"absolute inset-y-0 left-0 overflow-hidden\"\n                  style={{ width: `${fill * 100}%` }}\n                  aria-hidden=\"true\"\n                >\n                  <Star size={size} className=\"fill-primary text-primary\" />\n                </span>\n              )}\n            </span>\n          )\n        })}\n        {name && !readOnly && (\n          <input\n            type=\"hidden\"\n            name={name}\n            value={selected}\n            disabled={disabled || undefined}\n          />\n        )}\n      </div>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}