Feedback & Data
RatingStars
Stars fill under the pointer as a preview, and a pick runs a small pop along them with a ring off the last one. Works by mouse, touch and arrow keys, with half stars and a read-only mode for averages.
Download
- React
- Tailwind CSS v4
- motion
components/ui/rating-stars.tsx
"use client";
import { useState, type CSSProperties, type KeyboardEvent, type MouseEvent } from "react";
import { motion, stagger, useAnimate, useReducedMotion } from "motion/react";
const STAR = "M12 2.6l2.9 5.9 6.5.9-4.7 4.6 1.1 6.5-5.8-3.1-5.8 3.1 1.1-6.5L2.6 9.4l6.5-.9z";
type RatingStarsProps = {
/** Current rating. Leave out to let the stars keep their own. */
value?: number;
/** Rating at the start when `value` is left out. */
defaultValue?: number;
/** Called with the new rating. 0 means cleared. */
onChange?: (value: number) => void;
/** Number of stars. */
max?: number;
/** 0.5 lets people pick half stars. */
step?: 0.5 | 1;
/** Only shows the rating. Takes any fraction, like 4.3. */
readOnly?: boolean;
/** One star in pixels. */
size?: number;
/** What the rating is for, read by screen readers. */
label?: string;
/** Filled star colour. */
accent?: string;
/** Empty star colour, as a text colour class. */
className?: string;
};
/* RatingStars: stars fill under the pointer as a preview, and a pick runs a small pop along them with a ring off the last one. */
export function RatingStars({
value,
defaultValue = 0,
onChange,
max = 5,
step = 1,
readOnly = false,
size = 36,
label = "Rating",
accent = "#fbbf24",
className = "text-white/20",
}: RatingStarsProps) {
const reduce = useReducedMotion();
const [scope, run] = useAnimate<HTMLDivElement>();
const [own, setOwn] = useState(defaultValue);
const [hover, setHover] = useState<number | null>(null);
const [ring, setRing] = useState({ star: 0, key: 0 });
const current = value ?? own;
const shown = hover ?? current;
const spoken = current ? `${current} out of ${max}` : "No rating";
const commit = (next: number) => {
setOwn(next);
onChange?.(next);
if (reduce || !next) return;
const star = Math.ceil(next);
setRing((now) => ({ star, key: now.key + 1 }));
run(`[data-star]:nth-child(-n+${star})`, { scale: [1, 1.28, 1] }, { duration: 0.32, delay: stagger(0.035) });
};
const under = (e: MouseEvent<HTMLDivElement>) => {
const box = e.currentTarget.getBoundingClientRect();
return Math.min(max, Math.max(step, Math.ceil(((e.clientX - box.left) / box.width) * (max / step)) * step));
};
const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
const moves: Record<string, number> = { ArrowRight: current + step, ArrowUp: current + step, ArrowLeft: current - step, ArrowDown: current - step, Home: 0, End: max };
if (!(e.key in moves)) return;
e.preventDefault();
const next = Math.min(max, Math.max(0, moves[e.key]));
if (next !== current) commit(next);
};
const pick = (e: MouseEvent<HTMLDivElement>) => {
const next = under(e);
commit(next === current ? 0 : next);
};
const input = readOnly
? { role: "img", "aria-label": `${label}: ${spoken}` }
: {
role: "slider",
tabIndex: 0,
"aria-label": label,
"aria-valuemin": 0,
"aria-valuemax": max,
"aria-valuenow": current,
"aria-valuetext": spoken,
onKeyDown: onKey,
onPointerMove: (e: MouseEvent<HTMLDivElement>) => setHover(under(e)),
onPointerLeave: () => setHover(null),
onPointerCancel: () => setHover(null),
onClick: pick,
};
return (
<div
ref={scope}
{...input}
style={{ "--rs": accent, width: size * max, height: size } as CSSProperties}
className={`relative flex max-w-full rounded-lg outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${readOnly ? "" : "cursor-pointer touch-pan-y"} ${className}`}
>
{Array.from({ length: max }, (_, i) => {
const fill = Math.min(1, Math.max(0, shown - i));
return (
<span key={i} data-star className="relative block shrink-0" style={{ width: size, height: size }}>
<svg aria-hidden viewBox="0 0 24 24" className="absolute inset-0 size-full fill-current">
<path d={STAR} />
</svg>
<motion.svg
aria-hidden
viewBox="0 0 24 24"
className="absolute inset-0 size-full fill-(--rs)"
initial={false}
animate={{ clipPath: `inset(0 ${100 - fill * 100}% 0 0)` }}
transition={{ duration: reduce ? 0 : 0.16, ease: "easeOut" }}
>
<path d={STAR} />
</motion.svg>
</span>
);
})}
{ring.key > 0 && !reduce && (
<motion.span
key={ring.key}
aria-hidden
className="pointer-events-none absolute top-0 rounded-full border-2 border-(--rs)"
style={{ left: (ring.star - 1) * size, width: size, height: size }}
initial={{ scale: 0.5, opacity: 0.7 }}
animate={{ scale: 1.5, opacity: 0 }}
transition={{ duration: 0.5, ease: "easeOut" }}
/>
)}
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/rating-stars.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/rating-stars-demo.tsx
"use client"; import { useState } from "react"; import { RatingStars } from "@/components/ui/rating-stars"; const WORDS = ["Tap a star", "Poor", "Okay", "Good", "Great", "Loved it"]; export default function RatingStarsExample() { const [rating, setRating] = useState(0); return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-6"> <div className="grid justify-items-center gap-5"> <div className="grid justify-items-center gap-2"> <RatingStars value={rating} onChange={setRating} label="Rate this template" /> <p className="text-sm text-white/70">{WORDS[rating]}</p> </div> <div className="flex items-center gap-2 text-sm text-white/60"> <RatingStars readOnly defaultValue={4.3} size={18} label="Average rating" /> 4.3 from 218 reviews </div> </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| value | number | โ | Current rating. Leave out to let the stars keep their own. |
| defaultValue | number | 0 | Rating at the start when value is left out. |
| onChange | (value) => void | โ | Called with the new rating. Picking the same rating again clears it to 0. |
| max | number | 5 | Number of stars. |
| step | 0.5 | 1 | 1 | 0.5 lets people pick half stars. |
| readOnly | boolean | false | Only shows the rating. Takes any fraction, like 4.3. |
| size | number | 36 | One star in pixels. |
| label | string | "Rating" | What the rating is for, read by screen readers. |
| accent | string | "#fbbf24" | Filled star colour. |
| className | string | "text-white/20" | Empty star colour, as a text colour class. |