Web InnoventixFreeCode

Marks Slider

Original · free

slider with labelled marks

byWeb InnoventixReact + Tailwind
sldmarkssliders
Open

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.json
sld-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 &mdash; 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} &middot; {PRESETS[queued].res} &middot;{" "}
                    {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 quote

Similar components

Browse all →