Rating Slider
Original · freesatisfaction rating slider
byWeb InnoventixReact + Tailwind
sldratingsliders
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/sld-rating.jsonsld-rating.tsx
"use client";
import {
useState,
useRef,
useCallback,
type PointerEvent as ReactPointerEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";
type Level = {
label: string;
blurb: string;
labelText: string;
faceText: string;
faceBg: string;
ring: string;
badge: string;
bar: string;
};
const STEP = 5;
const THUMB = 30;
const LEVELS: Level[] = [
{
label: "Very dissatisfied",
blurb: "We clearly missed the mark. Tell us exactly what went wrong.",
labelText: "text-rose-600 dark:text-rose-400",
faceText: "text-rose-500 dark:text-rose-400",
faceBg: "bg-rose-50 dark:bg-rose-500/10",
ring: "ring-rose-200 dark:ring-rose-500/30",
badge:
"bg-rose-500/10 text-rose-600 ring-rose-500/25 dark:text-rose-300 dark:ring-rose-400/30",
bar: "bg-rose-500",
},
{
label: "Dissatisfied",
blurb: "Not our best work. There is real room for us to improve here.",
labelText: "text-amber-600 dark:text-amber-400",
faceText: "text-amber-500 dark:text-amber-400",
faceBg: "bg-amber-50 dark:bg-amber-500/10",
ring: "ring-amber-200 dark:ring-amber-500/30",
badge:
"bg-amber-500/10 text-amber-600 ring-amber-500/25 dark:text-amber-300 dark:ring-amber-400/30",
bar: "bg-amber-500",
},
{
label: "Neutral",
blurb: "Middle of the road. Solid enough, but nothing memorable yet.",
labelText: "text-amber-600 dark:text-amber-300",
faceText: "text-amber-500 dark:text-amber-300",
faceBg: "bg-amber-50 dark:bg-amber-400/10",
ring: "ring-amber-200 dark:ring-amber-400/30",
badge:
"bg-amber-400/15 text-amber-600 ring-amber-400/30 dark:text-amber-200 dark:ring-amber-300/30",
bar: "bg-amber-400",
},
{
label: "Satisfied",
blurb: "Glad it landed well. We would love to make it even better.",
labelText: "text-emerald-600 dark:text-emerald-400",
faceText: "text-emerald-500 dark:text-emerald-400",
faceBg: "bg-emerald-50 dark:bg-emerald-500/10",
ring: "ring-emerald-200 dark:ring-emerald-500/30",
badge:
"bg-emerald-500/10 text-emerald-600 ring-emerald-500/25 dark:text-emerald-300 dark:ring-emerald-400/30",
bar: "bg-emerald-500",
},
{
label: "Very satisfied",
blurb: "This made our day. Thank you for the generous feedback.",
labelText: "text-emerald-600 dark:text-emerald-400",
faceText: "text-emerald-500 dark:text-emerald-400",
faceBg: "bg-emerald-50 dark:bg-emerald-500/10",
ring: "ring-emerald-200 dark:ring-emerald-500/30",
badge:
"bg-emerald-500/15 text-emerald-700 ring-emerald-500/30 dark:text-emerald-200 dark:ring-emerald-300/30",
bar: "bg-emerald-500",
},
];
const SEGMENTS: { label: string; value: number }[] = [
{ label: "Awful", value: 10 },
{ label: "Poor", value: 30 },
{ label: "Okay", value: 50 },
{ label: "Good", value: 70 },
{ label: "Great", value: 90 },
];
function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n));
}
function levelFor(value: number): number {
return Math.min(LEVELS.length - 1, Math.floor(value / 20));
}
function mouthPath(value: number): string {
const curve = ((value - 50) / 50) * 20;
const corner = 62 - curve * 0.35;
const mid = 62 + curve;
return `M 34 ${corner.toFixed(1)} Q 50 ${mid.toFixed(1)} 66 ${corner.toFixed(1)}`;
}
export default function SldRating() {
const reduce = useReducedMotion();
const [value, setValue] = useState<number>(70);
const [dragging, setDragging] = useState<boolean>(false);
const [focused, setFocused] = useState<boolean>(false);
const [submitted, setSubmitted] = useState<boolean>(false);
const trackRef = useRef<HTMLDivElement | null>(null);
const thumbRef = useRef<HTMLDivElement | null>(null);
const index = levelFor(value);
const level = LEVELS[index];
const active = dragging || focused;
const eyeRy = (7 - (value / 100) * 2).toFixed(2);
const setFromClientX = useCallback((clientX: number) => {
const el = trackRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const ratio = rect.width === 0 ? 0 : (clientX - rect.left) / rect.width;
const next = clamp(Math.round((ratio * 100) / STEP) * STEP, 0, 100);
setValue(next);
}, []);
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
if (submitted) return;
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
setDragging(true);
setFromClientX(e.clientX);
thumbRef.current?.focus();
};
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!dragging) return;
setFromClientX(e.clientX);
};
const onPointerUp = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!dragging) return;
setDragging(false);
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
};
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
if (submitted) return;
let next = value;
switch (e.key) {
case "ArrowRight":
case "ArrowUp":
next = value + STEP;
break;
case "ArrowLeft":
case "ArrowDown":
next = value - STEP;
break;
case "PageUp":
next = value + 20;
break;
case "PageDown":
next = value - 20;
break;
case "Home":
next = 0;
break;
case "End":
next = 100;
break;
default:
return;
}
e.preventDefault();
setValue(clamp(next, 0, 100));
};
const thumbLeft = `calc(${value}% + ${((0.5 - value / 100) * THUMB).toFixed(2)}px)`;
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-4 py-20 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:py-28">
<style>{`
@keyframes sldr-bob { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-5px)} }
@keyframes sldr-shimmer { 0%{transform:translateX(-130%)} 100%{transform:translateX(360%)} }
@keyframes sldr-ping { 0%{transform:scale(1);opacity:.55} 100%{transform:scale(2.3);opacity:0} }
@keyframes sldr-draw { from{stroke-dashoffset:26} to{stroke-dashoffset:0} }
.sldr-bob{animation:sldr-bob 4.8s ease-in-out infinite}
.sldr-shimmer{animation:sldr-shimmer 2.8s ease-in-out infinite}
.sldr-ping{animation:sldr-ping 1.7s ease-out infinite}
.sldr-draw{stroke-dasharray:26;animation:sldr-draw .5s ease-out forwards}
@media (prefers-reduced-motion: reduce){
.sldr-bob,.sldr-shimmer,.sldr-ping,.sldr-draw{animation:none!important}
.sldr-draw{stroke-dashoffset:0}
}
`}</style>
{/* atmospheric backdrop */}
<div
aria-hidden="true"
className="pointer-events-none absolute -top-24 left-1/2 h-72 w-72 -translate-x-1/2 rounded-full bg-gradient-to-br from-indigo-300/40 via-sky-200/30 to-emerald-200/30 blur-3xl dark:from-indigo-600/20 dark:via-sky-700/10 dark:to-emerald-700/10"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-[0.4] [background-image:radial-gradient(circle_at_1px_1px,rgb(100_116_139/0.14)_1px,transparent_0)] [background-size:22px_22px] dark:opacity-[0.5] dark:[background-image:radial-gradient(circle_at_1px_1px,rgb(148_163_184/0.12)_1px,transparent_0)]"
/>
<div className="relative mx-auto max-w-xl">
<div className="rounded-[28px] border border-slate-200/80 bg-white/80 p-6 shadow-[0_24px_60px_-28px_rgb(15_23_42/0.4)] backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/70 dark:shadow-[0_24px_60px_-28px_rgb(0_0_0/0.7)] sm:p-9">
<div className="flex items-center justify-between gap-4">
<span className="inline-flex items-center gap-2 rounded-full bg-slate-900/5 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500 ring-1 ring-inset ring-slate-900/10 dark:bg-white/5 dark:text-slate-400 dark:ring-white/10">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Feedback
</span>
<span
className={`inline-flex items-baseline gap-1 rounded-full px-3 py-1 text-sm font-semibold tabular-nums ring-1 ring-inset transition-colors duration-300 ${level.badge}`}
>
<span className="text-lg leading-none">{value}</span>
<span className="text-xs opacity-70">/100</span>
</span>
</div>
<h2 className="mt-5 text-2xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-3xl">
How satisfied are you with Innoventix?
</h2>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
Slide the dial to the spot that matches your experience. Drag,
click a step, or use the arrow keys.
</p>
{/* face + verdict */}
<div className="mt-8 flex flex-col items-center text-center">
<motion.div
key={index}
initial={reduce ? false : { scale: 0.82, opacity: 0.5 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ type: "spring", stiffness: 320, damping: 18 }}
className={`relative flex h-28 w-28 items-center justify-center rounded-full ring-8 ring-inset transition-colors duration-300 ${level.faceBg} ${level.ring}`}
>
<div className={`sldr-bob ${level.faceText}`}>
<svg
viewBox="0 0 100 100"
className="h-20 w-20"
aria-hidden="true"
fill="none"
>
<circle
cx="50"
cy="50"
r="42"
stroke="currentColor"
strokeOpacity="0.18"
strokeWidth="4"
/>
<ellipse cx="37" cy="43" rx="5.4" ry={eyeRy} fill="currentColor" />
<ellipse cx="63" cy="43" rx="5.4" ry={eyeRy} fill="currentColor" />
<path
d={mouthPath(value)}
stroke="currentColor"
strokeWidth="5.5"
strokeLinecap="round"
style={{ transition: reduce ? undefined : "d 0.15s ease-out" }}
/>
</svg>
</div>
</motion.div>
<div aria-live="polite" className="mt-5">
<p
className={`text-lg font-semibold transition-colors duration-300 ${level.labelText}`}
>
{level.label}
</p>
<p className="mx-auto mt-1 max-w-xs text-sm text-slate-500 dark:text-slate-400">
{level.blurb}
</p>
</div>
</div>
{/* slider */}
<div className="mt-8">
<div
ref={trackRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className={`relative h-9 select-none ${
submitted ? "cursor-default" : "cursor-pointer"
}`}
style={{ touchAction: "none" }}
>
{/* base track */}
<div className="absolute inset-x-0 top-1/2 h-3 -translate-y-1/2 rounded-full bg-slate-200 ring-1 ring-inset ring-slate-900/5 dark:bg-slate-800 dark:ring-white/5" />
{/* filled gradient (clipped to value) */}
<div
className="absolute inset-x-0 top-1/2 h-3 -translate-y-1/2 overflow-hidden rounded-full"
style={{
clipPath: `inset(0 ${(100 - value).toFixed(2)}% 0 0 round 9999px)`,
transition: dragging ? "none" : "clip-path 0.18s ease-out",
backgroundImage:
"linear-gradient(90deg, rgb(244 63 94) 0%, rgb(245 158 11) 50%, rgb(16 185 129) 100%)",
}}
>
<div className="sldr-shimmer absolute inset-y-0 -left-1/3 w-1/3 bg-gradient-to-r from-transparent via-white/55 to-transparent dark:via-white/35" />
</div>
{/* value bubble */}
<AnimatePresence>
{active && !submitted ? (
<motion.div
initial={reduce ? { opacity: 1 } : { opacity: 0, y: 6, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.9 }}
transition={{ duration: 0.16 }}
className="pointer-events-none absolute bottom-full z-10 mb-3 -translate-x-1/2 rounded-lg bg-slate-900 px-2 py-1 text-xs font-semibold tabular-nums text-white shadow-lg dark:bg-white dark:text-slate-900"
style={{ left: thumbLeft }}
>
{value}
<span
aria-hidden="true"
className="absolute left-1/2 top-full h-2 w-2 -translate-x-1/2 -translate-y-1 rotate-45 bg-slate-900 dark:bg-white"
/>
</motion.div>
) : null}
</AnimatePresence>
{/* thumb */}
<div
ref={thumbRef}
role="slider"
tabIndex={submitted ? -1 : 0}
aria-label="Satisfaction rating"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={value}
aria-valuetext={`${value} out of 100, ${level.label}`}
aria-disabled={submitted || undefined}
onKeyDown={onKeyDown}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
className="absolute top-1/2 z-10 flex h-[30px] w-[30px] -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border-[3px] border-white bg-slate-900 shadow-[0_4px_14px_-2px_rgb(15_23_42/0.5)] outline-none ring-slate-900/10 transition-transform duration-150 focus-visible:ring-4 focus-visible:ring-indigo-500/50 dark:border-slate-900 dark:bg-white dark:focus-visible:ring-indigo-400/60"
style={{
left: thumbLeft,
transform: `translate(-50%, -50%) scale(${
active && !reduce ? 1.12 : 1
})`,
}}
>
<span
className={`h-2 w-2 rounded-full transition-colors duration-300 ${level.bar}`}
/>
{active && !submitted ? (
<span
aria-hidden="true"
className={`sldr-ping absolute inset-0 rounded-full ring-2 ${level.ring}`}
/>
) : null}
</div>
</div>
{/* step labels */}
<div className="mt-3 grid grid-cols-5">
{SEGMENTS.map((seg, i) => {
const on = i === index;
return (
<button
key={seg.label}
type="button"
disabled={submitted}
onClick={() => {
setValue(seg.value);
thumbRef.current?.focus();
}}
aria-pressed={on}
className={`rounded-md py-1 text-center text-xs font-medium outline-none transition-colors duration-200 focus-visible:ring-2 focus-visible:ring-indigo-500/50 disabled:cursor-default dark:focus-visible:ring-indigo-400/60 ${
on
? level.labelText
: "text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300"
}`}
>
{seg.label}
</button>
);
})}
</div>
</div>
{/* footer / confirmation */}
<div className="mt-7 min-h-[52px]">
<AnimatePresence mode="wait" initial={false}>
{submitted ? (
<motion.div
key="done"
initial={reduce ? { opacity: 1 } : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
transition={{ duration: 0.22 }}
className="flex flex-col items-center gap-3 rounded-2xl bg-emerald-50 px-4 py-4 text-center ring-1 ring-inset ring-emerald-500/20 dark:bg-emerald-500/10 dark:ring-emerald-400/25 sm:flex-row sm:text-left"
>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white">
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" aria-hidden="true">
<path
d="M5 12.5l4 4 10-10"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="sldr-draw"
/>
</svg>
</span>
<p
aria-live="assertive"
className="flex-1 text-sm font-medium text-emerald-800 dark:text-emerald-200"
>
Thanks for the feedback! You rated us{" "}
<span className="font-semibold">{level.label.toLowerCase()}</span>{" "}
({value}/100).
</p>
<button
type="button"
onClick={() => {
setSubmitted(false);
thumbRef.current?.focus();
}}
className="shrink-0 rounded-lg px-3 py-1.5 text-sm font-semibold text-emerald-700 underline-offset-4 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-emerald-500/50 dark:text-emerald-300"
>
Change response
</button>
</motion.div>
) : (
<motion.button
key="cta"
type="button"
onClick={() => setSubmitted(true)}
initial={reduce ? { opacity: 1 } : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
transition={{ duration: 0.22 }}
whileTap={reduce ? undefined : { scale: 0.98 }}
className="group flex w-full items-center justify-center gap-2 rounded-2xl bg-slate-900 px-5 py-3.5 text-sm font-semibold text-white shadow-lg shadow-slate-900/10 outline-none transition-colors hover:bg-slate-800 focus-visible:ring-4 focus-visible:ring-indigo-500/40 dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-indigo-400/50"
>
Submit my rating
<svg
viewBox="0 0 24 24"
className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-0.5"
fill="none"
aria-hidden="true"
>
<path
d="M4 12h15m0 0l-6-6m6 6l-6 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</motion.button>
)}
</AnimatePresence>
</div>
</div>
</div>
</section>
);
}Dependencies
motion
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 →
Basic Slider
Originalbasic range slider with value bubble

Range Dual Slider
Originaldual-handle min/max range

Steps Slider
Originalstepped slider with tick marks

Vertical Slider
Originalvertical sliders

Tooltip Slider
Originalslider with a floating value tooltip

Volume Slider
Originalvolume slider with icon

Price Range Slider
Originalprice range filter slider

Gradient Track Slider
Originalslider with a gradient track

Brightness Slider
Originalbrightness/contrast control sliders

Marks Slider
Originalslider with labelled marks

Color Hue Slider
Originalhue/saturation color sliders

Progress Drag Slider
Originalmedia scrubber slider

