Interactive Star Rating
A star rating input with half-step precision: drag or tap across the stars, hover to preview, and a chosen rating pops in a wave with a burst of sparks and a changing word label. Radio-group keyboard support, a fractional read-only mode, controlled or not. No dependencies.
DOM + CSS0 deps1 file · 12 KB TSXreduced-motion aware
Props
"use client";
import { useId, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
const SAFE_COLOR = /^(#[0-9a-f]{3,8}|(rgb|hsl)a?\([\d\s.,%/-]+\))$/i;
const SAFE_SIZE = /^(\d+(\.\d+)?(px|rem|em|cqw|vw)|(min|max|clamp|calc)\([\d\s.,%+*/()a-z-]+\))$/i;
const STAR = "M12 2.6l2.9 5.9 6.5.95-4.7 4.58 1.1 6.47L12 17.45l-5.8 3.05 1.1-6.47L2.6 9.45l6.5-.95z";
const DEFAULT_LABELS = ["Poor", "Fair", "Good", "Great", "Excellent"];
const SPARKS = [0, 1, 2, 3, 4, 5];
const CSS = [
":where(.sr){display:flex;align-items:center;gap:14px;-webkit-tap-highlight-color:transparent}",
".sr[data-disabled]{opacity:.45}",
".sr-row{position:relative;display:flex;gap:var(--sr-gap,4px);padding:6px 0;margin:-6px 0;touch-action:pan-y;user-select:none;-webkit-user-select:none}",
".sr[data-live] .sr-row{cursor:pointer}",
".sr-star{position:relative;flex:none;width:var(--sr-size,28px);height:var(--sr-size,28px);transition:transform .38s cubic-bezier(.34,1.56,.64,1)}",
".sr-star[data-hot]{transform:scale(1.13)}",
".sr-star[data-hot][data-down]{transform:scale(.94);transition-duration:.12s}",
".sr-in{position:absolute;inset:0}",
".sr-in[data-pop]{animation:sr-pop .6s cubic-bezier(.34,1.56,.64,1) both}",
".sr-svg{display:block;width:var(--sr-size,28px);height:var(--sr-size,28px);overflow:visible}",
".sr-back{position:absolute;left:0;top:0;opacity:.5}",
".sr-clip{position:absolute;left:0;top:0;bottom:0;overflow:hidden;transition:width .22s cubic-bezier(.2,.8,.2,1),opacity .2s}",
".sr[data-preview] .sr-clip{opacity:.78}",
".sr-r{position:absolute;inset:-3px;margin:0;padding:0;border:0;border-radius:30%;background:none;appearance:none;-webkit-appearance:none;pointer-events:none;color:inherit}",
".sr-r:focus{outline:none}",
".sr-r:focus-visible{outline:2px solid currentColor;outline-offset:1px;border-radius:30%}",
".sr-sp{position:absolute;left:50%;top:50%;width:calc(var(--sr-size,28px)*.26);height:calc(var(--sr-size,28px)*.26);min-width:6px;min-height:6px;background:currentColor;clip-path:polygon(50% 0,63% 37%,100% 50%,63% 63%,50% 100%,37% 63%,0 50%,37% 37%);pointer-events:none;animation:sr-spark .62s cubic-bezier(.2,.8,.2,1) both}",
".sr-label{display:flex;align-items:baseline;gap:.5em;min-width:7.4em;font-size:max(13px,calc(var(--sr-size,28px)*.36));line-height:1.2;font-variant-numeric:tabular-nums}",
".sr-word{display:inline-block;font-weight:600;animation:sr-word .3s cubic-bezier(.2,.8,.2,1) both}",
".sr-word[data-empty]{font-weight:500;opacity:.55}",
".sr-num{opacity:.5;font-size:.85em}",
".sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}",
"@keyframes sr-pop{0%{transform:scale(.7) rotate(-14deg)}45%{transform:scale(1.34) rotate(6deg)}100%{transform:scale(1) rotate(0)}}",
"@keyframes sr-spark{0%{opacity:1;transform:translate(-50%,-50%) rotate(var(--a)) translateX(calc(var(--sr-size,28px)*.28)) scale(.35)}70%{opacity:1}100%{opacity:0;transform:translate(-50%,-50%) rotate(var(--a)) translateX(calc(var(--sr-size,28px)*var(--d,.95))) scale(1) rotate(80deg)}}",
"@keyframes sr-word{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}",
"@media (prefers-reduced-motion:reduce){.sr-star,.sr-clip{transition-duration:.01ms!important}.sr-star[data-hot]{transform:none}.sr-in[data-pop],.sr-sp,.sr-word{animation:none!important}.sr-sp{display:none}}",
].join("\n");
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const fmt = (v: number) => (Number.isInteger(v) ? String(v) : v.toFixed(1));
export interface StarRatingProps {
/** Number of stars. Default 5. */
max?: number;
/** Starting rating when uncontrolled. Default 0. */
defaultRating?: number;
/** Controlled rating. */
value?: number;
/** Called with the new rating on every commit. */
onChange?: (rating: number) => void;
/** 1 = whole stars (default), 0.5 = half stars. */
precision?: 1 | 0.5;
/** Display only. Fractional ratings (4.3) draw a partly filled star. */
readOnly?: boolean;
disabled?: boolean;
/** Star size: a number of px, or a CSS length like "min(10cqw, 56px)". Default 28. */
size?: number | string;
/** Hex / rgb / hsl. Default #ef4444. */
color?: string;
/** Show the word label ("Great") beside the stars. Default false. */
showLabel?: boolean;
/** Words for the label, lowest to highest. */
labels?: string[];
/** Label while nothing is rated. */
emptyLabel?: string;
/** Accessible name of the group. */
label?: string;
className?: string;
}
type Burst = { id: number; from: number; to: number };
export function StarRating({
max = 5,
defaultRating = 0,
value,
onChange,
precision = 1,
readOnly = false,
disabled = false,
size,
color = "#ef4444",
showLabel = false,
labels = DEFAULT_LABELS,
emptyLabel = "Tap to rate",
label = "Rating",
className = "",
}: StarRatingProps) {
const n = clamp(Math.round(Number.isFinite(max) ? max : 5), 1, 12);
const step = precision === 0.5 ? 0.5 : 1;
const isControlled = value !== undefined;
const [inner, setInner] = useState(defaultRating);
const rating = clamp((isControlled ? value : inner) || 0, 0, n);
const [hover, setHover] = useState<number | null>(null);
const [drag, setDrag] = useState<number | null>(null);
const [burst, setBurst] = useState<Burst | null>(null);
const rowRef = useRef<HTMLDivElement>(null);
const gid = "sr" + useId().replace(/[^a-zA-Z0-9]/g, "");
const live = !readOnly && !disabled;
const pointed = live ? (drag ?? hover) : null;
const shown = pointed ?? rating;
const accent = SAFE_COLOR.test(color) ? color : "#ef4444";
const vars: CSSProperties = {};
if (size !== undefined) {
const s = typeof size === "number" ? (size > 0 ? size + "px" : "") : SAFE_SIZE.test(size) ? size : "";
if (s) (vars as Record<string, string>)["--sr-size"] = s;
}
const wordFor = (v: number) =>
v <= 0 || labels.length === 0
? emptyLabel
: (labels[Math.min(labels.length - 1, Math.max(0, Math.ceil((v / n) * labels.length) - 1))] ?? "");
const commit = (v: number) => {
const to = Math.ceil(v) - 1;
const from = v > rating ? Math.max(0, Math.floor(rating)) : to;
setBurst((b) => ({ id: (b?.id ?? 0) + 1, from: Math.min(from, to), to }));
if (!isControlled) setInner(v);
onChange?.(v);
};
const valueAt = (clientX: number): number => {
const stars = rowRef.current?.children;
if (!stars || stars.length === 0) return step;
const first = stars[0]!.getBoundingClientRect();
const second = stars[1]?.getBoundingClientRect();
const gap = second ? Math.max(0, second.left - first.right) : 0;
for (let i = 0; i < stars.length; i++) {
const r = stars[i]!.getBoundingClientRect();
if (clientX <= r.right + gap / 2) {
const f = clamp((clientX - r.left) / r.width, 0, 1);
return i + (step === 0.5 ? (f <= 0.5 ? 0.5 : 1) : 1);
}
}
return n;
};
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!live || (e.pointerType === "mouse" && e.button !== 0)) return;
e.currentTarget.setPointerCapture(e.pointerId);
setDrag(valueAt(e.clientX));
};
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!live) return;
if (drag !== null) setDrag(valueAt(e.clientX));
else if (e.pointerType === "mouse") setHover(valueAt(e.clientX));
};
const onPointerUp = (e: ReactPointerEvent<HTMLDivElement>) => {
if (drag === null) return;
const v = valueAt(e.clientX);
setDrag(null);
setHover(e.pointerType === "mouse" ? v : null);
commit(v);
};
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
if (!live) return;
const cur = rating > 0 ? Math.ceil(rating / step) * step : 0;
let next: number | null = null;
if (e.key === "ArrowRight" || e.key === "ArrowUp") next = Math.min(n, cur + step);
else if (e.key === "ArrowLeft" || e.key === "ArrowDown") next = Math.max(step, cur - step);
else if (e.key === "Home") next = step;
else if (e.key === "End") next = n;
if (next === null) return;
e.preventDefault();
commit(next);
rowRef.current?.querySelector<HTMLElement>('[data-v="' + next + '"]')?.focus();
};
const tabTarget = rating > 0 ? Math.ceil(rating / step) * step : step;
const hotIndex = pointed !== null && pointed > 0 ? Math.ceil(pointed) - 1 : -1;
const radioValues = (i: number) => (step === 0.5 ? [i + 0.5, i + 1] : [i + 1]);
const stars = Array.from({ length: n }, (_, i) => {
const fill = clamp(shown - i, 0, 1);
const inWave = burst !== null && i >= burst.from && i <= burst.to;
return (
<span key={i} className="sr-star" data-hot={i === hotIndex ? "" : undefined} data-down={i === hotIndex && drag !== null ? "" : undefined}>
<span
key={inWave ? burst.id : 0}
className="sr-in"
data-pop={inWave ? "" : undefined}
style={inWave ? { animationDelay: (i - burst.from) * 55 + "ms" } : undefined}
>
<svg className="sr-svg sr-back" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d={STAR} fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
</svg>
<span className="sr-clip" style={{ width: fill * 100 + "%" }}>
<svg className="sr-svg" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<defs>
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#fff" stopOpacity="0.5" />
<stop offset="0.55" stopColor="#fff" stopOpacity="0" />
</linearGradient>
</defs>
<path d={STAR} fill="currentColor" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
<path d={STAR} fill={"url(#" + gid + ")"} />
</svg>
</span>
</span>
{live &&
radioValues(i).map((v) => (
<button
key={v}
type="button"
role="radio"
className="sr-r"
data-v={v}
aria-checked={Math.abs(v - rating) < 1e-6}
aria-label={"Rate " + fmt(v) + " out of " + n}
tabIndex={v === tabTarget ? 0 : -1}
onClick={() => commit(v)}
/>
))}
{burst !== null && burst.to === i && rating > 0
? SPARKS.map((k) => (
<span
key={burst.id + "-" + k}
className="sr-sp"
style={{ "--a": k * 60 + 18 + "deg", "--d": k % 2 ? 0.82 : 1.1, animationDelay: "40ms" } as CSSProperties}
/>
))
: null}
</span>
);
});
return (
<>
<style>{CSS}</style>
<div
className={"sr " + className}
data-live={live ? "" : undefined}
data-disabled={disabled ? "" : undefined}
data-preview={pointed !== null && pointed !== rating ? "" : undefined}
style={vars}
>
{live ? (
<div
ref={rowRef}
role="radiogroup"
aria-label={label}
className="sr-row"
style={{ color: accent }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={() => setDrag(null)}
onPointerLeave={() => setHover(null)}
onKeyDown={onKeyDown}
>
{stars}
</div>
) : (
<div
ref={rowRef}
role="img"
aria-label={label + ": " + fmt(Math.round(rating * 10) / 10) + " out of " + n}
className="sr-row"
style={{ color: accent }}
>
{stars}
</div>
)}
{showLabel ? (
<span className="sr-label" aria-hidden="true">
<span key={wordFor(shown)} className="sr-word" data-empty={shown <= 0 ? "" : undefined}>
{wordFor(shown)}
</span>
{shown > 0 ? <span className="sr-num">{fmt(Math.round(shown * 10) / 10)}</span> : null}
</span>
) : null}
<span className="sr-only" aria-live="polite" role="status">
{live && rating > 0 ? fmt(rating) + " out of " + n + ", " + wordFor(rating) : ""}
</span>
</div>
</>
);
}
Free. Pro unlocks everything, from $27/month.
Details
About this component
The committed rating, the hovered value and a drag in progress are three separate pieces of state, and the stars show the first one that exists: dragging, then hovering, then the rating. That is what lets the pointer preview a value without changing it, and while previewing the fill dims slightly so you can tell a preview from a choice. The whole row is one pointer surface. Hover maps the pointer to a value, press and drag scrubs, release commits, so a finger can slide across the stars on a phone instead of aiming at a small target. With precision set to 0.5 the left half of a star is a half step; the default of 1 keeps whole stars, as before. Committing runs one sequence: stars pop in a staggered wave from the previous rating to the new one (0.6s, an overshooting spring curve), six small sparks leave the chosen star, and an optional word label (Poor to Excellent, or your own list) swaps with a short slide. Fill is drawn by clipping a solid star to a width, so a read-only rating of 4.3 shows a star filled 30 percent. The row is uncontrolled from defaultRating, or controlled with value and onChange. Star size is a number of pixels or any CSS length, so it can follow a container with a clamp or cqw value, and the colour is a prop. Stars and the label follow the page, so it sits on light and dark backgrounds alike.
Curator’s note
Interactive Star Rating, rebuilt in October. The first version was five spring-scaled lucide icons; this one scrubs, takes half steps, and needs nothing but React.
