Interactive Rating
Original · freehoverable star rating with label
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-interactive.json"use client";
import { useId, useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Rating = {
value: number;
label: string;
blurb: string;
tone: string;
dot: string;
};
const RATINGS: Rating[] = [
{
value: 1,
label: "Terrible",
blurb: "That's below the standard we hold ourselves to — tell us what broke.",
tone: "text-rose-600 dark:text-rose-400",
dot: "bg-rose-500",
},
{
value: 2,
label: "Poor",
blurb: "We missed the mark here and we'd like a second shot at getting it right.",
tone: "text-rose-500 dark:text-rose-400",
dot: "bg-rose-400",
},
{
value: 3,
label: "Okay",
blurb: "Solid work, but there's real room to make the result exceptional.",
tone: "text-amber-600 dark:text-amber-400",
dot: "bg-amber-500",
},
{
value: 4,
label: "Great",
blurb: "Glad it landed well — thanks for building this alongside us.",
tone: "text-emerald-600 dark:text-emerald-400",
dot: "bg-emerald-500",
},
{
value: 5,
label: "Excellent",
blurb: "You made our week. A short public review would mean a lot to the team.",
tone: "text-emerald-600 dark:text-emerald-400",
dot: "bg-emerald-500",
},
];
const STARS = [1, 2, 3, 4, 5];
function clampToRange(n: number): number {
if (n > 5) return 1;
if (n < 1) return 5;
return n;
}
export default function RateInteractive() {
const reduced = useReducedMotion();
const [value, setValue] = useState(0);
const [hover, setHover] = useState(0);
const [focusIndex, setFocusIndex] = useState(1);
const [submitted, setSubmitted] = useState(false);
const starRefs = useRef<Array<HTMLButtonElement | null>>([]);
const groupId = useId();
const active = hover || value;
const current = active > 0 ? RATINGS[active - 1] : null;
const selected = value > 0 ? RATINGS[value - 1] : null;
function select(next: number) {
setValue(next);
setFocusIndex(next);
setSubmitted(false);
}
function reset() {
setValue(0);
setHover(0);
setFocusIndex(1);
setSubmitted(false);
starRefs.current[1]?.focus();
}
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
let next = focusIndex;
switch (event.key) {
case "ArrowRight":
case "ArrowUp":
next = clampToRange(focusIndex + 1);
break;
case "ArrowLeft":
case "ArrowDown":
next = clampToRange(focusIndex - 1);
break;
case "Home":
next = 1;
break;
case "End":
next = 5;
break;
case " ":
case "Enter":
event.preventDefault();
select(focusIndex);
return;
default:
return;
}
event.preventDefault();
setFocusIndex(next);
select(next);
starRefs.current[next]?.focus();
}
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 rateint-pop {
0% { transform: scale(0.6); opacity: 0; }
60% { transform: scale(1.08); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
@keyframes rateint-rise {
from { transform: translateY(6px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes rateint-glow {
0%, 100% { opacity: 0.35; }
50% { opacity: 0.7; }
}
.rateint-pop { animation: rateint-pop 320ms cubic-bezier(0.22, 1, 0.36, 1) both; }
.rateint-rise { animation: rateint-rise 300ms ease-out both; }
.rateint-glow { animation: rateint-glow 3.2s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.rateint-pop, .rateint-rise, .rateint-glow { animation: none !important; }
}
`}</style>
<div className="mx-auto max-w-lg">
<div className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white p-8 shadow-xl shadow-slate-900/5 sm:p-10 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/30">
<div
aria-hidden="true"
className="rateint-glow pointer-events-none absolute -right-16 -top-16 h-44 w-44 rounded-full bg-gradient-to-br from-indigo-400/30 to-violet-500/30 blur-3xl"
/>
<div className="relative">
<span className="inline-flex items-center gap-1.5 rounded-full border border-indigo-200 bg-indigo-50 px-3 py-1 text-xs font-medium text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Project feedback
</span>
<h2 className="mt-4 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
How was your experience?
</h2>
<p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Your honest rating shapes how we scope, build, and hand off every
engagement. It takes about five seconds.
</p>
<div className="mt-8">
<div
role="radiogroup"
aria-label="Rate your experience from 1 to 5 stars"
aria-required="true"
id={groupId}
onKeyDown={handleKeyDown}
onMouseLeave={() => setHover(0)}
className="flex items-center gap-1 sm:gap-2"
>
{STARS.map((n) => {
const isFilled = n <= active;
const isChecked = value === n;
const isTabbable = focusIndex === n;
return (
<button
key={n}
type="button"
role="radio"
aria-checked={isChecked}
aria-label={`${n} star${n > 1 ? "s" : ""} — ${RATINGS[n - 1].label}`}
tabIndex={isTabbable ? 0 : -1}
ref={(el) => {
starRefs.current[n] = el;
}}
onClick={() => select(n)}
onMouseEnter={() => setHover(n)}
onFocus={() => setFocusIndex(n)}
className="group 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-offset-slate-900"
>
<motion.span
className="block"
animate={
reduced
? undefined
: { scale: isFilled && active === n ? 1.18 : isFilled ? 1.05 : 1 }
}
transition={{ type: "spring", stiffness: 420, damping: 18 }}
>
<svg
viewBox="0 0 24 24"
className={`h-9 w-9 transition-colors duration-150 sm:h-11 sm:w-11 ${
isFilled
? "fill-amber-400 text-amber-400 drop-shadow-sm"
: "fill-transparent text-slate-300 group-hover:text-amber-300 dark:text-slate-600"
}`}
stroke="currentColor"
strokeWidth={1.5}
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M12 2.75l2.72 5.51 6.08.88-4.4 4.29 1.04 6.05L12 16.9l-5.44 2.86 1.04-6.05-4.4-4.29 6.08-.88z" />
</svg>
</motion.span>
</button>
);
})}
</div>
<div className="mt-5 min-h-[3.75rem]">
<AnimatePresence mode="wait" initial={false}>
{current ? (
<motion.div
key={current.value}
initial={reduced ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, y: -6 }}
transition={{ duration: 0.18 }}
>
<div className="flex items-center gap-2">
<span className={`text-lg font-semibold ${current.tone}`}>
{current.label}
</span>
<span className="text-sm font-medium text-slate-400 dark:text-slate-500">
{current.value} / 5
</span>
{hover > 0 && hover !== value ? (
<span className="ml-1 rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-500 dark:bg-slate-800 dark:text-slate-400">
preview
</span>
) : null}
</div>
<p className="mt-1 text-sm leading-relaxed text-slate-600 dark:text-slate-400">
{current.blurb}
</p>
</motion.div>
) : (
<motion.p
key="placeholder"
initial={reduced ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
className="text-sm text-slate-500 dark:text-slate-400"
>
Hover the stars to preview, then click to lock in your rating.
Arrow keys work too.
</motion.p>
)}
</AnimatePresence>
</div>
<div className="mt-6 flex flex-wrap items-center gap-3">
<button
type="button"
disabled={value === 0 || submitted}
onClick={() => setSubmitted(true)}
className="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm transition-colors outline-none hover:bg-indigo-500 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-200 disabled:text-slate-400 dark:focus-visible:ring-offset-slate-900 dark:disabled:bg-slate-800 dark:disabled:text-slate-600"
>
{submitted ? "Rating submitted" : "Submit rating"}
</button>
{value > 0 ? (
<button
type="button"
onClick={reset}
className="inline-flex items-center rounded-xl px-3 py-2.5 text-sm font-medium text-slate-600 underline-offset-4 transition-colors outline-none hover:text-slate-900 hover:underline focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:text-white dark:focus-visible:ring-offset-slate-900"
>
Reset
</button>
) : null}
</div>
<AnimatePresence initial={false}>
{submitted && selected ? (
<motion.div
key="confirm"
initial={reduced ? false : { opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}
transition={{ duration: 0.24 }}
className="overflow-hidden"
>
<div className="rateint-rise mt-5 flex items-start gap-3 rounded-2xl border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-500/30 dark:bg-emerald-500/10">
<span className="rateint-pop mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white">
<svg
viewBox="0 0 20 20"
className="h-3.5 w-3.5"
fill="none"
stroke="currentColor"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M4 10.5l3.5 3.5L16 5.5" />
</svg>
</span>
<div>
<p className="text-sm font-semibold text-emerald-800 dark:text-emerald-300">
Thanks — your {selected.value}-star rating is in.
</p>
<p className="mt-0.5 text-sm text-emerald-700/80 dark:text-emerald-300/70">
{selected.value >= 4
? "We'd love a public review — check your inbox for a link."
: "A project lead will reach out to make this right."}
</p>
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>
</div>
<div
aria-live="polite"
className="sr-only"
>
{selected
? `Selected ${selected.value} of 5 stars, ${selected.label}.`
: "No rating selected."}
</div>
</div>
</div>
<p className="mt-4 text-center text-xs text-slate-500 dark:text-slate-500">
Based on 214 completed engagements this year.
</p>
</div>
</section>
);
}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 Rating
Originalinteractive star rating

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

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.

