Scale Rating
Original · free1-10 numeric scale 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-scale.json"use client";
import {
useId,
useRef,
useState,
type ChangeEvent,
type KeyboardEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
const SCALE_MIN = 1;
const SCALE_MAX = 10;
const SCORES: number[] = Array.from({ length: SCALE_MAX }, (_, i) => i + 1);
const MAX_COMMENT = 280;
type BandId = "low" | "mid" | "good" | "top";
interface Band {
id: BandId;
title: string;
prompt: string;
placeholder: string;
chip: string;
fill: string;
text: string;
softBg: string;
softBorder: string;
}
const BANDS: Record<BandId, Band> = {
low: {
id: "low",
title: "Not great",
prompt: "We're sorry we let you down. What went wrong on this one?",
placeholder: "Tell us what we could have handled better...",
chip: "border-rose-500 bg-rose-500 text-white dark:border-rose-400 dark:bg-rose-500",
fill: "from-rose-500 to-rose-400",
text: "text-rose-600 dark:text-rose-400",
softBg: "bg-rose-50 dark:bg-rose-950/40",
softBorder: "border-rose-200 dark:border-rose-900/60",
},
mid: {
id: "mid",
title: "Could be better",
prompt: "We can do better than this. What would have made it a great experience?",
placeholder: "What would have improved things for you...",
chip: "border-amber-500 bg-amber-500 text-white dark:border-amber-400 dark:bg-amber-500",
fill: "from-amber-500 to-amber-400",
text: "text-amber-600 dark:text-amber-400",
softBg: "bg-amber-50 dark:bg-amber-950/40",
softBorder: "border-amber-200 dark:border-amber-900/60",
},
good: {
id: "good",
title: "Pretty good",
prompt: "Thanks — we're close. What would it take to earn a perfect score?",
placeholder: "What would push this to a 10...",
chip: "border-sky-500 bg-sky-500 text-white dark:border-sky-400 dark:bg-sky-500",
fill: "from-sky-500 to-sky-400",
text: "text-sky-600 dark:text-sky-400",
softBg: "bg-sky-50 dark:bg-sky-950/40",
softBorder: "border-sky-200 dark:border-sky-900/60",
},
top: {
id: "top",
title: "Excellent",
prompt: "Wonderful, thank you! What stood out most about the experience?",
placeholder: "What did you love most...",
chip: "border-emerald-500 bg-emerald-500 text-white dark:border-emerald-400 dark:bg-emerald-500",
fill: "from-emerald-500 to-emerald-400",
text: "text-emerald-600 dark:text-emerald-400",
softBg: "bg-emerald-50 dark:bg-emerald-950/40",
softBorder: "border-emerald-200 dark:border-emerald-900/60",
},
};
function getBand(score: number): Band {
if (score <= 3) return BANDS.low;
if (score <= 6) return BANDS.mid;
if (score <= 8) return BANDS.good;
return BANDS.top;
}
const KEYFRAMES = `
@keyframes ratescale-pop {
0% { transform: scale(0.82); }
55% { transform: scale(1.08); }
100% { transform: scale(1); }
}
@keyframes ratescale-badge {
0% { transform: scale(0.4); opacity: 0; }
60% { transform: scale(1.12); opacity: 1; }
100% { transform: scale(1); opacity: 1; }
}
.ratescale-pop { animation: ratescale-pop 260ms cubic-bezier(0.34, 1.56, 0.64, 1); }
.ratescale-badge { animation: ratescale-badge 420ms cubic-bezier(0.34, 1.56, 0.64, 1); }
@media (prefers-reduced-motion: reduce) {
.ratescale-pop, .ratescale-badge { animation: none; }
}
`;
export default function RateScale() {
const reduce = useReducedMotion();
const titleId = useId();
const hintId = useId();
const promptId = useId();
const commentId = useId();
const [value, setValue] = useState<number | null>(null);
const [hovered, setHovered] = useState<number | null>(null);
const [comment, setComment] = useState("");
const [submitted, setSubmitted] = useState(false);
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
const editBtnRef = useRef<HTMLButtonElement | null>(null);
const displayScore = hovered ?? value ?? 0;
const activeBand = displayScore > 0 ? getBand(displayScore) : null;
const selectedBand = value !== null ? getBand(value) : null;
const focusableScore = value ?? SCALE_MIN;
function selectAndFocus(next: number) {
setValue(next);
optionRefs.current[next - 1]?.focus();
}
function handleKeyDown(e: KeyboardEvent<HTMLButtonElement>, score: number) {
let next: number | null = null;
switch (e.key) {
case "ArrowRight":
case "ArrowUp":
next = Math.min(SCALE_MAX, score + 1);
break;
case "ArrowLeft":
case "ArrowDown":
next = Math.max(SCALE_MIN, score - 1);
break;
case "Home":
next = SCALE_MIN;
break;
case "End":
next = SCALE_MAX;
break;
case " ":
case "Enter":
next = score;
break;
default:
return;
}
e.preventDefault();
selectAndFocus(next);
}
function handleComment(e: ChangeEvent<HTMLTextAreaElement>) {
setComment(e.target.value.slice(0, MAX_COMMENT));
}
function handleSubmit(e: { preventDefault: () => void }) {
e.preventDefault();
if (value === null) return;
setSubmitted(true);
}
function handleReset() {
setSubmitted(false);
requestAnimationFrame(() => {
optionRefs.current[(value ?? SCALE_MIN) - 1]?.focus();
});
}
return (
<section className="relative w-full bg-slate-50 px-4 py-16 sm:py-24 dark:bg-zinc-950">
<style>{KEYFRAMES}</style>
<div className="mx-auto w-full max-w-xl overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-zinc-800 dark:bg-zinc-900">
<div
aria-hidden="true"
className={`h-1 w-full bg-gradient-to-r transition-colors duration-300 ${
activeBand
? activeBand.fill
: "from-slate-200 to-slate-200 dark:from-zinc-800 dark:to-zinc-800"
}`}
/>
<div className="p-6 sm:p-8">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
Feedback
</p>
<h2
id={titleId}
className="mt-2 text-xl font-semibold tracking-tight text-slate-900 sm:text-2xl dark:text-white"
>
How was your experience with our support team?
</h2>
<p className="mt-2 text-sm leading-relaxed text-slate-500 dark:text-zinc-400">
Rate it from 1 to 10. Your answer helps us tune response times and
quality — it takes about ten seconds.
</p>
<AnimatePresence
mode="wait"
initial={false}
onExitComplete={() => {
if (submitted) {
requestAnimationFrame(() => editBtnRef.current?.focus());
}
}}
>
{submitted && selectedBand && value !== null ? (
<motion.div
key="success"
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
transition={reduce ? { duration: 0 } : { duration: 0.25, ease: "easeOut" }}
className="mt-8"
>
<div
className={`flex flex-col items-center rounded-xl border px-6 py-8 text-center ${selectedBand.softBg} ${selectedBand.softBorder}`}
>
<span
className={`ratescale-badge flex h-14 w-14 items-center justify-center rounded-full bg-gradient-to-br text-white ${selectedBand.fill}`}
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="h-7 w-7"
aria-hidden="true"
>
<path d="M20 6 9 17l-5-5" />
</svg>
</span>
<h3 className="mt-4 text-lg font-semibold text-slate-900 dark:text-white">
Thanks for the feedback
</h3>
<p className="mt-1 text-sm text-slate-600 dark:text-zinc-300">
You rated your support experience{" "}
<span className={`font-bold tabular-nums ${selectedBand.text}`}>
{value}/10
</span>{" "}
— {selectedBand.title.toLowerCase()}.
</p>
{comment.trim().length > 0 && (
<blockquote className="mt-4 max-w-sm rounded-lg border border-slate-200 bg-white/70 px-4 py-3 text-left text-sm italic text-slate-600 dark:border-zinc-700 dark:bg-zinc-800/60 dark:text-zinc-300">
“{comment.trim()}”
</blockquote>
)}
<button
type="button"
ref={editBtnRef}
onClick={handleReset}
className="mt-6 inline-flex items-center justify-center gap-2 rounded-xl border border-slate-300 bg-white px-5 py-2.5 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-900"
>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
aria-hidden="true"
>
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.12 2.12 0 0 1 3 3L12 15l-4 1 1-4Z" />
</svg>
Edit response
</button>
</div>
</motion.div>
) : (
<motion.form
key="form"
onSubmit={handleSubmit}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
transition={reduce ? { duration: 0 } : { duration: 0.25, ease: "easeOut" }}
className="mt-8"
>
<div className="flex items-end justify-between gap-4">
<div className="flex items-baseline gap-1.5">
<span
className={`text-4xl font-bold tabular-nums transition-colors ${
displayScore > 0 && activeBand
? activeBand.text
: "text-slate-300 dark:text-zinc-600"
}`}
>
{displayScore > 0 ? displayScore : "—"}
</span>
<span className="text-lg font-medium text-slate-400 dark:text-zinc-500">
/ 10
</span>
</div>
<span
className={`rounded-full px-3 py-1 text-xs font-semibold transition-colors ${
displayScore > 0 && activeBand
? `${activeBand.softBg} ${activeBand.text}`
: "bg-slate-100 text-slate-400 dark:bg-zinc-800 dark:text-zinc-500"
}`}
>
{activeBand ? activeBand.title : "No rating yet"}
</span>
</div>
<div
role="radiogroup"
aria-labelledby={titleId}
aria-describedby={hintId}
className="mt-4 grid grid-cols-5 gap-2 sm:grid-cols-10"
>
{SCORES.map((score) => {
const isSelected = value === score;
return (
<button
key={score}
type="button"
role="radio"
aria-checked={isSelected}
aria-label={`${score} out of 10`}
tabIndex={score === focusableScore ? 0 : -1}
ref={(el) => {
optionRefs.current[score - 1] = el;
}}
onClick={() => setValue(score)}
onKeyDown={(e) => handleKeyDown(e, score)}
onMouseEnter={() => setHovered(score)}
onMouseLeave={() => setHovered(null)}
onFocus={() => setHovered(score)}
onBlur={() => setHovered(null)}
className={`relative flex h-11 w-full items-center justify-center rounded-xl border text-sm font-semibold tabular-nums transition-colors select-none focus-visible:outline-none 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-zinc-900 ${
isSelected
? `${getBand(score).chip} shadow-sm ratescale-pop`
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:bg-slate-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300 dark:hover:border-zinc-600 dark:hover:bg-zinc-700/60"
}`}
>
{score}
</button>
);
})}
</div>
<span id={hintId} className="sr-only">
Use the left and right arrow keys to choose a rating from 1
(poor) to 10 (excellent), then press Enter to submit.
</span>
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-zinc-800">
<motion.div
className={`h-full rounded-full bg-gradient-to-r ${
activeBand ? activeBand.fill : "from-slate-200 to-slate-200"
}`}
initial={false}
animate={{ width: `${(displayScore / SCALE_MAX) * 100}%` }}
transition={
reduce
? { duration: 0 }
: { type: "spring", stiffness: 260, damping: 32 }
}
/>
</div>
<div className="mt-2 flex items-center justify-between text-xs font-medium text-slate-400 dark:text-zinc-500">
<span>1 — Poor</span>
<span>10 — Excellent</span>
</div>
<AnimatePresence initial={false}>
{selectedBand && (
<motion.div
key="followup"
initial={reduce ? { opacity: 0 } : { opacity: 0, height: 0, y: -4 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, height: "auto", y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, height: 0, y: -4 }}
transition={reduce ? { duration: 0 } : { duration: 0.28, ease: "easeOut" }}
className="overflow-hidden"
>
<div className="pt-5">
<p
id={promptId}
aria-live="polite"
className={`text-sm font-medium ${selectedBand.text}`}
>
{selectedBand.prompt}
</p>
<label
htmlFor={commentId}
className="mt-3 block text-xs font-semibold text-slate-500 dark:text-zinc-400"
>
Add a comment (optional)
</label>
<textarea
id={commentId}
value={comment}
onChange={handleComment}
aria-describedby={promptId}
rows={3}
placeholder={selectedBand.placeholder}
className="mt-1.5 w-full resize-none rounded-xl border border-slate-200 bg-white px-3.5 py-2.5 text-sm text-slate-800 placeholder:text-slate-400 focus-visible:border-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/30 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder:text-zinc-500 dark:focus-visible:border-indigo-400 dark:focus-visible:ring-indigo-400/30"
/>
<div className="mt-1 text-right text-xs tabular-nums text-slate-400 dark:text-zinc-500">
{comment.length}/{MAX_COMMENT}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="mt-5 flex flex-wrap items-center justify-between gap-3 border-t border-slate-100 pt-5 dark:border-zinc-800">
<span className="text-xs text-slate-400 dark:text-zinc-500">
Anonymous · never shared with third parties
</span>
<button
type="submit"
disabled={value === null}
className="inline-flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-5 py-2.5 text-sm font-semibold text-white shadow-sm 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:opacity-40 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-zinc-900"
>
Send feedback
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="h-4 w-4"
aria-hidden="true"
>
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
</button>
</div>
</motion.form>
)}
</AnimatePresence>
</div>
</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

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.

