{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"rating","type":"registry:ui","title":"Rating","description":"Stars that are a native radio group: arrows change the value, a pointer previews, half steps when you need them, and a read-only average that says how many ratings it comes from.","dependencies":["clsx","tailwind-merge"],"registryDependencies":["https://caliperui.site/r/styles.json"],"files":[{"path":"ui/lib/cn.ts","type":"registry:ui","target":"components/ui/lib/cn.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\nconst twMerge = extendTailwindMerge({\n  extend: { theme: { radius: [\"control\", \"surface\", \"overlay\"], shadow: [\"overlay\"] } },\n});\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"},{"path":"ui/rating.tsx","type":"registry:ui","target":"components/ui/rating.tsx","content":"\"use client\";\n\nimport { useId, useState, type KeyboardEvent } from \"react\";\nimport { cn } from \"./lib/cn\";\nimport \"./shared/styles-5.css\";\nimport \"./rating.css\";\n\nconst STAR = \"M12 2.6l2.83 5.9 6.47.84-4.74 4.5 1.2 6.43L12 17.17l-5.76 3.1 1.2-6.43-4.74-4.5 6.47-.84z\";\n\nfunction Star({ fill }: { fill: \"full\" | \"half\" | \"empty\" }) {\n  return (\n    <span data-slot=\"rating-glyph\" data-fill={fill} aria-hidden=\"true\">\n      <svg viewBox=\"0 0 24 24\" data-layer=\"track\"><path d={STAR} /></svg>\n      <svg viewBox=\"0 0 24 24\" data-layer=\"fill\"><path d={STAR} /></svg>\n    </span>\n  );\n}\n\nconst fillOf = (shown: number, index: number) => (shown >= index ? \"full\" : shown >= index - 0.5 ? \"half\" : \"empty\");\nconst roundHalf = (value: number) => Math.round(value * 2) / 2;\nconst formatValue = (value: number) => (Number.isInteger(value) ? String(value) : value.toFixed(1));\n\nexport type RatingProps = {\n  /** Names the group, e.g. \"Rate this recipe\". Shown as the legend unless `hideLabel`. */\n  label: string;\n  hideLabel?: boolean;\n  value?: number;\n  defaultValue?: number;\n  onValueChange?: (value: number) => void;\n  /** Number of stars. */\n  max?: number;\n  /** 0.5 allows half stars. */\n  step?: 1 | 0.5;\n  /** Submitted with the form. */\n  name?: string;\n  disabled?: boolean;\n  /** Display only: one image with the value in words, e.g. a product's average. */\n  readOnly?: boolean;\n  /** A word for each whole star, shown beside the stars, e.g. [\"Poor\", \"Fair\", \"Good\", \"Great\", \"Loved it\"]. */\n  labels?: readonly string[];\n  /** Choosing the current value again, or Delete, clears it. */\n  clearable?: boolean;\n  /** Read-only: how many ratings the average comes from. */\n  count?: number;\n  /** Read-only: show the number after the stars. */\n  showValue?: boolean;\n  size?: \"sm\" | \"md\" | \"lg\";\n  className?: string;\n};\n\n/**\n * Stars that work as a native radio group: arrows change the value, the\n * chosen star settles in, a pointer previews before you choose, and the\n * value posts with the form. Read-only, it is one image that says the\n * average and how many ratings it comes from.\n */\nexport function Rating({\n  label,\n  hideLabel = false,\n  value: controlled,\n  defaultValue = 0,\n  onValueChange,\n  max = 5,\n  step = 1,\n  name,\n  disabled,\n  readOnly = false,\n  labels,\n  clearable = true,\n  count,\n  showValue = true,\n  size = \"md\",\n  className,\n}: RatingProps) {\n  const groupName = useId();\n  const [inner, setInner] = useState(defaultValue);\n  const [hover, setHover] = useState<number | null>(null);\n  const [pop, setPop] = useState({ star: 0, n: 0 });\n  const value = controlled ?? inner;\n  const stars = Array.from({ length: max }, (_, i) => i + 1);\n  const wordFor = (v: number) => (labels && v > 0 ? labels[Math.min(labels.length, Math.ceil(v)) - 1] : undefined);\n\n  if (readOnly) {\n    const shown = roundHalf(value);\n    const spoken = `Rated ${formatValue(Math.round(value * 10) / 10)} out of ${max}${count !== undefined ? `, from ${count.toLocaleString(\"en-US\")} ratings` : \"\"}`;\n    return (\n      <span data-slot=\"rating\" data-readonly=\"\" data-size={size} role=\"img\" aria-label={spoken} className={cn(\"inline-flex items-center gap-1.5\", className)}>\n        <span data-slot=\"rating-stars\" aria-hidden=\"true\">\n          {stars.map((star) => (\n            <span key={star} data-slot=\"rating-star\">\n              <Star fill={fillOf(shown, star)} />\n            </span>\n          ))}\n        </span>\n        {showValue && <span data-slot=\"rating-value\" aria-hidden=\"true\">{formatValue(Math.round(value * 10) / 10)}</span>}\n        {count !== undefined && <span data-slot=\"rating-count\" aria-hidden=\"true\">({count.toLocaleString(\"en-US\")})</span>}\n      </span>\n    );\n  }\n\n  function choose(next: number, star: number) {\n    const resolved = clearable && next === value ? 0 : next;\n    if (controlled === undefined) setInner(resolved);\n    onValueChange?.(resolved);\n    if (resolved) setPop((current) => ({ star, n: current.n + 1 }));\n  }\n\n  function clear(event: KeyboardEvent) {\n    if (!clearable || (event.key !== \"Backspace\" && event.key !== \"Delete\")) return;\n    event.preventDefault();\n    if (controlled === undefined) setInner(0);\n    onValueChange?.(0);\n  }\n\n  const shown = hover ?? value;\n  const word = wordFor(shown);\n  const options = (star: number) => (step === 0.5 ? [star - 0.5, star] : [star]);\n\n  return (\n    <fieldset data-slot=\"rating\" data-size={size} disabled={disabled} className={cn(\"m-0 min-w-0 border-0 p-0\", className)}>\n      <legend className={cn(\"mb-1.5 p-0 text-sm font-medium\", hideLabel && \"sr-only\")}>{label}</legend>\n      <div className=\"flex flex-wrap items-center gap-x-3 gap-y-1\">\n        <div data-slot=\"rating-stars\" onPointerLeave={() => setHover(null)}>\n          {stars.map((star) => (\n            <span key={star} data-slot=\"rating-star\" data-half={step === 0.5 || undefined}>\n              <span key={pop.star === star ? pop.n : 0} data-slot=\"rating-settle\" data-pop={pop.star === star && pop.n > 0 ? \"\" : undefined}>\n                <Star fill={fillOf(shown, star)} />\n              </span>\n              {options(star).map((option) => (\n                <input\n                  key={option}\n                  type=\"radio\"\n                  name={name ?? groupName}\n                  value={option}\n                  checked={value === option}\n                  data-side={step === 0.5 ? (option === star ? \"end\" : \"start\") : undefined}\n                  aria-label={`${formatValue(option)} of ${max} stars${wordFor(option) ? `, ${wordFor(option)}` : \"\"}`}\n                  onChange={() => choose(option, star)}\n                  onClick={(event) => {\n                    // A click on the already chosen star fires no change event: clear it here.\n                    if (value === option && event.detail > 0) choose(option, star);\n                  }}\n                  onKeyDown={clear}\n                  onPointerEnter={(event) => event.pointerType === \"mouse\" && setHover(option)}\n                />\n              ))}\n            </span>\n          ))}\n        </div>\n        {/* Each radio already says its word; this line is for the eye, and previews under the pointer. */}\n        <span data-slot=\"rating-word\" data-preview={hover !== null || undefined} aria-hidden=\"true\">\n          {word ?? (value > 0 ? `${formatValue(value)} of ${max}` : \"Not rated\")}\n        </span>\n      </div>\n    </fieldset>\n  );\n}\n"},{"path":"ui/shared/styles-5.css","type":"registry:file","target":"components/ui/shared/styles-5.css","content":"/* Shared by not-found-compass.tsx, rating.tsx, ruler-slider.tsx; each imports it. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  [data-half=\"north\"] { fill: var(--foreground); }\n  [data-half=\"north-shade\"] { fill: color-mix(in oklab, var(--foreground) 70%, var(--background)); }\n  [data-half=\"south\"] { fill: var(--border); }\n}\n"},{"path":"ui/rating.css","type":"registry:file","target":"components/ui/rating.css","content":"/* rating.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  /* Rating: stars in ember; a half star clips its fill at the middle. */\n  [data-slot=\"rating\"] { --star: 22px; --star-cell: 30px; }\n  [data-slot=\"rating\"][data-size=\"sm\"] { --star: 15px; --star-cell: 20px; }\n  [data-slot=\"rating\"][data-size=\"lg\"] { --star: 30px; --star-cell: 38px; }\n  @media (pointer: coarse) { [data-slot=\"rating\"]:not([data-readonly]) { --star-cell: 44px; } }\n  [data-slot=\"rating\"][data-readonly] { --star-cell: var(--star); }\n  [data-slot=\"rating-stars\"] { display: inline-flex; align-items: center; }\n  [data-slot=\"rating-star\"] { position: relative; display: grid; place-items: center; width: var(--star-cell); height: var(--star-cell); border-radius: var(--radius-control); }\n  [data-slot=\"rating\"][data-readonly] [data-slot=\"rating-star\"] { width: calc(var(--star) + 2px); height: var(--star); }\n  [data-slot=\"rating-star\"]:has(input:focus-visible) { outline: 2px solid var(--ring); outline-offset: 0; }\n  [data-slot=\"rating-star\"] input {\n    position: absolute; inset: 0; z-index: 1; margin: 0; opacity: 0; cursor: pointer; appearance: none;\n  }\n  [data-slot=\"rating-star\"][data-half] input[data-side=\"start\"] { inset-inline-end: 50%; }\n  [data-slot=\"rating-star\"][data-half] input[data-side=\"end\"] { inset-inline-start: 50%; }\n  [data-slot=\"rating\"]:disabled input { cursor: not-allowed; }\n  [data-slot=\"rating\"]:disabled [data-slot=\"rating-stars\"] { opacity: 0.45; }\n  [data-slot=\"rating-settle\"] { display: grid; }\n  [data-slot=\"rating-settle\"][data-pop] { animation: caliper-star-settle var(--duration-deliberate) var(--ease-settle); }\n  [data-slot=\"rating-glyph\"] { position: relative; display: grid; width: var(--star); height: var(--star); }\n  [data-slot=\"rating-glyph\"] svg { grid-area: 1 / 1; width: 100%; height: 100%; }\n  [data-slot=\"rating-glyph\"] [data-layer=\"track\"] path { fill: var(--muted); stroke: var(--border-strong); stroke-width: 1.25; stroke-linejoin: round; }\n  [data-slot=\"rating-glyph\"] [data-layer=\"fill\"] path { fill: var(--brand); stroke: var(--brand); stroke-width: 1.25; stroke-linejoin: round; }\n  [data-slot=\"rating-glyph\"] [data-layer=\"fill\"] { clip-path: inset(0 100% 0 0); transition: clip-path var(--duration-fast) var(--ease-out); }\n  [data-slot=\"rating-glyph\"][data-fill=\"full\"] [data-layer=\"fill\"] { clip-path: inset(0 0 0 0); }\n  [data-slot=\"rating-glyph\"][data-fill=\"half\"] [data-layer=\"fill\"] { clip-path: inset(0 50% 0 0); }\n  [dir=\"rtl\"] [data-slot=\"rating-glyph\"][data-fill=\"half\"] [data-layer=\"fill\"] { clip-path: inset(0 0 0 50%); }\n  [data-slot=\"rating-word\"] { min-width: 6ch; font-size: 13px; color: var(--muted-foreground); font-variant-numeric: tabular-nums; }\n  [data-slot=\"rating-word\"][data-preview] { color: var(--foreground); }\n  [data-slot=\"rating-value\"] { font-size: 13px; font-weight: 600; font-variant-numeric: tabular-nums; }\n  [data-slot=\"rating-count\"] { font-size: 13px; color: var(--muted-foreground); font-variant-numeric: tabular-nums; }\n\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"rating-settle\"][data-pop] { animation: none !important; }\n  }\n}\n\n@keyframes caliper-star-settle { 0% { scale: 0.82; } 100% { scale: 1; } }\n"}],"categories":["inputs"],"meta":{"tier":"free","license":"MIT","family":"choices","docs":"/docs/components/rating"}}