Star Rating
Stars fill up to the one you hover, and the one you pick pops with a small bounce, all on native radio buttons.
Create a five-star rating in pure CSS on native radio inputs. Wrap each input and an SVG star in a label, and lay the labels out with display: flex and flex-direction: row-reverse, ordered 5 to 1 in the DOM, so the sibling selector ~ reaches the stars visually to the left. Stars start with a muted fill. Fill the checked star and every star after it in the DOM with the accent colour using label:has(input:checked) and label:has(input:checked) ~ label. While the row is hovered, reset them and fill label:hover and label:hover ~ label instead. When a star is checked, play a 360ms keyframe on it: scale 1 to 1.35 to 1 with cubic-bezier(0.34, 1.56, 0.64, 1). Hide the inputs visually but keep them focusable, and show a focus ring on the label of the focused input. Give each input an aria-label like "4 stars". Under prefers-reduced-motion, skip the pop. The legend reads "Rate this motion".
Source
'use client'
import { useId } from 'react'
import { fmt } from '@/lib/i18n'
import s from './demo.module.css'
export default function StarRating({ labels }: { labels: Record<string, string> }) {
const name = useId()
return (
<fieldset className={s.field}>
<legend className={s.legend}>{labels.legend}</legend>
<div className={s.stars}>
{[5, 4, 3, 2, 1].map((n) => (
<label key={n} className={s.star}>
<input type="radio" name={name} value={n} className={s.input} aria-label={fmt(labels.star, n)} />
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2.8l2.8 5.9 6.4.8-4.7 4.4 1.2 6.4L12 17.2l-5.7 3.1 1.2-6.4-4.7-4.4 6.4-.8z" />
</svg>
</label>
))}
</div>
</fieldset>
)
}
.field {
display: grid;
justify-items: center;
gap: 12px;
margin: 0;
padding: 0;
border: 0;
}
.legend {
margin-bottom: 12px;
padding: 0;
font: 500 var(--text-sm) / 1 var(--font-body);
color: var(--stage-muted);
text-align: center;
}
.stars {
display: flex;
flex-direction: row-reverse;
gap: 6px;
}
.star {
position: relative;
display: grid;
width: 36px;
height: 36px;
border-radius: 8px;
cursor: pointer;
}
.star svg {
width: 100%;
height: 100%;
fill: var(--stage-rule);
transition: fill var(--dur-micro) var(--ease-out);
}
.input {
position: absolute;
inset: 0;
opacity: 0;
margin: 0;
cursor: pointer;
}
.star:has(.input:checked) svg,
.star:has(.input:checked) ~ .star svg { fill: var(--stage-accent); }
.stars:hover .star svg { fill: var(--stage-rule); }
.stars:hover .star:hover svg,
.stars:hover .star:hover ~ .star svg { fill: var(--stage-accent); }
.star:has(.input:checked) svg { animation: pop 360ms cubic-bezier(0.34, 1.56, 0.64, 1); }
.star:has(.input:focus-visible) { outline: 2px solid var(--color-focus); outline-offset: 2px; }
@keyframes pop {
50% { transform: scale(1.35); }
}
@media (prefers-reduced-motion: reduce) {
.star:has(.input:checked) svg { animation: none; }
}