Rating
A row of stars that previews the score on hover, fills up to the pick in a quick wave, and moves one star at a time with the arrow keys.
Tap a star
"use client";
import { useState } from "react";
import { Rating } from "@/components/motion/rating";
const WORDS = ["Tap a star", "Not for me", "Could be better", "Pretty good", "Really good", "Love it"];
export function RatingPreview() {
const [value, setValue] = useState(0);
return (
<div className="flex flex-col items-center gap-3">
<Rating value={value} onValueChange={setValue} size="lg" label="Rate this component" />
<p className="h-5 text-sm text-muted-foreground" aria-live="polite">
{WORDS[value]}
</p>
</div>
);
}
"use client";
// easeui.dev/components/motion/rating
import { Star } from "lucide-react";
import { useId, useState } from "react";
import { cn } from "@/lib/utils";
export type RatingSize = "sm" | "md" | "lg";
export interface RatingProps {
/** Controlled value, from 0 to `max`. */
value?: number;
/** Starting value when uncontrolled. Default 0. */
defaultValue?: number;
/** Called with the new value each time a star is picked. */
onValueChange?: (value: number) => void;
/** How many stars. Default 5. */
max?: number;
/** Shows the value without letting anyone change it. Default false. */
readOnly?: boolean;
/** Default "md". */
size?: RatingSize;
/** Names the group for screen readers. Default "Rating". */
label?: string;
className?: string;
}
const SIZES: Record<RatingSize, string> = { sm: "h-4 w-4", md: "h-6 w-6", lg: "h-8 w-8" };
/**
* A row of stars. Hovering previews the score, picking one fills the row up to it in a quick
* wave, and the arrow keys move it one star at a time. Built on native radio inputs.
*/
export function Rating({
value,
defaultValue = 0,
onValueChange,
max = 5,
readOnly = false,
size = "md",
label = "Rating",
className,
}: RatingProps) {
const name = useId();
const [inner, setInner] = useState(defaultValue);
const current = value ?? inner;
const [hover, setHover] = useState<number | null>(null);
// Bumped on every pick, so the pop replays even when the same star is picked twice.
const [wave, setWave] = useState(0);
const shown = hover ?? current;
const pick = (next: number) => {
if (readOnly) return;
setInner(next);
setWave((n) => n + 1);
onValueChange?.(next);
};
return (
<fieldset
onPointerLeave={() => setHover(null)}
disabled={readOnly}
className={cn("m-0 inline-flex min-w-0 items-center gap-0.5 border-0 p-0", className)}
>
<legend className="sr-only">{label}</legend>
{Array.from({ length: max }, (_, index) => index + 1).map((score) => {
const on = score <= shown;
return (
<label
key={score}
onPointerEnter={() => !readOnly && setHover(score)}
className={cn(
"relative grid touch-manipulation place-items-center rounded-md",
readOnly ? "cursor-default" : "cursor-pointer",
// Grows the hit area without spacing the stars apart.
"after:absolute after:-inset-1",
)}
>
<input
type="radio"
name={name}
value={score}
checked={score === current}
onChange={() => pick(score)}
className="peer sr-only"
/>
<span className="sr-only">
{score} star{score === 1 ? "" : "s"}
</span>
<span aria-hidden="true" className="grid rounded-md peer-focus-visible:ring-2 peer-focus-visible:ring-foreground/40">
<Star
key={score <= current ? wave : 0}
style={{ animationDelay: `${(score - 1) * 35}ms` }}
className={cn(
SIZES[size],
// Hover is instant, since it fires as the pointer sweeps along the row.
on ? "fill-warning text-warning" : "fill-transparent text-foreground/25",
wave > 0 && score <= current && "animate-[rating-pop_320ms_cubic-bezier(0.23,1,0.32,1)_both] motion-reduce:animate-none",
)}
/>
</span>
</label>
);
})}
<style>{"@keyframes rating-pop { 40% { transform: scale(1.22) } }"}</style>
</fieldset>
);
}
Installation
$ bunx --bun shadcn add @easeui/rating
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx lucide-react tailwind-merge - 3
Add the source files
components/motion/rating.tsx "use client"; // easeui.dev/components/motion/rating import { Star } from "lucide-react"; import { useId, useState } from "react"; import { cn } from "@/lib/utils"; export type RatingSize = "sm" | "md" | "lg"; export interface RatingProps { /** Controlled value, from 0 to `max`. */ value?: number; /** Starting value when uncontrolled. Default 0. */ defaultValue?: number; /** Called with the new value each time a star is picked. */ onValueChange?: (value: number) => void; /** How many stars. Default 5. */ max?: number; /** Shows the value without letting anyone change it. Default false. */ readOnly?: boolean; /** Default "md". */ size?: RatingSize; /** Names the group for screen readers. Default "Rating". */ label?: string; className?: string; } const SIZES: Record<RatingSize, string> = { sm: "h-4 w-4", md: "h-6 w-6", lg: "h-8 w-8" }; /** * A row of stars. Hovering previews the score, picking one fills the row up to it in a quick * wave, and the arrow keys move it one star at a time. Built on native radio inputs. */ export function Rating({ value, defaultValue = 0, onValueChange, max = 5, readOnly = false, size = "md", label = "Rating", className, }: RatingProps) { const name = useId(); const [inner, setInner] = useState(defaultValue); const current = value ?? inner; const [hover, setHover] = useState<number | null>(null); // Bumped on every pick, so the pop replays even when the same star is picked twice. const [wave, setWave] = useState(0); const shown = hover ?? current; const pick = (next: number) => { if (readOnly) return; setInner(next); setWave((n) => n + 1); onValueChange?.(next); }; return ( <fieldset onPointerLeave={() => setHover(null)} disabled={readOnly} className={cn("m-0 inline-flex min-w-0 items-center gap-0.5 border-0 p-0", className)} > <legend className="sr-only">{label}</legend> {Array.from({ length: max }, (_, index) => index + 1).map((score) => { const on = score <= shown; return ( <label key={score} onPointerEnter={() => !readOnly && setHover(score)} className={cn( "relative grid touch-manipulation place-items-center rounded-md", readOnly ? "cursor-default" : "cursor-pointer", // Grows the hit area without spacing the stars apart. "after:absolute after:-inset-1", )} > <input type="radio" name={name} value={score} checked={score === current} onChange={() => pick(score)} className="peer sr-only" /> <span className="sr-only"> {score} star{score === 1 ? "" : "s"} </span> <span aria-hidden="true" className="grid rounded-md peer-focus-visible:ring-2 peer-focus-visible:ring-foreground/40"> <Star key={score <= current ? wave : 0} style={{ animationDelay: `${(score - 1) * 35}ms` }} className={cn( SIZES[size], // Hover is instant, since it fires as the pointer sweeps along the row. on ? "fill-warning text-warning" : "fill-transparent text-foreground/25", wave > 0 && score <= current && "animate-[rating-pop_320ms_cubic-bezier(0.23,1,0.32,1)_both] motion-reduce:animate-none", )} /> </span> </label> ); })} <style>{"@keyframes rating-pop { 40% { transform: scale(1.22) } }"}</style> </fieldset> ); }lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| value? | number | - | Controlled value, from 0 to `max`. |
| defaultValue? | number | 0 | Starting value when uncontrolled. Default 0. |
| onValueChange? | ((value: number) => void) | - | Called with the new value each time a star is picked. |
| max? | number | 5 | How many stars. Default 5. |
| readOnly? | boolean | false | Shows the value without letting anyone change it. Default false. |
| size? | "sm" | "md" | "lg" | md | Default "md". |
| label? | string | Rating | Names the group for screen readers. Default "Rating". |
| className? | string | - | - |