# Slider Stepped Labels — React Slider Component

> A discrete slider with labelled stops: the thumb follows your finger freely, then springs onto the nearest stop while its label lights up.

- Category: [Sliders](https://pixelpapa.com/components/sliders.md)
- Tags: slider, steps, labels, snap, discrete
- Dependencies: motion (plus React and Tailwind CSS v4)
- Last updated: 2026-11-13
- Live preview: https://pixelpapa.com/components/sliders/slider-stepped-labels

## Install

```bash
npx shadcn@latest add "https://pixelpapa.com/r/slider-stepped-labels.json"
```

The file is written to `components/pixelpapa/slider-stepped-labels.tsx`. To install manually, run `npm install motion` and copy the source below into your project. It imports `cn` from `@/lib/utils`, which every shadcn project has.

## Source

```tsx
"use client";

import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
import { cn } from "@/lib/utils";

export type Step = { value: string; label: string };

type SteppedSliderProps = {
  steps: Step[];
  value?: string;
  defaultValue?: string;
  onChange?: (value: string) => void;
  label: string;
  className?: string;
};

export function SteppedSlider({ steps, value, defaultValue, onChange, label, className }: SteppedSliderProps) {
  const [inner, setInner] = useState(defaultValue ?? steps[0]?.value);
  const [drag, setDrag] = useState<number | null>(null); // Raw 0–1 position while dragging.
  const track = useRef<HTMLDivElement>(null);
  const thumb = useRef<HTMLDivElement>(null);
  const reduce = useReducedMotion();
  const current = value ?? inner;
  const last = steps.length - 1;
  const index = Math.max(0, steps.findIndex((s) => s.value === current));
  const position = drag ?? index / last;

  function set(i: number) {
    const next = steps[Math.min(last, Math.max(0, i))].value;
    if (next === current) return;
    if (value === undefined) setInner(next);
    onChange?.(next);
  }

  function follow(e: PointerEvent<HTMLDivElement>) {
    const rect = track.current?.getBoundingClientRect();
    if (!rect) return;
    const p = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
    setDrag(p);
    set(Math.round(p * last));
  }

  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    const moves: Record<string, number> = {
      ArrowRight: index + 1,
      ArrowUp: index + 1,
      ArrowLeft: index - 1,
      ArrowDown: index - 1,
      Home: 0,
      End: last,
    };
    if (e.key in moves) {
      e.preventDefault();
      set(moves[e.key]);
    }
  }

  // No spring while the finger is down; a bouncy one for the snap on release.
  const transition = drag !== null || reduce ? { duration: 0 } : ({ type: "spring", stiffness: 420, damping: 24 } as const);

  return (
    <div className={cn("w-full select-none", className)}>
      <div
        className="relative flex h-7 cursor-pointer touch-none items-center px-3"
        onPointerDown={(e) => {
          e.currentTarget.setPointerCapture(e.pointerId);
          follow(e);
          thumb.current?.focus();
        }}
        onPointerMove={(e) => drag !== null && follow(e)}
        onPointerUp={() => setDrag(null)}
        onPointerCancel={() => setDrag(null)}
      >
        <div ref={track} className="relative h-1.5 w-full rounded-full bg-muted">
          <motion.div
            className="absolute inset-y-0 left-0 rounded-full bg-primary"
            initial={false}
            animate={{ width: `${position * 100}%` }}
            transition={transition}
          />
          {steps.map((s, i) => (
            <span
              key={s.value}
              aria-hidden
              className={cn(
                "absolute top-1/2 size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full transition-colors",
                i / last <= position ? "bg-primary-foreground/80" : "bg-muted-foreground/40",
              )}
              style={{ left: `${(i / last) * 100}%` }}
            />
          ))}
          <motion.div
            className="absolute top-1/2"
            initial={false}
            animate={{ left: `${position * 100}%` }}
            transition={transition}
          >
            <motion.div
              ref={thumb}
              role="slider"
              tabIndex={0}
              aria-label={label}
              aria-valuemin={0}
              aria-valuemax={last}
              aria-valuenow={index}
              aria-valuetext={steps[index]?.label}
              onKeyDown={onKeyDown}
              animate={{ scale: drag !== null ? 1.2 : 1 }}
              className="-mt-3 -ml-3 size-6 rounded-full border-2 border-primary bg-background shadow-md outline-offset-4"
            />
          </motion.div>
        </div>
      </div>
      <div className="relative mt-2 h-5 px-3">
        <div className="relative">
          {steps.map((s, i) => (
            <button
              key={s.value}
              type="button"
              tabIndex={-1}
              onClick={() => set(i)}
              className={cn(
                "absolute top-0 -translate-x-1/2 text-xs whitespace-nowrap transition-colors duration-200",
                i === index ? "font-semibold text-foreground" : "text-muted-foreground hover:text-foreground",
              )}
              style={{ left: `${(i / last) * 100}%` }}
            >
              {s.label}
            </button>
          ))}
        </div>
      </div>
    </div>
  );
}

const qualities = [
  { value: "draft", label: "Draft", size: "0.4 MB", note: "Fast previews. Visible banding in gradients." },
  { value: "web", label: "Web", size: "1.1 MB", note: "Good for sharing links and email." },
  { value: "high", label: "High", size: "2.8 MB", note: "Crisp on retina screens. Our default." },
  { value: "print", label: "Print", size: "9.6 MB", note: "300 dpi with full colour profile." },
  { value: "lossless", label: "Lossless", size: "24 MB", note: "Every pixel preserved, no compression." },
];

export default function SliderSteppedLabelsDemo() {
  const [quality, setQuality] = useState("high");
  const active = qualities.find((q) => q.value === quality) ?? qualities[0];

  return (
    <div className="w-full max-w-md rounded-2xl border border-border bg-card p-6 shadow-sm">
      <div className="flex items-start justify-between gap-4">
        <div>
          <h3 className="text-lg font-semibold tracking-tight text-card-foreground">Export quality</h3>
          <div className="relative mt-1 h-5 overflow-hidden text-sm text-muted-foreground">
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.p
                key={active.value}
                initial={{ y: 14, opacity: 0 }}
                animate={{ y: 0, opacity: 1 }}
                exit={{ y: -14, opacity: 0 }}
                transition={{ type: "spring", stiffness: 420, damping: 32 }}
                className="leading-5 whitespace-nowrap"
              >
                {active.note}
              </motion.p>
            </AnimatePresence>
          </div>
        </div>
        <span className="shrink-0 rounded-lg bg-muted px-2.5 py-1 text-sm font-semibold text-foreground tabular-nums">
          {active.size}
        </span>
      </div>
      <SteppedSlider className="mt-7" label="Export quality" steps={qualities} value={quality} onChange={setQuality} />
      <button type="button" className="mt-6 h-10 w-full rounded-xl bg-primary text-sm font-medium text-primary-foreground">
        Export as {active.label}
      </button>
    </div>
  );
}
```

## AI prompt

```text
Add the "Slider Stepped Labels" component from PixelPapa UI to this project.

What it is: A discrete slider with labelled stops: the thumb follows your finger freely, then springs onto the nearest stop while its label lights up.
Category: Sliders. Depends on: motion.

1. Install it with the shadcn CLI:
   npx shadcn@latest add "https://pixelpapa.com/r/slider-stepped-labels.json"
   (If the CLI is unavailable, fetch that URL, write files[0].content to components/pixelpapa/slider-stepped-labels.tsx and install the listed dependencies.)
2. Import it from "@/components/pixelpapa/slider-stepped-labels" and place it where I describe below.
3. Keep its animation timings and accessibility attributes. Replace the demo copy and data with content that fits my product, and use my existing theme tokens (bg-background, text-foreground, bg-primary, border-border) rather than hard-coded colours.

Where to use it: <describe the page and position here>
```

## More sliders

- [Slider Circular Dial](https://pixelpapa.com/components/sliders/slider-circular-dial.md) (Pro): A thermostat-style dial: drag the knob around a 270° arc and the gradient track, knob and reading follow on a spring; presets sweep it to a new value.
- [Slider Range Dual](https://pixelpapa.com/components/sliders/slider-range-dual.md) (Pro): A two-thumb range slider under a histogram whose bars light up inside the selection; thumbs keep a minimum gap and the nearest one follows a track press.
- [Slider Ruler Ticks](https://pixelpapa.com/components/sliders/slider-ruler-ticks.md) (Pro): A ruler you drag or flick under a fixed needle; it coasts with inertia, snaps to the nearest tick and shows the reading large in the centre.
- [Slider Spring Thumb](https://pixelpapa.com/components/sliders/slider-spring-thumb.md): A slider whose value bubble trails the thumb on a loose spring and leans into the direction of travel, then settles upright.
- [Slider Volume Vertical](https://pixelpapa.com/components/sliders/slider-volume-vertical.md): A tall pill you drag up and down like a phone volume control; the fill follows on a spring, the pill stretches when you pull past either end, and M mutes.
