# Rating (Lumen Halo): prompt.md (v1.0.0)

- id: `lumen-rating` · version 1.0.0 · component · free
- category: Inputs
- build: Base UI (one set of files; its dependencies follow the build)
- install (this build): `npx shadcn@latest add https://beautiful-ui.dev/r/lumen-rating.json`
- npm dependencies: none
- registry dependencies: utils, https://beautiful-ui.dev/r/lumen-foundation.json
- docs: https://beautiful-ui.dev/components/lumen-rating
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

Stars you can set, or a score you show: interactive it's a real radio group (arrows, forms, screen readers) with a hover preview and press-again to clear; read-only it draws fractions and reads "4.3 out of 5".

## Build it
- Stack: React 19 (`ref` is a plain prop), TypeScript, Tailwind CSS v4 utilities, a shadcn-initialised project with the `@/*` alias.
- Packages: none beyond React.
- Files: `components/ui/lumen/controls/rating.tsx`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `lumen-foundation`.
- Builds: one set of files for both, but its dependencies come in Base UI and Radix builds. Install the one that matches the project (see Install): a free item's bare URL installs the Base UI build of it and its dependencies.
- Exports to keep: `Rating`, `(word)`, and every exported type.
- CSS: the install merges this item's rules (the registry `css` field) into your global stylesheet, in `@layer components`, and adds the lumen foundation (tokens, keyframes, motion levels) once. Nothing to import by hand.
- Re-running `add` (or `--overwrite`) re-applies those rules: put overrides in your own CSS, never in the installed rules.
- Tokens: retheme with the `--lumen-*` custom properties (`--lumen-accent`, `--lumen-accent-foreground`, `--lumen-accent-text`, `--lumen-bad`, `--lumen-bad-text`, `--lumen-chart-accent`, `--lumen-chart-accent-foreground`, `--lumen-chart-accent-text`, `--lumen-focus`, `--lumen-good`, `--lumen-good-text`, `--lumen-hairline`, `--lumen-ink`, `--lumen-muted-ink`, `--lumen-series-1`, `--lumen-series-2`, `--lumen-series-3`, `--lumen-series-4`, `--lumen-series-5`, `--lumen-series-6`, `--lumen-warn`, `--lumen-warn-text`); this item's CSS also reads `--lumen-font-sans`, `--lumen-radius-k`. Never add Tailwind colour classes inside the component.

```tsx
import { Rating } from "@/components/ui/lumen/controls/rating";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `Rating` | — | The group (or the read-only image). |
| `(word)` | — | The word for the shown score. |

## Sound
- Keep every `data-slot` and `data-sound` attribute: the sound layer reads them.
- Installing this item adds no audio. Nothing plays until the app mounts `GlassSoundProvider` once (install: `npx shadcn@latest add https://beautiful-ui.dev/r/glass-sound.json`, import from `@/components/beautiful-ui/glass-sound`); `GlassSoundToggle` is its mute control. Without a provider the audio engine never loads.

## Match the original
- Read `components/ui/lumen/controls/rating.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when
- rating, star rating, stars, review score, feedback stars, product rating, score display, 5 stars
- Asking for a quick score: a review, feedback after a support chat, a template's quality
- Showing an average score next to its count

### Not when
- Measuring something precise: use a number or Slider
- A yes/no reaction: use Toggle

## Mistakes
- Show the count next to an average: 4.8 from 3 reviews means little
- Use readOnly for averages; interactive ratings are whole numbers

## Usage

```tsx
"use client";

import * as React from "react";
import { Rating } from "@/components/ui/lumen/controls/rating";

export function ReviewScore({ average, count }: { average: number; count: number }) {
  const [mine, setMine] = React.useState(0);
  return (
    <div className="grid gap-3">
      <div className="flex items-center gap-2">
        <Rating readOnly value={average} size="sm" />
        <span className="text-sm text-muted-foreground">{average.toFixed(1)} from {count} reviews</span>
      </div>
      <Rating aria-label="Your rating" value={mine} onValueChange={setMine} words={["Poor", "Fair", "Good", "Great", "Excellent"]} />
    </div>
  );
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `value / defaultValue / onValueChange` | `number / number / (value: number) => void` | `0` | The score; 0 is none. Interactive scores are whole numbers. |
| `max` | `number` | `5` | How many stars. |
| `readOnly` | `boolean` | `false` | Show a score (fractions fill part of a star) instead of asking for one. |
| `clearable` | `boolean` | `true` | Pressing the chosen star again clears it. |
| `words` | `string[]` |  | A word for each score, shown beside the stars and read with each one. |
| `name` | `string` |  | The radios' name, for forms. |
| `size` | `"sm" \| "default" \| "lg"` | `"default"` | 16, 20 or 26px stars (28px on touch when interactive). |
| `icon / labels / locale / aria-label` | `ReactNode / Partial<{ item, score }> / string / string` |  | Your own glyph, the words for other languages, the number format, and the group's name (default "Rating"). |

Full docs: https://beautiful-ui.dev/components/lumen-rating

## Customising
- Colours: set the `--lumen-*` tokens on `:root`, or on a container with the `lumen-scope` class to retheme one area. Add the `lumen-inherit` class to follow your shadcn palette instead (`--chart-N`, `--destructive`).
- Dark mode follows the `.dark` class on an ancestor (the shadcn and next-themes convention).
- Update later by re-running the install with `--overwrite` (review the diff if you edited it). Changelog: https://beautiful-ui.dev/r/changelog.json

## Keyboard

| Keys | Action |
|---|---|
| Tab | Into the group (the chosen star, or the first) |
| Arrow keys | Choose the previous or next score |
| Space | Choose the focused star; again clears it |

## Motion inventory

| Interaction | What moves |
|---|---|
| Press | The star dips a little on press; hover previews the score at a lighter ink |

## Accessibility contract (preserve when editing)
- Interactive: a radiogroup of native radio inputs, each named ("4 stars, Great")
- Read-only: one image named with the score ("4.3 out of 5")
- Focus is a ring on the star; hover never changes the value

## Install

Base UI project (a base-* style in components.json):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/lumen-rating.json
```

Radix project (a radix-*, new-york or default style):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/radix-nova/lumen-rating.json
```

Or add the `@beautiful-ui` registry to components.json and run `npx shadcn@latest add @beautiful-ui/lumen-rating`: the CLI picks the build from your style.

## Credits
- Built on shadcn/ui (https://ui.shadcn.com)

## Source (Base UI build)

### components/ui/lumen/controls/rating.tsx

```tsx
"use client";
// Generated from src/registry/core/controls/rating.tsx by scripts/gen-systems.ts. Edit the core file, not this one.

import * as React from "react";
import { cn } from "@/lib/utils";

/*
 * Rating: stars you can set, or a score you show. Interactive, it's a group of real radio inputs, so
 * the arrows, forms (name) and screen readers work as they do for any radio group; hovering previews
 * the score, and pressing the chosen star again clears it. Read-only, it draws fractions (4.3 fills a
 * third of the fifth star) and is announced as "4.3 out of 5". Words per score are optional.
 *
 *   <Rating aria-label="Your rating" defaultValue={4} words={["Poor", "Fair", "Good", "Great", "Excellent"]} />
 *   <Rating readOnly value={4.3} />
 */

export interface RatingLabels {
  /** Each star's name: "3 stars". */
  item: (n: number, max: number) => string;
  /** The read-only score: "4.3 out of 5". */
  score: (value: string, max: number) => string;
}

const DEFAULT_LABELS: RatingLabels = {
  item: (n) => `${n} ${n === 1 ? "star" : "stars"}`,
  score: (v, max) => `${v} out of ${max}`,
};

/** How much of star `i` (0-based) is filled for a score: 0–1. */
export function starFill(value: number, i: number): number {
  return Math.max(0, Math.min(1, value - i));
}

const Star = () => (
  <svg aria-hidden viewBox="0 0 20 20" className={`lumen-rating-glyph`}>
    <path d="M10 1.9l2.47 5.01 5.53.8-4 3.9.94 5.5L10 14.52 5.06 17.1 6 11.62l-4-3.9 5.53-.8z" />
  </svg>
);

function Rating({
  value: valueProp,
  defaultValue = 0,
  onValueChange,
  max: maxProp = 5,
  readOnly = false,
  disabled,
  clearable = true,
  name,
  size = "default",
  words,
  icon,
  locale,
  labels: labelsProp,
  className,
  "aria-label": ariaLabel = "Rating",
  ...props
}: Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
  /** The score: whole numbers when interactive; any fraction read-only. 0 is none. */
  value?: number | undefined;
  defaultValue?: number;
  onValueChange?: ((value: number) => void) | undefined;
  /** How many stars. Default 5. */
  max?: number;
  /** Show a score instead of asking for one. */
  readOnly?: boolean;
  disabled?: boolean | undefined;
  /** Pressing the chosen star again clears it. Default true. */
  clearable?: boolean;
  /** The radio inputs' name, for forms. */
  name?: string | undefined;
  size?: "sm" | "default" | "lg";
  /** A word for each score (index 0 is 1 star), shown beside the stars. */
  words?: readonly string[] | undefined;
  /** Replaces the star glyph. */
  icon?: React.ReactNode;
  locale?: string | undefined;
  labels?: Partial<RatingLabels> | undefined;
}) {
  const labels = { ...DEFAULT_LABELS, ...labelsProp };
  const [own, setOwn] = React.useState(defaultValue);
  const value = valueProp ?? own;
  const [hover, setHover] = React.useState<number | null>(null);
  const autoName = React.useId();
  const set = (next: number) => {
    if (valueProp === undefined) setOwn(next);
    onValueChange?.(next);
  };
  // One normalised scale for everything (choices, words, score): 1–50 whole steps.
  const max = Number.isFinite(maxProp) ? Math.max(1, Math.min(50, Math.round(maxProp))) : 5;
  const stars = Array.from({ length: max }, (_, i) => i);
  const glyph = icon ?? <Star />;

  if (readOnly) {
    const text = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 }).format(value);
    return (
      <div role="img" aria-label={ariaLabel && ariaLabel !== "Rating" ? `${ariaLabel}: ${labels.score(text, max)}` : labels.score(text, max)} data-slot="rating" data-readonly="" data-size={size} className={cn(`lumen-rating`, className)} {...props}>
        {stars.map((i) => (
          <span key={i} className={`lumen-rating-star`} style={{ "--fill": `${starFill(value, i) * 100}%` } as React.CSSProperties}>
            {glyph}
            <span className={`lumen-rating-star-fill`}>{glyph}</span>
          </span>
        ))}
      </div>
    );
  }

  const shown = hover ?? value;
  const word = words?.[Math.round(shown) - 1];
  return (
    <div
      role="radiogroup"
      aria-label={ariaLabel}
      aria-disabled={disabled || undefined}
      data-slot="rating"
      data-size={size}
      data-hovering={hover !== null || undefined}
      className={cn(`lumen-rating`, className)}
      onPointerLeave={() => setHover(null)}
      {...props}
    >
      {stars.map((i) => {
        const n = i + 1;
        return (
          <label key={i} className={`lumen-rating-star`} data-on={n <= shown || undefined} onPointerEnter={() => !disabled && setHover(n)}>
            <input
              type="radio"
              className={`lumen-rating-input`}
              name={name ?? autoName}
              value={n}
              checked={value === n}
              disabled={disabled}
              aria-label={words?.[i] ? `${labels.item(n, max)}, ${words[i]}` : labels.item(n, max)}
              onChange={() => set(n)}
              onClick={() => {
                // A radio can't be unchecked by itself: pressing the chosen star again clears it.
                if (clearable && value === n) set(0);
              }}
            />
            {glyph}
          </label>
        );
      })}
      {words && (
        <span aria-hidden data-slot="rating-word" className={`lumen-rating-word`}>
          {word ?? ""}
        </span>
      )}
    </div>
  );
}

export { Rating };
```

### CSS (the registry `css` / `cssVars`, merged into the global stylesheet by the shadcn CLI)

```css
@layer components {
  .lumen-rating {
    --star: 20px;
    --rating-color: var(--gc-ink);
    display: inline-flex;
    align-items: center;
    gap: 2px;
    color: var(--gc-ink);
    font: 400 12.5px/1 var(--lumen-font-sans, var(--font-sans, var(--font-geist, var(--font-geist-sans, 'Geist')))), system-ui, sans-serif;
  }
  .lumen-rating[data-size="sm"] {
    --star: 16px;
  }
  .lumen-rating[data-size="lg"] {
    --star: 26px;
    gap: 4px;
  }
  .lumen-rating-star {
    position: relative;
    display: inline-grid;
    flex: none;
    place-items: center;
    width: var(--star);
    height: var(--star);
    border-radius: calc(6px * var(--lumen-radius-k, 1));
    color: color-mix(in srgb,var(--gc-ink) 14%,transparent);
    transition: color min(0.15s, var(--gcp-cm)) ease;
  }
  .lumen-rating:not([data-readonly]) .lumen-rating-star {
    cursor: pointer;
  }
  .lumen-rating-star[data-on] {
    color: var(--rating-color);
  }
  .lumen-rating[data-hovering] .lumen-rating-star[data-on] {
    color: color-mix(in srgb,var(--rating-color) 62%,transparent);
  }
  .lumen-rating-glyph {
    width: 100%;
    height: 100%;
    fill: currentColor;
    transition: transform calc(.25s * var(--gcp-k,1)) var(--gc-sp);
  }
  .lumen-rating-star:active .lumen-rating-glyph {
    transform: scale(.86);
  }
  .lumen-rating-input {
    position: absolute;
    inset: 0;
    z-index: 1;
    width: 100%;
    height: 100%;
    margin: 0;
    opacity: 0;
    cursor: inherit;
    appearance: none;
  }
  .lumen-rating-star:has(.lumen-rating-input:focus-visible) {
    box-shadow: 0 0 0 2px var(--gc-gap), 0 0 0 4px var(--gc-p-focus), 0 0 #0000;
  }
  .lumen-rating[aria-disabled="true"] {
    opacity: .5;
  }
  .lumen-rating[aria-disabled="true"] .lumen-rating-star {
    cursor: default;
  }
  .lumen-rating-star-fill {
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    color: var(--rating-color);
    clip-path: inset(0 calc(100% - var(--fill)) 0 0);
  }
  [dir="rtl"] .lumen-rating-star-fill {
    clip-path: inset(0 0 0 calc(100% - var(--fill)));
  }
  .lumen-rating-word {
    min-width: 9ch;
    margin-inline-start: 8px;
    color: var(--gc-sec);
  }
  @media (pointer:coarse) {
    .lumen-rating:not([data-readonly]) {
      --star: 28px;
    }
  }
  @media (prefers-reduced-motion:reduce) {
    .lumen-rating-glyph {
      transition: none;
    }
  }
}
```

The lumen foundation (the tokens listed above, keyframes and motion levels) installs once with the first component; its CSS is public at https://beautiful-ui.dev/r/lumen-foundation.json.
