Installation
Run this in a project set up with shadcn/ui:
$ pnpm dlx shadcn@latest add https://uxelith.com/r/rating.jsonIt adds these packages for you:
clsxtailwind-mergemotionThe source lands in your components folder as
rating.tsx. It's yours: change anything.
Usage
import { Rating } from "@/components/rating"
export function Review() {
return <Rating label="Rate this template" showLabel defaultValue={4} name="rating" />
}Source
One file, 187 lines, nothing hidden. Read it before you install it.
"use client";
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface RatingProps extends Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> {
/** Accessible name, e.g. "Rate this template". Shown as a visible legend when `showLabel` is set. */
label: string;
showLabel?: boolean;
/** Number of stars. */
max?: number;
/** Controlled value, 0 for none. With `readOnly` it may be fractional, e.g. 4.6. */
value?: number;
/** Initial value when uncontrolled. */
defaultValue?: number;
onValueChange?: (value: number) => void;
/** Words for each value, e.g. `["Poor", "Fair", "Good", "Great", "Loved it"]`, shown beside the stars. */
labels?: string[];
/** Show the value without letting anyone change it, e.g. a review's score. */
readOnly?: boolean;
disabled?: boolean;
size?: "sm" | "md" | "lg";
/** Submit with forms under this name. Defaults to a generated one. */
name?: string;
}
const sizes = { sm: "size-4", md: "size-6", lg: "size-8" } as const;
const ON = "text-[var(--rating-on,var(--chart-3))]";
const STAR = "M12 2.8l2.84 5.75 6.35.92-4.6 4.48 1.09 6.33L12 17.3l-5.68 2.98 1.09-6.33-4.6-4.48 6.35-.92z";
const SPARKS = 6;
/**
* A star in outline, filled up to `fill` (0–1) from the left. Unlit stars
* stay outlines, so the score never depends on colour alone.
*/
function Star({ fill, className }: { fill: number; className?: string }) {
return (
<span className={cn("relative block", className)}>
<svg viewBox="0 0 24 24" aria-hidden="true" className="absolute inset-0 size-full text-muted-foreground/60">
<path d={STAR} fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className={cn("absolute inset-0 size-full transition-[clip-path] duration-200 motion-reduce:transition-none", ON)}
style={{ clipPath: `inset(0 ${100 - fill * 100}% 0 0)` }}
>
<path d={STAR} fill="currentColor" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round" />
</svg>
</span>
);
}
/** A small ring of sparks thrown off a star as it is chosen. */
function Sparks() {
return (
<span aria-hidden="true" className="pointer-events-none absolute inset-0">
{Array.from({ length: SPARKS }, (_, i) => {
const angle = (i / SPARKS) * Math.PI * 2 - Math.PI / 2;
return (
<motion.span
key={i}
className={cn("absolute left-1/2 top-1/2 -ml-0.5 -mt-0.5 size-1 rounded-full bg-current", ON)}
initial={{ x: 0, y: 0, opacity: 1, scale: 1 }}
animate={{ x: Math.cos(angle) * 18, y: Math.sin(angle) * 18, opacity: 0, scale: 0.4 }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
/>
);
})}
</span>
);
}
/**
* A star rating: stars light up in a quick wave as you hover, and the one
* you choose pops and throws off sparks.
*
* - Built from native radio buttons in a fieldset, so arrow keys, forms and
* screen readers ("4 stars, Great") behave as they would for any radio group.
* - `readOnly` renders a single image labelled "4.6 out of 5 stars", with
* partly filled stars for fractions.
* - Colours: `--rating-on` (default `--chart-3`, amber). No movement under
* reduced motion.
*/
export function Rating({
label,
showLabel = false,
max = 5,
value: valueProp,
defaultValue = 0,
onValueChange,
labels,
readOnly = false,
disabled = false,
size = "md",
name: nameProp,
className,
...props
}: RatingProps) {
const generatedName = React.useId();
const name = nameProp ?? generatedName;
const reduceMotion = useReducedMotion();
const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
const value = valueProp ?? uncontrolled;
const [hovered, setHovered] = React.useState(0);
// Bumped on every choice, so the pop and sparks replay even for the same star.
const [chosen, setChosen] = React.useState<{ star: number; count: number } | null>(null);
const shown = hovered || value;
const stars = Array.from({ length: max }, (_, i) => i + 1);
if (readOnly) {
const score = Math.round(value * 10) / 10;
return (
<div
role="img"
aria-label={`${label}: ${score} out of ${max} stars`}
className={cn("inline-flex items-center gap-0.5", className)}
{...props}
>
{stars.map((n) => (
<Star key={n} fill={Math.min(1, Math.max(0, value - (n - 1)))} className={sizes[size]} />
))}
</div>
);
}
const choose = (next: number) => {
if (valueProp === undefined) setUncontrolled(next);
setChosen((c) => ({ star: next, count: (c?.count ?? 0) + 1 }));
onValueChange?.(next);
};
const word = labels?.[shown - 1];
return (
<div className={cn("inline-grid gap-2", className)} {...props}>
<fieldset disabled={disabled} className="m-0 min-w-0 border-0 p-0 disabled:opacity-50">
<legend className={showLabel ? "mb-2 text-sm font-medium" : "sr-only"}>{label}</legend>
<div className="flex items-center gap-3">
<div className="flex items-center gap-0.5" onPointerLeave={() => setHovered(0)}>
{stars.map((n) => (
<label
key={n}
onPointerEnter={() => !disabled && setHovered(n)}
className={cn(
"group/star relative grid cursor-pointer place-items-center rounded-md p-0.5",
"has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring",
disabled && "cursor-not-allowed",
)}
>
<input
type="radio"
name={name}
value={n}
checked={value === n}
onChange={() => choose(n)}
aria-label={`${n} ${n === 1 ? "star" : "stars"}${labels?.[n - 1] ? `, ${labels[n - 1]}` : ""}`}
className="peer sr-only"
/>
<motion.span
key={chosen?.star === n ? `pop-${chosen.count}` : "rest"}
initial={false}
animate={chosen?.star === n && !reduceMotion ? { scale: [1, 1.3, 1], rotate: [0, -12, 0] } : { scale: 1 }}
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
className="block transition-[scale] duration-150 group-hover/star:scale-110 group-active/star:scale-90 motion-reduce:transition-none"
>
<Star
fill={n <= shown ? 1 : 0}
className={sizes[size]}
/>
</motion.span>
{chosen?.star === n && !reduceMotion && <Sparks key={`sparks-${chosen.count}`} />}
</label>
))}
</div>
{labels && (
// The radios already carry these words; this is the visible copy.
<span aria-hidden="true" className="min-w-16 text-sm font-medium text-muted-foreground">
{word ?? ""}
</span>
)}
</div>
</fieldset>
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | — | Accessible name; a visible legend with showLabel. |
| showLabel | boolean | false | Show the label above the stars. |
| max | number | 5 | Number of stars. |
| value / defaultValue | number | — / 0 | Controlled or initial value; 0 is none. Read-only scores may be fractional. |
| onValueChange | (value: number) => void | — | Called with the chosen number of stars. |
| readOnly | boolean | false | Show a score that can't be changed. |
| size | "sm" | "md" | "lg" | "md" | Star size. |
| name | string | generated | Form field name. |
| labels | string[] | — | A word for each value, shown beside the stars and read with each one. |
| --rating-on | CSS variable | --chart-3 | Colour of lit stars. |
Accessibility
- Native radio buttons in a fieldset: arrow keys, forms and screen readers ("4 stars, Great") just work.
- Unlit stars are outlines, so the score never depends on colour alone.
- readOnly renders one image labelled "4.6 out of 5 stars".
- No pop, sparks or hover growth under prefers-reduced-motion.
Changelog
- v1.0.0Latest
First release.