Rating Stars Fill
Stars light left to right under the pointer, each settling a fraction after the one before it.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Rating Stars Fill
*
* Stars fill left to right as the pointer sweeps across them, each one
* settling a fraction after the one before it, and the whole row locks
* when a rating is chosen.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Works with zero props; tune via `variant`, `count`, `labels`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type RatingStarsFillProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** How many stars. */
count?: number;
/** Rating chosen up front, from 0 (none). */
defaultValue?: number;
/** One word per rating, lowest first. */
labels?: string[];
/** Line shown before anything is chosen. */
prompt?: string;
/** Star size in px. */
size?: number;
/** Fires with the locked rating. */
onChange?: (value: number) => void;
};
type VariantConfig = {
/** Gap between one star lighting and the next, in seconds. */
stagger: number;
/** How small an unlit star sits. */
restScale: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// The sweep is the point, so the stagger carries the energy rather than
// the springs — every ratio here is at or above 0.8, and no star
// overshoots twice. Variants change tempo and travel only.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost simultaneous, barely any size change.
subtle: {
stagger: 0.018,
restScale: 0.94,
spring: { type: "spring", stiffness: 560, damping: 44 },
},
default: {
stagger: 0.035,
restScale: 0.86,
spring: { type: "spring", stiffness: 440, damping: 34 },
},
// A visible run across the row, from further down.
playful: {
stagger: 0.055,
restScale: 0.76,
spring: { type: "spring", stiffness: 340, damping: 30 },
},
};
const STAR = "#F5A524";
const STAR_PATH =
"M12 2.6l2.9 5.88 6.49.94-4.7 4.58 1.11 6.46L12 17.4l-5.8 3.06 1.1-6.46-4.69-4.58 6.49-.94z";
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` lands correctly on a light surface and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const DEFAULT_LABELS = ["Poor", "Fair", "Good", "Great", "Excellent"];
export default function RatingStarsFill({
variant = "default",
count = 5,
defaultValue = 0,
labels = DEFAULT_LABELS,
prompt = "How did we do?",
size = 30,
onChange,
}: RatingStarsFillProps) {
const [value, setValue] = useState(defaultValue);
const [hover, setHover] = useState(0);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// The pointer wins while it is over the row; the locked rating is what
// the row falls back to the moment it leaves.
const shown = hover || value;
const word = shown > 0 ? (labels[shown - 1] ?? "") : prompt;
const commit = (next: number) => {
setValue(next);
onChange?.(next);
};
return (
<div style={{ display: "inline-flex", flexDirection: "column", alignItems: "center", gap: 10 }}>
<div
role="radiogroup"
aria-label="Rating"
onPointerLeave={() => setHover(0)}
style={{ display: "flex", alignItems: "center", gap: 6 }}
>
{Array.from({ length: count }, (_, index) => {
const position = index + 1;
const lit = position <= shown;
return (
<button
key={position}
type="button"
role="radio"
aria-checked={value === position}
aria-label={`${position} of ${count}`}
onPointerEnter={() => setHover(position)}
onFocus={() => setHover(position)}
onBlur={() => setHover(0)}
onClick={() => commit(position)}
style={{
padding: 0,
border: 0,
background: "none",
lineHeight: 0,
cursor: "pointer",
color: "inherit",
}}
>
<motion.span
// Icons may scale; the word underneath may not. The
// settle lives entirely in this 30px mark.
initial={false}
animate={{ scale: reduceMotion ? 1 : lit ? 1 : cfg.restScale }}
transition={{
...cfg.spring,
// The run reads left to right because each star waits
// its turn — the springs themselves are identical.
delay: lit && !reduceMotion ? index * cfg.stagger : 0,
}}
style={{
position: "relative",
display: "block",
width: size,
height: size,
}}
>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
aria-hidden
style={{ display: "block" }}
>
<path d={STAR_PATH} fill={tone(13)} />
</svg>
{/* The lit star is a second copy crossfaded over the
resting one: a fill cannot be tweened from a mixed
color, but an opacity can, and it stays composited. */}
<motion.svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
aria-hidden
initial={false}
animate={{ opacity: lit ? 1 : 0 }}
transition={{
duration: reduceMotion ? 0.14 : 0.18,
ease: "easeOut",
delay: lit && !reduceMotion ? index * cfg.stagger : 0,
}}
style={{ position: "absolute", inset: 0, display: "block" }}
>
<path d={STAR_PATH} fill={STAR} />
</motion.svg>
</motion.span>
</button>
);
})}
</div>
{/* The word is text, so it crossfades in place at a constant size:
no scaling, no travel, no bounce. */}
<div
style={{
display: "grid",
minHeight: 17,
fontSize: 12.5,
textAlign: "center",
}}
>
<motion.span
key={word}
initial={{ opacity: 0 }}
animate={{ opacity: shown > 0 ? 1 : 0.55 }}
transition={{ duration: 0.16, ease: "easeOut" }}
// One constant weight and one constant size: a word that
// thickens as it changes is a word that jumps.
style={{ gridArea: "1 / 1", fontWeight: 550, whiteSpace: "nowrap" }}
>
{word}
</motion.span>
</div>
</div>
);
}About this pattern
A rating control is a preview before it is a commitment, so the row has to answer the pointer as it travels. Each star waits its turn by a few hundredths of a second, which is what makes the fill read as a run across the row rather than five things changing at once — the springs themselves are identical. The lit star is a second copy crossfaded over the resting one, because a fill cannot be tweened out of a mixed color while an opacity can and stays composited. The pointer wins while it is over the row and the locked rating is what the row falls back to when it leaves. The word underneath crossfades at one constant size and weight.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Modal sheet
Stars settle individually as the choice is made.
Related patterns
- Field Valid CheckA small mark strokes itself in at the right edge of a field the moment its value becomes acceptable.
- Review SubmitStar fills bloom from the middle, then the form folds away and the posted review rises into its place.
- Batch Action CountTicking rows rolls a count in a floating bar that rises on the first selection.