Marks Slider
Original · freeslider with labelled marks
byWeb InnoventixReact + Tailwind
sldmarkssliders
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-marks.jsonsld-marks.tsx
"use client";
import {
useState,
useRef,
useId,
useCallback,
type PointerEvent as ReactPointerEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";
interface Preset {
label: string;
res: string;
fps: string;
bitrate: string;
size: string;
time: string;
note: string;
}
const PRESETS: Preset[] = [
{
label: "Draft",
res: "540p",
fps: "24 fps",
bitrate: "3 Mbps",
size: "88 MB",
time: "~40 sec",
note: "A fast, low-fidelity pass for checking cuts, pacing, and captions. Not meant for delivery.",
},
{
label: "Standard",
res: "720p",
fps: "30 fps",
bitrate: "6 Mbps",
size: "165 MB",
time: "~2 min",
note: "Balanced quality for Slack threads, internal review, and vertical social stories.",
},
{
label: "High",
res: "1080p",
fps: "30 fps",
bitrate: "12 Mbps",
size: "320 MB",
time: "~5 min",
note: "The everyday default for YouTube, landing pages, and client sign-off.",
},
{
label: "Ultra",
res: "1440p",
fps: "60 fps",
bitrate: "24 Mbps",
size: "640 MB",
time: "~11 min",
note: "Silky 60 fps motion for product demos, motion graphics, and large displays.",
},
{
label: "Cinema",
res: "2160p",
fps: "60 fps",
bitrate: "45 Mbps",
size: "1.2 GB",
time: "~24 min",
note: "A master-grade 4K export for festival submissions, color grading, and archival.",
},
];
const N = PRESETS.length;
export default function MarksSlider() {
const reduce = useReducedMotion();
const uid = useId();
const labelId = `${uid}-label`;
const descId = `${uid}-desc`;
const [index, setIndex] = useState<number>(2);
const [dragging, setDragging] = useState<boolean>(false);
const [queued, setQueued] = useState<number | null>(null);
const trackRef = useRef<HTMLDivElement>(null);
const thumbRef = useRef<HTMLDivElement>(null);
const preset = PRESETS[index];
const pct = (index / (N - 1)) * 100;
const slide = reduce
? { duration: 0 }
: { type: "spring" as const, stiffness: 380, damping: 32, mass: 0.7 };
const commit = useCallback((v: number) => {
const clamped = Math.min(N - 1, Math.max(0, v));
setIndex(clamped);
setQueued(null);
}, []);
const valueFromClientX = useCallback(
(clientX: number) => {
const el = trackRef.current;
if (!el) return index;
const rect = el.getBoundingClientRect();
const ratio = (clientX - rect.left) / rect.width;
const clamped = Math.min(1, Math.max(0, ratio));
return Math.round(clamped * (N - 1));
},
[index],
);
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
e.preventDefault();
e.currentTarget.setPointerCapture(e.pointerId);
setDragging(true);
commit(valueFromClientX(e.clientX));
thumbRef.current?.focus();
};
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!dragging) return;
commit(valueFromClientX(e.clientX));
};
const onPointerUp = (e: ReactPointerEvent<HTMLDivElement>) => {
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 "Home":
next = 0;
break;
case "End":
next = N - 1;
break;
case "PageUp":
next = index + 2;
break;
case "PageDown":
next = index - 2;
break;
default:
return;
}
e.preventDefault();
commit(next);
};
const markLeft = (i: number) => (i / (N - 1)) * 100;
const markTransform = (i: number) =>
i === 0 ? "translateX(0)" : i === N - 1 ? "translateX(-100%)" : "translateX(-50%)";
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-5 py-20 dark:bg-slate-950 sm:py-28">
<style>{`
@keyframes sldmk-float {
0%, 100% { transform: translate3d(0, 0, 0); }
50% { transform: translate3d(0, -16px, 0); }
}
@keyframes sldmk-ring {
0% { opacity: 0.45; transform: scale(1); }
100% { opacity: 0; transform: scale(2); }
}
@media (prefers-reduced-motion: reduce) {
.sldmk-blob { animation: none !important; }
.sldmk-ring { animation: none !important; }
}
`}</style>
<div
aria-hidden="true"
className="sldmk-blob 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-400/30 to-violet-500/30 blur-3xl dark:from-indigo-500/20 dark:to-violet-600/20"
style={{ animation: "sldmk-float 9s ease-in-out infinite" }}
/>
<div className="relative mx-auto max-w-2xl">
<div className="mb-8 text-center">
<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-semibold uppercase tracking-wide 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" />
Export presets
</span>
<h2 className="mt-4 text-3xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
Pick the render quality
</h2>
<p className="mx-auto mt-3 max-w-md text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Drag the handle or use the arrow keys. Every preset is tuned for a real
destination — from a quick preview to a festival-ready master.
</p>
</div>
<div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-xl shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/30 sm:p-8">
{/* Current selection */}
<div className="mb-8 flex flex-wrap items-end justify-between gap-3">
<div>
<span
id={labelId}
className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400"
>
Render quality
</span>
<div className="mt-1 flex items-baseline gap-2">
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={index}
initial={reduce ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8 }}
transition={reduce ? { duration: 0 } : { duration: 0.2 }}
className="text-2xl font-bold text-slate-900 dark:text-white"
>
{preset.label}
</motion.span>
</AnimatePresence>
<span className="text-lg font-semibold text-indigo-600 dark:text-violet-300">
{preset.res}
</span>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
{[preset.fps, preset.bitrate].map((chip) => (
<span
key={chip}
className="rounded-md bg-slate-100 px-2 py-1 text-xs font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300"
>
{chip}
</span>
))}
</div>
</div>
{/* Slider */}
<div className="px-3 sm:px-4">
<div
ref={trackRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className="relative h-10 cursor-pointer touch-none select-none"
>
{/* bar */}
<div className="absolute left-0 right-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-slate-200 dark:bg-slate-700" />
{/* fill */}
<motion.div
className="absolute left-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-gradient-to-r from-indigo-500 to-violet-500"
initial={false}
animate={{ width: `${pct}%` }}
transition={slide}
/>
{/* ticks */}
{PRESETS.map((p, i) => (
<span
key={p.label}
aria-hidden="true"
className={`absolute top-1/2 z-20 h-2.5 w-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full ring-2 ${
i <= index
? "bg-white ring-indigo-500 dark:ring-violet-400"
: "bg-slate-300 ring-transparent dark:bg-slate-600"
}`}
style={{ left: `${markLeft(i)}%` }}
/>
))}
{/* thumb */}
<motion.div
ref={thumbRef}
role="slider"
tabIndex={0}
aria-labelledby={labelId}
aria-describedby={descId}
aria-orientation="horizontal"
aria-valuemin={0}
aria-valuemax={N - 1}
aria-valuenow={index}
aria-valuetext={`${preset.label}, ${preset.res} at ${preset.fps}`}
onKeyDown={onKeyDown}
className="absolute top-1/2 z-30 flex h-6 w-6 cursor-grab items-center justify-center rounded-full border-2 border-indigo-500 bg-white shadow-lg outline-none active:cursor-grabbing focus-visible:ring-4 focus-visible:ring-indigo-500/40 dark:border-violet-400 dark:bg-slate-100"
style={{ transform: "translate(-50%, -50%)" }}
initial={false}
animate={{ left: `${pct}%` }}
transition={slide}
>
{dragging && !reduce ? (
<span
aria-hidden="true"
className="sldmk-ring absolute inset-0 rounded-full bg-indigo-500/40"
style={{ animation: "sldmk-ring 1s ease-out infinite" }}
/>
) : null}
<svg
aria-hidden="true"
viewBox="0 0 24 24"
className="h-3 w-3 text-indigo-500 dark:text-violet-500"
>
<path
d="M9 5v14M15 5v14"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
</motion.div>
</div>
{/* labelled marks */}
<div className="relative mt-3 h-8">
{PRESETS.map((p, i) => (
<button
key={p.label}
type="button"
onClick={() => commit(i)}
aria-pressed={i === index}
aria-label={`Set quality to ${p.label}, ${p.res}`}
className={`absolute top-0 whitespace-nowrap rounded-md px-1.5 py-1 text-[11px] font-medium leading-tight outline-none transition-colors focus-visible:ring-2 focus-visible:ring-indigo-500 sm:text-xs ${
i === index
? "text-indigo-600 dark:text-violet-300"
: "text-slate-500 hover:text-slate-800 dark:text-slate-400 dark:hover:text-slate-200"
}`}
style={{ left: `${markLeft(i)}%`, transform: markTransform(i) }}
>
{p.label}
</button>
))}
</div>
</div>
<p id={descId} className="sr-only">
Use the arrow keys to change quality one preset at a time. Press Home for
the lowest preset and End for the highest.
</p>
{/* Stats */}
<div className="mt-8 grid grid-cols-2 gap-3">
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-800/40">
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">
Estimated size
</span>
<div className="mt-1 text-xl font-bold text-slate-900 dark:text-white">
{preset.size}
</div>
<span className="text-xs text-slate-400 dark:text-slate-500">
for a 3-minute clip
</span>
</div>
<div className="rounded-2xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-800/40">
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">
Render time
</span>
<div className="mt-1 text-xl font-bold text-slate-900 dark:text-white">
{preset.time}
</div>
<span className="text-xs text-slate-400 dark:text-slate-500">
on an M-series laptop
</span>
</div>
</div>
{/* Note */}
<div className="mt-4 min-h-[3rem] rounded-2xl border border-indigo-100 bg-indigo-50/60 p-4 dark:border-indigo-500/20 dark:bg-indigo-500/5">
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={index}
initial={reduce ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6 }}
transition={reduce ? { duration: 0 } : { duration: 0.22 }}
className="text-sm leading-relaxed text-slate-700 dark:text-slate-300"
>
{preset.note}
</motion.p>
</AnimatePresence>
</div>
{/* Action */}
<div className="mt-6 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={() => setQueued(index)}
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 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 dark:bg-violet-600 dark:hover:bg-violet-500 dark:focus-visible:ring-offset-slate-900"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
className="h-4 w-4"
>
<path
d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Queue export at {preset.label}
</button>
<div aria-live="polite" className="min-h-[1.5rem]">
<AnimatePresence>
{queued !== null ? (
<motion.span
key={queued}
initial={reduce ? false : { opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0 }}
transition={reduce ? { duration: 0 } : { duration: 0.2 }}
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-50 px-3 py-1.5 text-sm font-medium text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-300"
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
className="h-4 w-4"
>
<path
d="m5 13 4 4L19 7"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Queued {PRESETS[queued].label} · {PRESETS[queued].res} ·{" "}
{PRESETS[queued].time}
</motion.span>
) : null}
</AnimatePresence>
</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

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

Rating Slider
Originalsatisfaction rating slider

Brightness Slider
Originalbrightness/contrast control sliders

Color Hue Slider
Originalhue/saturation color sliders

Progress Drag Slider
Originalmedia scrubber slider

