Rating input
Five stars as a radio group, with hover preview, arrow keys and a read only display.
npx shadcn@latest add https://ui.goodtabs.co/r/rating-input.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/rating-input
Controlled. Hover previews a value, click or arrow keys set it.
Read only in a review row
- Mira Hale
Installed with one command and matched our theme straight away.
- Tom Okafor
Calm defaults. I only changed the button label.
The sm display inside a list of reviews, labelled Rated 4 of 5.
Large
How was the install?
24px stars under a question, uncontrolled with a default value.
Usage
import { RatingInput } from "@/components/ui/rating-input"
<RatingInput value={rating} onValueChange={setRating} />
<RatingInput readOnly value={4} size="sm" />Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | Controlled value, 0 to max. Leave out to go uncontrolled. | |
| defaultValue | number | 0 | Starting value for the uncontrolled form. |
| onValueChange | (value: number) => void | Fires with the new value. | |
| max | number | 5 | Number of stars. |
| size | "sm" | "default" | "lg" | "default" | Star size: 14, 16 or 24px. |
| readOnly | boolean | false | Renders a static display with an aria-label like Rated 4 of 5. |
| label | string | "Rating" | Accessible name of the group. |
Source
rating-input/rating-input.tsx
"use client";
import * as React from "react";
import { StarIcon } from "lucide-react";
import { cn } from "@/lib/utils";
type Size = "sm" | "default" | "lg";
type RatingInputProps = Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
/** Controlled value, 0 to max. Leave out for an uncontrolled input. */
value?: number;
/** Starting value for the uncontrolled form. */
defaultValue?: number;
/** Fires with the new value when a star is chosen. */
onValueChange?: (value: number) => void;
/** Number of stars. */
max?: number;
/** Star size: 14, 16 or 24px. */
size?: Size;
/** Renders a static display with an aria-label like "Rated 4 of 5". */
readOnly?: boolean;
/** Accessible name of the group. */
label?: string;
};
const star = { sm: "size-3.5", default: "size-4", lg: "size-6" };
const hit = { sm: "size-5", default: "size-6", lg: "size-8" };
/**
* Five stars as a radio group. Arrow keys move the value, hover previews it.
* Monochrome: filled stars are foreground, empty ones are the muted outline.
*/
export function RatingInput({
value,
defaultValue = 0,
onValueChange,
max = 5,
size = "default",
readOnly = false,
label = "Rating",
className,
...props
}: RatingInputProps) {
const [inner, setInner] = React.useState(defaultValue);
const [hover, setHover] = React.useState<number | null>(null);
const controlled = value !== undefined;
const current = controlled ? value : inner;
const shown = hover ?? current;
const stars = Array.from({ length: max }, (_, i) => i + 1);
const update = (next: number) => {
if (!controlled) setInner(next);
onValueChange?.(next);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
let next: number | null = null;
if (e.key === "ArrowRight" || e.key === "ArrowUp") next = Math.min(max, current + 1);
if (e.key === "ArrowLeft" || e.key === "ArrowDown") next = Math.max(1, current - 1);
if (e.key === "Home") next = 1;
if (e.key === "End") next = max;
if (next === null) return;
e.preventDefault();
update(next);
const group = e.currentTarget.parentElement;
group?.querySelectorAll<HTMLButtonElement>('[role="radio"]')[next - 1]?.focus();
};
if (readOnly) {
return (
<div
role="img"
aria-label={`Rated ${current} of ${max}`}
data-slot="rating-display"
className={cn("inline-flex items-center gap-0.5", className)}
{...props}
>
{stars.map((n) => (
<StarIcon
key={n}
aria-hidden="true"
className={cn(star[size], n <= current ? "fill-current text-foreground" : "text-muted-foreground")}
/>
))}
</div>
);
}
return (
<div
role="radiogroup"
aria-label={label}
data-slot="rating-input"
className={cn("inline-flex items-center", className)}
onMouseLeave={() => setHover(null)}
{...props}
>
{stars.map((n) => {
const checked = n === current;
const tabbable = current === 0 ? n === 1 : checked;
return (
<button
key={n}
type="button"
role="radio"
aria-checked={checked}
aria-label={`${n} of ${max}`}
tabIndex={tabbable ? 0 : -1}
onClick={() => update(n)}
onKeyDown={onKeyDown}
onMouseEnter={() => setHover(n)}
onFocus={() => setHover(null)}
className={cn(
"inline-flex items-center justify-center rounded-sm transition-colors duration-100 ease-out outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
hit[size],
n <= shown ? "text-foreground" : "text-muted-foreground",
)}
>
<StarIcon aria-hidden="true" className={cn(star[size], n <= shown && "fill-current")} />
</button>
);
})}
</div>
);
}
For AI agents
Hand your agent this page, the markdown at /components/rating-input/prompt.md, or the Copy AI prompt button above.
When to use
- Collecting a one to five score in a review or feedback form
- Showing a score next to a product, a review or a listing with readOnly
- When the input should stay small enough to sit on one row with a label
When not to use
- Scales with more than seven steps or with text anchors: use a slider or a radio group
- A like or save mark, which is one toggle, not a scale
- Scores that need half stars; this one steps by whole numbers
Agent notes
- onValueChange is a function, so the component that renders a controlled RatingInput must be a client component
- Roving tabindex: one star is in the tab order, arrow keys move the value and the focus, Home and End jump
- A value of 0 means nothing chosen; the first star is then the tab stop
- readOnly swaps the buttons for a role img with the label Rated n of max, so it is one accessible node
- No colour: filled stars use fill-current on text-foreground, empty ones are the muted outline