Stars Rating
Original · freeinteractive star rating
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/rate-stars.json"use client";
import { useState, useRef, useId, type KeyboardEvent } from "react";
import { motion, useReducedMotion } from "motion/react";
type RatingMeta = {
label: string;
blurb: string;
};
const RATINGS: readonly RatingMeta[] = [
{ label: "Terrible", blurb: "Missed the mark completely." },
{ label: "Poor", blurb: "A few things worked, most didn't." },
{ label: "Average", blurb: "Did the job, nothing memorable." },
{ label: "Great", blurb: "Solid work, I'd come back." },
{ label: "Exceptional", blurb: "Genuinely delighted — worth telling others." },
];
type Bucket = { stars: number; pct: number; count: number };
const DISTRIBUTION: readonly Bucket[] = [
{ stars: 5, pct: 61, count: 783 },
{ stars: 4, pct: 24, count: 308 },
{ stars: 3, pct: 9, count: 116 },
{ stars: 2, pct: 4, count: 51 },
{ stars: 1, pct: 2, count: 26 },
];
const TOTAL_REVIEWS = 1284;
const AVERAGE = 4.6;
function StarShape({ filled }: { filled: boolean }) {
return (
<svg
viewBox="0 0 24 24"
aria-hidden="true"
className="h-9 w-9 sm:h-11 sm:w-11"
>
<path
d="M12 2.4l2.9 5.88 6.49.95-4.7 4.58 1.11 6.46L12 17.22l-5.81 3.05 1.11-6.46-4.7-4.58 6.49-.95z"
className={
filled
? "fill-amber-400 stroke-amber-500 dark:fill-amber-400 dark:stroke-amber-300"
: "fill-transparent stroke-slate-300 dark:stroke-slate-600"
}
strokeWidth={1.5}
strokeLinejoin="round"
/>
</svg>
);
}
export default function RateStars() {
const reduced = useReducedMotion();
const groupId = useId();
const [value, setValue] = useState(0);
const [hover, setHover] = useState(0);
const [submitted, setSubmitted] = useState(false);
const starRefs = useRef<(HTMLButtonElement | null)[]>([]);
const display = hover || value;
const active = display > 0 ? RATINGS[display - 1] : null;
function focusStar(next: number) {
const clamped = Math.min(5, Math.max(1, next));
setValue(clamped);
setSubmitted(false);
const el = starRefs.current[clamped - 1];
if (el) el.focus();
}
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
const base = value || 0;
switch (event.key) {
case "ArrowRight":
case "ArrowUp":
event.preventDefault();
focusStar(base + 1);
break;
case "ArrowLeft":
case "ArrowDown":
event.preventDefault();
focusStar(base === 0 ? 1 : base - 1);
break;
case "Home":
event.preventDefault();
focusStar(1);
break;
case "End":
event.preventDefault();
focusStar(5);
break;
default:
if (/^[1-5]$/.test(event.key)) {
event.preventDefault();
focusStar(Number(event.key));
}
}
}
return (
<section className="relative w-full bg-slate-50 px-5 py-20 text-slate-900 sm:px-8 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes ratestars-pop {
0% { transform: scale(1); }
40% { transform: scale(1.28) rotate(-6deg); }
70% { transform: scale(0.94); }
100% { transform: scale(1); }
}
@keyframes ratestars-sheen {
0% { transform: translateX(-120%); opacity: 0; }
40% { opacity: 0.9; }
100% { transform: translateX(220%); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.ratestars-pop, .ratestars-sheen { animation: none !important; }
}
`}</style>
<div className="mx-auto grid w-full max-w-4xl gap-10 rounded-3xl border border-slate-200 bg-white p-7 shadow-xl shadow-slate-200/50 sm:p-10 md:grid-cols-[1.15fr_1fr] dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/40">
{/* Interactive rating */}
<div className="flex flex-col">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
Your review
</p>
<h2 className="mt-2 text-2xl font-bold tracking-tight sm:text-3xl">
How was the Northline dev tools onboarding?
</h2>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
Tap a star or use the arrow keys. Your feedback goes straight to the
product team.
</p>
<div
role="radiogroup"
aria-label="Rate your onboarding experience from 1 to 5 stars"
aria-describedby={`${groupId}-hint`}
onKeyDown={onKeyDown}
onMouseLeave={() => setHover(0)}
className="mt-6 flex items-center gap-1.5"
>
{RATINGS.map((meta, index) => {
const starNumber = index + 1;
const isFilled = display >= starNumber;
const isChecked = value === starNumber;
const justSet = !reduced && value === starNumber && hover === 0;
return (
<button
key={meta.label}
ref={(node) => {
starRefs.current[index] = node;
}}
type="button"
role="radio"
aria-checked={isChecked}
aria-label={`${starNumber} star${starNumber > 1 ? "s" : ""} — ${meta.label}`}
tabIndex={value === 0 ? (index === 0 ? 0 : -1) : isChecked ? 0 : -1}
onClick={() => {
setValue(starNumber);
setSubmitted(false);
}}
onMouseEnter={() => setHover(starNumber)}
onFocus={() => setHover(starNumber)}
onBlur={() => setHover(0)}
className="rounded-lg p-1 outline-none transition-transform focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900"
>
<span
className="ratestars-pop block"
style={
justSet
? { animation: "ratestars-pop 480ms ease-out" }
: undefined
}
>
<StarShape filled={isFilled} />
</span>
</button>
);
})}
</div>
<div
id={`${groupId}-hint`}
aria-live="polite"
className="mt-4 min-h-[2.75rem]"
>
{active ? (
<motion.div
key={active.label}
initial={reduced ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.25 }}
className="flex flex-wrap items-baseline gap-x-2"
>
<span className="text-lg font-semibold text-amber-600 dark:text-amber-400">
{display}.0 · {active.label}
</span>
<span className="text-sm text-slate-500 dark:text-slate-400">
{active.blurb}
</span>
</motion.div>
) : (
<span className="text-sm text-slate-400 dark:text-slate-500">
No rating yet — pick a star to begin.
</span>
)}
</div>
<div className="mt-6 flex flex-wrap items-center gap-3">
<button
type="button"
disabled={value === 0 || submitted}
onClick={() => setSubmitted(true)}
className="relative overflow-hidden rounded-full bg-indigo-600 px-6 py-2.5 text-sm font-semibold text-white transition-colors hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:bg-slate-300 disabled:text-slate-500 dark:focus-visible:ring-offset-slate-900 dark:disabled:bg-slate-700 dark:disabled:text-slate-400"
>
{submitted ? "Rating submitted" : "Submit rating"}
{!reduced && value > 0 && !submitted && (
<span
aria-hidden="true"
className="ratestars-sheen pointer-events-none absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-white/40 to-transparent"
style={{ animation: "ratestars-sheen 1.6s ease-in-out infinite" }}
/>
)}
</button>
{value > 0 && (
<button
type="button"
onClick={() => {
setValue(0);
setHover(0);
setSubmitted(false);
}}
className="rounded-full px-3 py-2 text-sm font-medium text-slate-500 underline-offset-4 hover:text-slate-800 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 dark:text-slate-400 dark:hover:text-slate-100"
>
Clear
</button>
)}
</div>
<div className="mt-3 min-h-[1.25rem]" aria-live="assertive">
{submitted && (
<p className="text-sm font-medium text-emerald-600 dark:text-emerald-400">
Thanks — your {value}-star rating is on its way to the team.
</p>
)}
</div>
</div>
{/* Community distribution */}
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-6 dark:border-slate-800 dark:bg-slate-950/40">
<div className="flex items-end gap-3">
<span className="text-4xl font-bold tabular-nums">
{AVERAGE.toFixed(1)}
</span>
<div className="pb-1">
<div className="flex" aria-hidden="true">
{[1, 2, 3, 4, 5].map((n) => (
<StarShapeSmall key={n} filled={n <= Math.round(AVERAGE)} />
))}
</div>
<p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
{TOTAL_REVIEWS.toLocaleString("en-US")} verified reviews
</p>
</div>
</div>
<ul className="mt-6 space-y-2.5">
{DISTRIBUTION.map((bucket) => (
<li key={bucket.stars} className="flex items-center gap-3">
<span className="w-10 shrink-0 text-xs font-medium tabular-nums text-slate-500 dark:text-slate-400">
{bucket.stars} star
</span>
<div className="h-2 flex-1 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
<motion.div
className="h-full rounded-full bg-amber-400"
initial={reduced ? false : { width: 0 }}
whileInView={{ width: `${bucket.pct}%` }}
viewport={{ once: true, margin: "-40px" }}
transition={{ duration: 0.7, ease: "easeOut" }}
style={reduced ? { width: `${bucket.pct}%` } : undefined}
/>
</div>
<span className="w-9 shrink-0 text-right text-xs tabular-nums text-slate-400 dark:text-slate-500">
{bucket.pct}%
</span>
</li>
))}
</ul>
<p className="mt-6 border-t border-slate-200 pt-4 text-xs leading-relaxed text-slate-500 dark:border-slate-800 dark:text-slate-400">
Ratings are collected after the first completed setup and refreshed
weekly. Only teams that shipped a live integration can leave one.
</p>
</div>
</div>
</section>
);
}
function StarShapeSmall({ filled }: { filled: boolean }) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-4 w-4">
<path
d="M12 2.4l2.9 5.88 6.49.95-4.7 4.58 1.11 6.46L12 17.22l-5.81 3.05 1.11-6.46-4.7-4.58 6.49-.95z"
className={
filled
? "fill-amber-400 stroke-amber-500 dark:stroke-amber-300"
: "fill-transparent stroke-slate-300 dark:stroke-slate-600"
}
strokeWidth={1.5}
strokeLinejoin="round"
/>
</svg>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Stars Half Rating
Originalhalf-star rating display

Emoji Rating
Originalemoji reaction rating
Thumbs Rating
Originalthumbs up/down rating

Hearts Rating
Originalheart rating

Scale Rating
Original1-10 numeric scale rating

Summary Rating
Originalrating summary with distribution bars

Interactive Rating
Originalhoverable star rating with label

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

Split Hero
OriginalA two-column hero pairing a headline and CTAs with a product mock and social proof.

Gradient Spotlight Hero
OriginalA minimal centred hero with a soft gradient-mesh backdrop, announcement pill and dual call-to-action buttons.

App Preview Hero
OriginalA centred hero that pairs headline copy with a realistic product dashboard mock built entirely from markup, complete with browser chrome and a floating notification card.

Waitlist Capture Hero
OriginalA dark, focused hero with an inline email capture form and avatar social proof, ready for pre-launch waitlists.

