Steps Slider
Original · freestepped slider with tick marks
byWeb InnoventixReact + Tailwind
sldstepssliders
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-steps.jsonsld-steps.tsx
"use client";
import {
useId,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
} from "react";
import { motion, useReducedMotion } from "motion/react";
type QualityStep = {
label: string;
res: string;
dims: string;
bitrate: string;
size: string;
render: string;
blurb: string;
};
const STEPS: QualityStep[] = [
{
label: "Draft",
res: "480p",
dims: "854 × 480",
bitrate: "2.5 Mbps",
size: "19 MB",
render: "~2 min",
blurb:
"Fast, low-fidelity preview for rough cuts and quick client check-ins.",
},
{
label: "Standard",
res: "720p",
dims: "1280 × 720",
bitrate: "5 Mbps",
size: "38 MB",
render: "~4 min",
blurb:
"Everyday quality for social clips and internal review passes.",
},
{
label: "High",
res: "1080p",
dims: "1920 × 1080",
bitrate: "10 Mbps",
size: "75 MB",
render: "~7 min",
blurb:
"Crisp full-HD delivery for YouTube, paid ads, and landing pages.",
},
{
label: "Ultra",
res: "1440p",
dims: "2560 × 1440",
bitrate: "16 Mbps",
size: "120 MB",
render: "~13 min",
blurb:
"Extra-sharp detail that holds up on large desktop displays.",
},
{
label: "Cinema",
res: "4K",
dims: "3840 × 2160",
bitrate: "40 Mbps",
size: "300 MB",
render: "~24 min",
blurb:
"Master-grade 4K export for broadcast, film, and long-term archive.",
},
];
const LAST = STEPS.length - 1;
const pct = (i: number): number => (i / LAST) * 100;
export default function SldSteps() {
const reduce = useReducedMotion();
const trackRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLDivElement>(null);
const labelId = useId();
const [index, setIndex] = useState(2);
const [dragging, setDragging] = useState(false);
const [focused, setFocused] = useState(false);
const current = STEPS[index];
const active = dragging || focused;
const spring = reduce
? { duration: 0 }
: ({ type: "spring", stiffness: 440, damping: 34, mass: 0.7 } as const);
const clamp = (n: number): number => Math.min(LAST, Math.max(0, n));
const stepFromClientX = (clientX: number): number => {
const track = trackRef.current;
if (!track) return index;
const rect = track.getBoundingClientRect();
if (rect.width === 0) return index;
const ratio = (clientX - rect.left) / rect.width;
return clamp(Math.round(ratio * LAST));
};
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
e.currentTarget.setPointerCapture(e.pointerId);
setDragging(true);
setIndex(stepFromClientX(e.clientX));
thumbRef.current?.focus();
};
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!dragging) return;
setIndex(stepFromClientX(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>) => {
let next = index;
switch (e.key) {
case "ArrowRight":
case "ArrowUp":
next = index + 1;
break;
case "ArrowLeft":
case "ArrowDown":
next = index - 1;
break;
case "PageUp":
next = index + 2;
break;
case "PageDown":
next = index - 2;
break;
case "Home":
next = 0;
break;
case "End":
next = LAST;
break;
default:
return;
}
e.preventDefault();
setIndex(clamp(next));
};
return (
<section className="relative w-full bg-slate-50 px-6 py-20 sm:px-8 dark:bg-slate-950">
<style>{`
@keyframes sldsteps-ping {
0% { transform: scale(1); opacity: 0.55; }
70% { transform: scale(2.6); opacity: 0; }
100% { transform: scale(2.6); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.sldsteps-ping-ring { animation: none !important; }
}
`}</style>
<div className="mx-auto max-w-xl">
<div className="overflow-hidden rounded-3xl border border-slate-200 bg-white shadow-xl shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/40">
<div className="border-b border-slate-100 px-7 pt-7 pb-6 sm:px-9 dark:border-slate-800">
<div className="flex items-center gap-2.5">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-xl bg-indigo-500/10 text-indigo-600 dark:bg-indigo-400/10 dark:text-indigo-300">
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="3" y="5" width="14" height="14" rx="2" />
<path d="M17 9l4-2v10l-4-2" />
</svg>
</span>
<span className="text-xs font-semibold tracking-[0.18em] text-indigo-600 uppercase dark:text-indigo-300">
Export settings
</span>
</div>
<h2
id={labelId}
className="mt-4 text-2xl font-semibold tracking-tight text-slate-900 dark:text-slate-50"
>
Render quality
</h2>
<p className="mt-2 text-sm leading-relaxed text-slate-500 dark:text-slate-400">
Drag the handle or use the arrow keys to pick an output tier.
Higher tiers sharpen every frame but take longer to render.
</p>
</div>
<div className="px-7 pt-14 pb-7 sm:px-9">
{/* Slider */}
<div className="px-3">
<div
ref={trackRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className="relative flex h-6 touch-none items-center select-none"
>
{/* base track */}
<div className="h-2 w-full rounded-full bg-slate-200 dark:bg-slate-700" />
{/* filled portion */}
<motion.div
className="pointer-events-none absolute top-1/2 left-0 h-2 -translate-y-1/2 rounded-full bg-gradient-to-r from-indigo-500 to-violet-500"
initial={false}
animate={{ width: `${pct(index)}%` }}
transition={spring}
/>
{/* tick marks */}
{STEPS.map((s, i) => {
const passed = i <= index;
return (
<span
key={s.label}
aria-hidden="true"
className={`pointer-events-none absolute top-1/2 h-3 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors duration-200 ${
passed
? "bg-white/70 dark:bg-white/60"
: "bg-slate-300 dark:bg-slate-600"
}`}
style={{ left: `${pct(i)}%` }}
/>
);
})}
{/* thumb */}
<motion.div
ref={thumbRef}
role="slider"
tabIndex={0}
aria-labelledby={labelId}
aria-orientation="horizontal"
aria-valuemin={0}
aria-valuemax={LAST}
aria-valuenow={index}
aria-valuetext={`${current.label}, ${current.res}`}
onKeyDown={onKeyDown}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
initial={false}
animate={{ left: `${pct(index)}%` }}
transition={spring}
className={`absolute top-1/2 z-10 flex h-6 w-6 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border-2 border-indigo-500 bg-white shadow-md shadow-indigo-500/20 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-indigo-400 dark:bg-slate-100 dark:shadow-black/40 dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900 ${
dragging ? "cursor-grabbing" : "cursor-grab"
}`}
>
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500 dark:bg-indigo-500" />
{active ? (
<span
className="sldsteps-ping-ring pointer-events-none absolute inset-0 rounded-full border-2 border-indigo-400 dark:border-indigo-300"
style={{ animation: "sldsteps-ping 1.5s ease-out infinite" }}
/>
) : null}
{/* value bubble */}
<span className="pointer-events-none absolute bottom-full left-1/2 mb-3 -translate-x-1/2 rounded-lg bg-slate-900 px-2.5 py-1 text-xs font-semibold whitespace-nowrap text-white shadow-lg dark:bg-slate-100 dark:text-slate-900">
{current.res}
<span
aria-hidden="true"
className="absolute top-full left-1/2 -mt-1 h-2 w-2 -translate-x-1/2 rotate-45 rounded-[2px] bg-slate-900 dark:bg-slate-100"
/>
</span>
</motion.div>
</div>
{/* step labels (clickable) */}
<div className="relative mt-5 h-11">
{STEPS.map((s, i) => {
const isCurrent = i === index;
return (
<button
key={s.label}
type="button"
onClick={() => {
setIndex(i);
thumbRef.current?.focus();
}}
aria-label={`${s.label}, ${s.res}`}
aria-current={isCurrent ? "true" : undefined}
className="absolute top-0 flex -translate-x-1/2 flex-col items-center gap-0.5 rounded-md px-1 py-0.5 outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:focus-visible:ring-indigo-400"
style={{ left: `${pct(i)}%` }}
>
<span
className={`text-[13px] font-semibold transition-colors ${
isCurrent
? "text-indigo-600 dark:text-indigo-300"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
}`}
>
{s.label}
</span>
<span
className={`text-[11px] tabular-nums transition-colors ${
isCurrent
? "text-slate-500 dark:text-slate-400"
: "text-slate-400 dark:text-slate-500"
}`}
>
{s.res}
</span>
</button>
);
})}
</div>
</div>
{/* live detail panel */}
<motion.div
key={index}
initial={reduce ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={reduce ? { duration: 0 } : { duration: 0.28, ease: "easeOut" }}
className="mt-8 rounded-2xl border border-slate-200 bg-slate-50/80 p-5 dark:border-slate-800 dark:bg-slate-950/40"
>
<div className="flex items-baseline justify-between gap-4">
<div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-bold tracking-tight text-slate-900 tabular-nums dark:text-slate-50">
{current.res}
</span>
<span className="text-sm font-medium text-slate-500 dark:text-slate-400">
{current.label}
</span>
</div>
<p className="mt-1.5 max-w-sm text-sm leading-relaxed text-slate-500 dark:text-slate-400">
{current.blurb}
</p>
</div>
<span className="inline-flex shrink-0 items-center gap-1.5 rounded-full bg-emerald-500/10 px-2.5 py-1 text-xs font-semibold text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500 dark:bg-emerald-400" />
Step {index + 1} / {STEPS.length}
</span>
</div>
<dl className="mt-5 grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-slate-200 bg-slate-200 sm:grid-cols-4 dark:border-slate-800 dark:bg-slate-800">
{[
{ k: "Dimensions", v: current.dims },
{ k: "Bitrate", v: current.bitrate },
{ k: "Size / min", v: current.size },
{ k: "Render · 5-min", v: current.render },
].map((stat) => (
<div
key={stat.k}
className="bg-white px-3.5 py-3 dark:bg-slate-900"
>
<dt className="text-[11px] font-medium tracking-wide text-slate-400 uppercase dark:text-slate-500">
{stat.k}
</dt>
<dd className="mt-1 text-sm font-semibold text-slate-800 tabular-nums dark:text-slate-100">
{stat.v}
</dd>
</div>
))}
</dl>
</motion.div>
</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

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

Rating Slider
Originalsatisfaction rating slider

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

