Web InnoventixFreeCode

Steps Slider

Original · free

stepped slider with tick marks

byWeb InnoventixReact + Tailwind
sldstepssliders
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-steps.json
sld-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 quote

Similar components

Browse all →