Rating Stars
Accessible star rating with hover preview and a playful wiggle.
How was your experience?
components/ui/rating-stars.tsx
"use client";
import { motion } from "motion/react";
import { Star } from "lucide-react";
import { useState } from "react";
const labels = ["Terrible", "Bad", "Okay", "Good", "Amazing"];
export function RatingStars({ value, onChange, max = 5 }: { value: number; onChange: (v: number) => void; max?: number }) {
const [hover, setHover] = useState(0);
const shown = hover || value;
return (
<div role="radiogroup" aria-label="Rating" className="flex gap-1" onMouseLeave={() => setHover(0)}>
{Array.from({ length: max }, (_, i) => i + 1).map((n) => (
<motion.button
key={n}
role="radio"
aria-checked={value === n}
aria-label={`${n} star${n > 1 ? "s" : ""}`}
onMouseEnter={() => setHover(n)}
onClick={() => onChange(n)}
whileHover={{ scale: 1.2 }}
whileTap={{ scale: 0.9 }}
className="p-0.5"
>
<motion.span animate={{ rotate: value === n ? [0, -15, 15, 0] : 0 }} transition={{ duration: 0.4 }} className="block">
<Star className={`size-8 transition-colors ${n <= shown ? "fill-amber-400 text-amber-400" : "text-zinc-300"}`} />
</motion.span>
</motion.button>
))}
</div>
);
}
export default function Demo() {
const [v, setV] = useState(0);
return (
<div className="text-center">
<p className="mb-3 text-sm font-medium text-zinc-900">How was your experience?</p>
<RatingStars value={v} onChange={setV} />
<p className="mt-3 h-5 text-sm text-zinc-500">{v ? labels[v - 1] : ""}</p>
</div>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/rating-stars.tsxcomponents/ui/rating-stars.tsx"use client"; import { motion } from "motion/react"; import { Star } from "lucide-react"; import { useState } from "react"; const labels = ["Terrible", "Bad", "Okay", "Good", "Amazing"]; export function RatingStars({ value, onChange, max = 5 }: { value: number; onChange: (v: number) => void; max?: number }) { const [hover, setHover] = useState(0); const shown = hover || value; return ( <div role="radiogroup" aria-label="Rating" className="flex gap-1" onMouseLeave={() => setHover(0)}> {Array.from({ length: max }, (_, i) => i + 1).map((n) => ( <motion.button key={n} role="radio" aria-checked={value === n} aria-label={`${n} star${n > 1 ? "s" : ""}`} onMouseEnter={() => setHover(n)} onClick={() => onChange(n)} whileHover={{ scale: 1.2 }} whileTap={{ scale: 0.9 }} className="p-0.5" > <motion.span animate={{ rotate: value === n ? [0, -15, 15, 0] : 0 }} transition={{ duration: 0.4 }} className="block"> <Star className={`size-8 transition-colors ${n <= shown ? "fill-amber-400 text-amber-400" : "text-zinc-300"}`} /> </motion.span> </motion.button> ))} </div> ); } export default function Demo() { const [v, setV] = useState(0); return ( <div className="text-center"> <p className="mb-3 text-sm font-medium text-zinc-900">How was your experience?</p> <RatingStars value={v} onChange={setV} /> <p className="mt-3 h-5 text-sm text-zinc-500">{v ? labels[v - 1] : ""}</p> </div> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.