Web InnoventixFreeCode

Segmented Slider

Original · free

segmented level slider

byWeb InnoventixReact + Tailwind
sldsegmentedsliders
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-segmented.json
sld-segmented.tsx
"use client";

import {
  useId,
  useRef,
  useState,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type Level = {
  name: string;
  short: string;
  hr: string;
  cue: string;
  blurb: string;
  seg: string;
  text: string;
  softBg: string;
  softBorder: string;
  ring: string;
  glow: string;
  chip: string;
};

const LEVELS: Level[] = [
  {
    name: "Recovery",
    short: "Recovery",
    hr: "50–60%",
    cue: "Nose-breathing only",
    blurb:
      "Active rest. A pace you could hold while telling a story. This is where adaptation happens — resist the urge to push it.",
    seg: "bg-emerald-400 dark:bg-emerald-500",
    text: "text-emerald-600 dark:text-emerald-400",
    softBg: "bg-emerald-50 dark:bg-emerald-500/10",
    softBorder: "border-emerald-200 dark:border-emerald-500/30",
    ring: "border-emerald-500",
    glow: "bg-emerald-400/50 dark:bg-emerald-400/40",
    chip: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
  },
  {
    name: "Easy",
    short: "Easy",
    hr: "60–70%",
    cue: "Full sentences",
    blurb:
      "Aerobic base. Breathing is present but unhurried and you could still hold a full sentence. Most weekly volume lives here.",
    seg: "bg-sky-400 dark:bg-sky-500",
    text: "text-sky-600 dark:text-sky-400",
    softBg: "bg-sky-50 dark:bg-sky-500/10",
    softBorder: "border-sky-200 dark:border-sky-500/30",
    ring: "border-sky-500",
    glow: "bg-sky-400/50 dark:bg-sky-400/40",
    chip: "bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300",
  },
  {
    name: "Moderate",
    short: "Moderate",
    hr: "70–80%",
    cue: "Short phrases",
    blurb:
      "Tempo. Comfortably hard — the sweet spot where breathing deepens but stays controlled. Sentences shorten to phrases.",
    seg: "bg-indigo-400 dark:bg-indigo-500",
    text: "text-indigo-600 dark:text-indigo-400",
    softBg: "bg-indigo-50 dark:bg-indigo-500/10",
    softBorder: "border-indigo-200 dark:border-indigo-500/30",
    ring: "border-indigo-500",
    glow: "bg-indigo-400/50 dark:bg-indigo-400/40",
    chip: "bg-indigo-100 text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300",
  },
  {
    name: "Hard",
    short: "Hard",
    hr: "80–90%",
    cue: "Single words",
    blurb:
      "Threshold. Talking breaks down to single words between breaths. Sustainable for minutes, not hours — treat it with respect.",
    seg: "bg-amber-400 dark:bg-amber-500",
    text: "text-amber-600 dark:text-amber-400",
    softBg: "bg-amber-50 dark:bg-amber-500/10",
    softBorder: "border-amber-200 dark:border-amber-500/30",
    ring: "border-amber-500",
    glow: "bg-amber-400/50 dark:bg-amber-400/40",
    chip: "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300",
  },
  {
    name: "Max Effort",
    short: "Max",
    hr: "90–100%",
    cue: "No talking",
    blurb:
      "All out. Unsustainable past 60–90 seconds and no words come out. Reserve it for intervals and the final push.",
    seg: "bg-rose-400 dark:bg-rose-500",
    text: "text-rose-600 dark:text-rose-400",
    softBg: "bg-rose-50 dark:bg-rose-500/10",
    softBorder: "border-rose-200 dark:border-rose-500/30",
    ring: "border-rose-500",
    glow: "bg-rose-400/50 dark:bg-rose-400/40",
    chip: "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
  },
];

export default function SldSegmented() {
  const [value, setValue] = useState<number>(1);
  const [dragging, setDragging] = useState<boolean>(false);
  const trackRef = useRef<HTMLDivElement>(null);
  const reduce = useReducedMotion();
  const labelId = useId();

  const max = LEVELS.length - 1;
  const active = LEVELS[value];
  const thumbPct = ((value + 0.5) / LEVELS.length) * 100;
  const valueText = `Zone ${value + 1} of ${LEVELS.length}: ${active.name}, ${active.hr} of max heart rate`;

  function valueFromClientX(clientX: number): number {
    const el = trackRef.current;
    if (!el) return value;
    const rect = el.getBoundingClientRect();
    const ratio = rect.width === 0 ? 0 : (clientX - rect.left) / rect.width;
    const idx = Math.floor(ratio * LEVELS.length);
    return Math.min(max, Math.max(0, idx));
  }

  function handlePointerDown(e: ReactPointerEvent<HTMLDivElement>): void {
    e.currentTarget.setPointerCapture(e.pointerId);
    setDragging(true);
    setValue(valueFromClientX(e.clientX));
  }

  function handlePointerMove(e: ReactPointerEvent<HTMLDivElement>): void {
    if (!dragging) return;
    setValue(valueFromClientX(e.clientX));
  }

  function handlePointerUp(e: ReactPointerEvent<HTMLDivElement>): void {
    setDragging(false);
    if (e.currentTarget.hasPointerCapture(e.pointerId)) {
      e.currentTarget.releasePointerCapture(e.pointerId);
    }
  }

  function handleKeyDown(e: ReactKeyboardEvent<HTMLDivElement>): void {
    let next = value;
    switch (e.key) {
      case "ArrowRight":
      case "ArrowUp":
        next = Math.min(max, value + 1);
        break;
      case "ArrowLeft":
      case "ArrowDown":
        next = Math.max(0, value - 1);
        break;
      case "PageUp":
        next = Math.min(max, value + 2);
        break;
      case "PageDown":
        next = Math.max(0, value - 2);
        break;
      case "Home":
        next = 0;
        break;
      case "End":
        next = max;
        break;
      default:
        return;
    }
    e.preventDefault();
    setValue(next);
  }

  return (
    <section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 to-white px-4 py-16 text-slate-900 dark:from-slate-950 dark:to-slate-900 dark:text-slate-100 sm:px-6 sm:py-24">
      <style>{`
        @keyframes sldseg-halo {
          0%, 100% { transform: scale(1); opacity: 0.45; }
          50% { transform: scale(1.55); opacity: 0.05; }
        }
        .sldseg-halo { animation: sldseg-halo 2.8s ease-in-out infinite; }
        @media (prefers-reduced-motion: reduce) {
          .sldseg-halo { animation: none !important; }
        }
      `}</style>

      <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-indigo-300/20 blur-3xl dark:bg-indigo-500/10"
      />

      <div className="relative mx-auto w-full max-w-2xl">
        <div className="rounded-3xl border border-slate-200 bg-white/80 p-6 shadow-xl shadow-slate-900/5 backdrop-blur-sm dark:border-slate-800 dark:bg-slate-900/70 sm:p-9">
          {/* Header */}
          <div className="flex items-center gap-2">
            <span className="inline-flex h-8 w-8 items-center justify-center rounded-full bg-slate-900 text-white dark:bg-white dark:text-slate-900">
              <svg
                viewBox="0 0 24 24"
                fill="none"
                stroke="currentColor"
                strokeWidth={2}
                strokeLinecap="round"
                strokeLinejoin="round"
                className="h-4 w-4"
                aria-hidden="true"
              >
                <path d="M3 12h4l2 5 4-14 2 9h6" />
              </svg>
            </span>
            <span className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500 dark:text-slate-400">
              Training load
            </span>
          </div>

          <h2
            id={labelId}
            className="mt-4 text-2xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-3xl"
          >
            How hard are we going today?
          </h2>
          <p className="mt-2 text-sm text-slate-600 dark:text-slate-400">
            Choose an effort zone. Your heart-rate target and pace cue update instantly.
          </p>

          {/* Active display */}
          <div
            className={`mt-7 rounded-2xl border p-5 transition-colors duration-300 ${active.softBg} ${active.softBorder}`}
          >
            <AnimatePresence mode="wait">
              <motion.div
                key={value}
                initial={reduce ? false : { opacity: 0, y: 8 }}
                animate={{ opacity: 1, y: 0 }}
                exit={reduce ? { opacity: 1 } : { opacity: 0, y: -8 }}
                transition={{ duration: reduce ? 0 : 0.26, ease: "easeOut" }}
              >
                <div className="flex flex-wrap items-center justify-between gap-3">
                  <div className="flex items-baseline gap-2">
                    <span className={`text-2xl font-bold tracking-tight ${active.text}`}>
                      {active.name}
                    </span>
                    <span className="text-sm font-medium text-slate-500 dark:text-slate-400">
                      Zone {value + 1}
                    </span>
                  </div>
                  <div className="flex items-center gap-2">
                    <span
                      className={`rounded-full px-2.5 py-1 text-xs font-semibold ${active.chip}`}
                    >
                      {active.hr} max HR
                    </span>
                    <span className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 dark:bg-slate-800 dark:text-slate-300">
                      {active.cue}
                    </span>
                  </div>
                </div>
                <p className="mt-3 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
                  {active.blurb}
                </p>
              </motion.div>
            </AnimatePresence>
          </div>

          {/* Slider */}
          <div
            role="slider"
            tabIndex={0}
            aria-labelledby={labelId}
            aria-valuemin={1}
            aria-valuemax={LEVELS.length}
            aria-valuenow={value + 1}
            aria-valuetext={valueText}
            onPointerDown={handlePointerDown}
            onPointerMove={handlePointerMove}
            onPointerUp={handlePointerUp}
            onPointerCancel={handlePointerUp}
            onKeyDown={handleKeyDown}
            className="group mt-8 cursor-pointer touch-none select-none rounded-2xl px-1 py-4 outline-none focus-visible:ring-2 focus-visible:ring-slate-900 focus-visible:ring-offset-4 focus-visible:ring-offset-white dark:focus-visible:ring-white dark:focus-visible:ring-offset-slate-900"
          >
            <div ref={trackRef} className="relative">
              {/* Base + segmented fill */}
              <div className="flex h-3.5 gap-1.5 rounded-full">
                {LEVELS.map((lvl, i) => (
                  <div
                    key={lvl.name}
                    className={`h-full flex-1 rounded-full transition-colors duration-300 ${
                      i <= value ? lvl.seg : "bg-slate-200 dark:bg-slate-800"
                    }`}
                  />
                ))}
              </div>

              {/* Thumb */}
              <motion.div
                className="pointer-events-none absolute top-1/2 -translate-x-1/2 -translate-y-1/2"
                animate={{ left: `${thumbPct}%` }}
                transition={
                  reduce ? { duration: 0 } : { type: "spring", stiffness: 480, damping: 30 }
                }
              >
                <span
                  className={`absolute inset-0 -z-10 m-auto h-6 w-6 rounded-full ${active.glow} ${
                    dragging ? "" : "sldseg-halo"
                  }`}
                />
                <span
                  className={`flex h-7 w-7 items-center justify-center rounded-full border-[3px] bg-white text-[11px] font-bold text-slate-800 shadow-md transition-transform duration-150 dark:bg-slate-950 dark:text-slate-100 ${active.ring} ${
                    dragging ? "scale-110" : "group-hover:scale-105"
                  }`}
                >
                  {value + 1}
                </span>
              </motion.div>
            </div>

            {/* Zone ticks */}
            <div className="mt-4 grid grid-cols-5 gap-1.5">
              {LEVELS.map((lvl, i) => (
                <button
                  key={lvl.name}
                  type="button"
                  tabIndex={-1}
                  aria-hidden="true"
                  onClick={() => setValue(i)}
                  onPointerDown={(e) => e.stopPropagation()}
                  className="flex flex-col items-center gap-1 text-center"
                >
                  <span
                    className={`h-1.5 w-1.5 rounded-full transition-colors duration-200 ${
                      i === value ? lvl.seg : "bg-slate-300 dark:bg-slate-700"
                    }`}
                  />
                  <span
                    className={`text-[11px] font-medium transition-colors duration-200 ${
                      i === value
                        ? `${lvl.text} font-semibold`
                        : "text-slate-400 dark:text-slate-500"
                    }`}
                  >
                    {lvl.short}
                  </span>
                </button>
              ))}
            </div>
          </div>

          {/* Footer */}
          <div className="mt-7 flex flex-col gap-3 border-t border-slate-200 pt-5 text-sm dark:border-slate-800 sm:flex-row sm:items-center sm:justify-between">
            <p className="text-slate-500 dark:text-slate-400">
              Drag the dial, tap a zone, or nudge with the{" "}
              <kbd className="rounded border border-slate-300 bg-slate-100 px-1.5 py-0.5 text-[11px] font-medium text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
                ←
              </kbd>{" "}
              <kbd className="rounded border border-slate-300 bg-slate-100 px-1.5 py-0.5 text-[11px] font-medium text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300">
                →
              </kbd>{" "}
              keys.
            </p>
            <span className="shrink-0 font-medium text-slate-700 dark:text-slate-200">
              Zone {value + 1} / {LEVELS.length}
            </span>
          </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 →