FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Slider

Slider

pnpm dlx shadcn@latest add …/r/slider.json

An accessible React slider for Next.js and Tailwind: one value or a range, with a thumb that grows into a pill showing its value as you reach for it.

  • 60%
    Single
  • $200$750
    Range
import { Slider } from "@/components/ui/slider"

export function Volume() {
  return <Slider aria-label="Volume" defaultValue={[60]} max={100} step={1} />
}
single-and-range.tsx
"use client";

import { Slider } from "@/components/ui/slider";

const percent = { style: "unit", unit: "percent" } as const;
const dollars = { style: "currency", currency: "USD", maximumFractionDigits: 0 } as const;

/**
 * One value and a range side by side, named underneath. Reach for a thumb (hover, Tab, drag) and it grows into
 * a pill with its value inside, flowing as it changes.
 */
export default function SliderSingleAndRange() {
  return (
    <ul className="flex w-full flex-wrap items-center justify-center gap-x-12 gap-y-10 pt-6">
      <li className="flex w-full max-w-48 flex-col items-center gap-4">
        <Slider aria-label="Volume" defaultValue={[60]} format={percent} />
        <span className="text-xs text-muted-foreground">Single</span>
      </li>
      <li className="flex w-full max-w-48 flex-col items-center gap-4">
        <Slider
          aria-label="Price"
          defaultValue={[200, 750]}
          max={1000}
          step={10}
          minStepsBetweenThumbs={5}
          format={dollars}
        />
        <span className="text-xs text-muted-foreground">Range</span>
      </li>
    </ul>
  );
}

Installation

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add motion
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :root {
      --size-control-sm: 2rem;
      --size-control-md: 2.25rem;
      --size-control-lg: 2.5rem;
      --size-icon-sm: 0.875rem;
      --size-icon-md: 1rem;
      --size-icon-lg: 1.25rem;
      --size-roll-fade: 0.25em;
      --size-roll-fade-x: 0.5em;
      --duration-instant: 80ms;
      --duration-fast: 150ms;
      --duration-base: 220ms;
      --duration-slow: 350ms;
      --duration-slower: 500ms;
      --z-overlay: 1200;
      --z-modal: 1300;
      --z-popover: 1400;
      --z-toast: 1500;
      --z-tooltip: 1600;
      --focus-ring-width: 2px;
      --focus-ring-offset: 2px;
      --opacity-disabled: 0.5;
      --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1);
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent);
    }
    
    .dark {
      --destructive-foreground: oklch(1 0 0);
      --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent);
    }
    
    @theme inline {
      --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both;
      --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both;
      --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1);
      --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both;
      --color-destructive-foreground: var(--destructive-foreground);
      --color-overlay: var(--overlay);
      @keyframes popover-in {
        from {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
        to {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
      }
      @keyframes popover-out {
        from {
          opacity: 1;
          filter: blur(0);
          transform: scale(1);
        }
        to {
          opacity: 0;
          filter: blur(6px);
          transform: scale(0.96);
        }
      }
      @keyframes collapsible-open {
        from {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
        to {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
      }
      @keyframes collapsible-close {
        from {
          height: var(--collapsible-height);
          opacity: 1;
          filter: blur(0);
        }
        to {
          height: 0;
          opacity: 0;
          filter: blur(6px);
        }
      }
    }
    
    @media (prefers-reduced-motion: reduce) {
      :root {
        --duration-instant: 1ms;
        --duration-fast: 1ms;
        --duration-base: 1ms;
        --duration-slow: 1ms;
        --duration-slower: 1ms;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    import type { Transition } from "motion/react";
    
    /*
     * Motion tokens for JavaScript animations (motion/react).
     * Durations and easings mirror the CSS tokens in src/styles/tokens.css;
     * `pnpm check:tokens` fails if the two drift apart.
     */
    
    /** Durations in seconds. Mirrors --duration-* in tokens.css. */
    export const duration = {
      instant: 0.08,
      fast: 0.15,
      base: 0.22,
      slow: 0.35,
      slower: 0.5,
    } as const;
    
    /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */
    export const ease = {
      out: [0.22, 1, 0.36, 1],
      in: [0.55, 0, 1, 0.45],
      inOut: [0.65, 0, 0.35, 1],
    } as const;
    
    /**
     * Named springs: the library's motion voice.
     * - snappy: quick, almost no overshoot. Toggles, tabs, small controls.
     * - smooth: slow and settled. Panels, sidebars, page sections.
     * - bouncy: playful overshoot. Rare, for moments of delight.
     */
    export const spring = {
      snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 },
      smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 },
      bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 },
    } as const satisfies Record<string, Transition>;
    
    export type SpringName = keyof typeof spring;
    
    /**
     * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it
     * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills,
     * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only.
     * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`).
     */
    export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const;
    
    /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */
    export const swap = {
      initial: { opacity: 0, filter: blur.hidden, y: 6 },
      animate: { opacity: 1, filter: blur.shown, y: 0 },
      exit: { opacity: 0, filter: blur.hidden, y: -6 },
    } as const;
    
    /**
     * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits'
     * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in
     * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in
     * components/number-ticker/number-ticker.tsx).
     */
    export const flow = {
      transform: {
        duration: 900,
        easing:
          "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)",
      },
      opacity: { duration: 450, easing: "ease-out" },
    } as const satisfies Record<string, EffectTiming>;
  4. 4

    Add Number Ticker first.

  5. 5

    Copy the source into your project.

    components/ui/slider.tsx
    "use client";
    
    import { NumberTicker } from "@/components/ui/number-ticker";
    import { blur, duration, ease, spring } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import {
      type AnimationPlaybackControls,
      animate,
      type MotionValue,
      motion,
      motionValue,
      useMotionValue,
      useReducedMotion,
      useTransform,
      useVelocity,
    } from "motion/react";
    import {
      type ComponentProps,
      type KeyboardEvent,
      type PointerEvent,
      useEffect,
      useId,
      useLayoutEffect,
      useMemo,
      useRef,
      useState,
    } from "react";
    
    /*
     * Slider: one value or a range (owner, 2026-10-16), redesigned as the value thumb the owner chose in the Lab
     * (2026-10-22), ported from it exactly: the same drawing, sizes and timings. The WAI-ARIA slider pattern, our own
     * rather than Radix's, because Radix places its thumbs itself and they didn't move like the Lab's.
     * - A thin track and a small round thumb that, the moment you reach for it (the pointer over the slider, keyboard
     *   focus, a drag), grows into a pill with the value inside. The number is the thumb, so there's no tooltip to
     *   float or follow; back at rest it shrinks to a dot. The value flows as it changes (our Number Ticker), and a
     *   quick drag counts through the values.
     * - Motion (.agents/rules/motion.md):
     *   - Press the track and the thumb glides to the point on spring.snappy; the keys and outside changes glide the
     *     same way. Dragging follows the pointer exactly.
     *   - Moving, the pill stretches along the track with its speed (up to a quarter, full at 900px/s) and settles
     *     round when it stops.
     *   - The pill grows and shrinks on spring.snappy; its number comes through the blur (duration.base in,
     *     duration.fast out).
     *   - The track thickens from 4px to 6px while a thumb is open.
     * - Behaviour: each thumb is a `role="slider"` with arrow keys (Shift for ten steps), Page Up/Down (ten steps),
     *   Home/End. A press on the track moves the nearest thumb there and focuses it; a range's thumbs can't cross.
     *   Vertical, right to left and inverted. With `name`, a hidden input per value submits it with a form.
     * - Nothing animates on first paint. With reduced motion the thumb jumps, doesn't stretch, the track keeps its
     *   thickness and the number only fades.
     */
    
    /** The thumb at rest, and grown into a pill (at least this wide; wider for a longer value), in px */
    const DOT = 14;
    const PILL = { width: 46, height: 26 };
    /** The room either side of the value in the pill, in px */
    const PILL_PAD = 10;
    /** Moving, the pill stretches along the track by up to a quarter, full at this speed in px a second */
    const STRETCH = 0.25;
    const FULL_SPEED = 900;
    /** While a thumb is open, the track thickens from 4px to 6px */
    const THICK = 1.5;
    /** Shift with an arrow, and Page Up/Down, move this many steps */
    const BIG_STEP = 10;
    
    /** How the last focus came: the keyboard opens a thumb's pill on focus, a pointer doesn't */
    let keyboardLast = false;
    let listening = false;
    function watchModality() {
      if (listening || typeof window === "undefined") return;
      listening = true;
      window.addEventListener("keydown", () => (keyboardLast = true), true);
      window.addEventListener("pointerdown", () => (keyboardLast = false), true);
    }
    
    export type SliderProps = Omit<ComponentProps<"div">, "defaultValue" | "dir" | "onChange"> & {
      /** The values, when you control them: one number per thumb, two for a range */
      value?: number[];
      /** The values at first */
      defaultValue?: number[];
      /** Called on every change, while dragging too */
      onValueChange?: (value: number[]) => void;
      /** Called once a drag ends or a key changes the value */
      onValueCommit?: (value: number[]) => void;
      min?: number;
      max?: number;
      step?: number;
      /** How many steps a range's thumbs keep apart */
      minStepsBetweenThumbs?: number;
      orientation?: "horizontal" | "vertical";
      /** Right to left puts the lowest value on the right */
      dir?: "ltr" | "rtl";
      /** Puts the lowest value at the other end */
      inverted?: boolean;
      disabled?: boolean;
      /** Submits the values with a form, through a hidden input per thumb */
      name?: string;
      form?: string;
      /** Intl.NumberFormat options for the value in the thumb and for screen readers, for example { style: "currency", currency: "USD" } */
      format?: Intl.NumberFormatOptions;
      /** The locale to format in. en-US unless you pass one, so the server and the browser agree */
      locale?: string;
      /** A name for each thumb. Without it, a range's thumbs are named from aria-label: "Price, minimum". */
      thumbLabels?: string[];
    };
    
    /**
     * Pick a value, or a range with two values, by dragging, pressing the track or with the keys. `value` and
     * `defaultValue` are arrays: one number for one thumb, two for a range. Name it with aria-label or
     * aria-labelledby; the name moves to the thumbs, which are the sliders screen readers see.
     */
    function Slider({
      className,
      value,
      defaultValue,
      onValueChange,
      onValueCommit,
      min = 0,
      max = 100,
      step = 1,
      minStepsBetweenThumbs = 0,
      orientation = "horizontal",
      dir = "ltr",
      inverted = false,
      disabled = false,
      name,
      form,
      format,
      locale = "en-US",
      thumbLabels,
      "aria-label": ariaLabel,
      "aria-labelledby": ariaLabelledby,
      onPointerEnter,
      onPointerLeave,
      onPointerDown,
      onPointerMove,
      onPointerUp,
      onPointerCancel,
      ref,
      ...props
    }: SliderProps) {
      const [uncontrolled, setUncontrolled] = useState(defaultValue ?? [min]);
      const values = value ?? uncontrolled;
      const count = values.length;
      const reduce = useReducedMotion() ?? false;
      const vertical = orientation === "vertical";
      // Drawn from the far end: vertical sliders grow upwards, right-to-left or inverted ones the other way
      const flip = vertical ? !inverted : (dir === "rtl") !== inverted;
      const thumbId = useId();
      const formatKey = JSON.stringify(format ?? {});
      // biome-ignore lint/correctness/useExhaustiveDependencies: format is compared through formatKey
      const formatter = useMemo(() => new Intl.NumberFormat(locale, format), [locale, formatKey]);
    
      const root = useRef<HTMLDivElement>(null);
      const thumbs = useRef<(HTMLSpanElement | null)[]>([]);
      // The track's length along its axis, as a motion value so the drawing always uses the latest measure
      const size = useMotionValue(256);
      const [hovered, setHovered] = useState(false);
      const [focused, setFocused] = useState<{ index: number; keyboard: boolean } | null>(null);
      // The thumb being moved by the pointer, and whether the pointer has moved since pressing (a drag)
      const [active, setActive] = useState<{ index: number; dragging: boolean } | null>(null);
    
      useEffect(watchModality, []);
    
      useEffect(() => {
        const node = root.current;
        if (!node) return;
        const measure = () => size.set(vertical ? node.offsetHeight : node.offsetWidth);
        measure();
        const observer = new ResizeObserver(measure);
        observer.observe(node);
        return () => observer.disconnect();
      }, [size, vertical]);
    
      const progressOf = (number: number) =>
        max === min ? 0 : Math.min(1, Math.max(0, (number - min) / (max - min)));
      const places = String(step).split(".")[1]?.length ?? 0;
      const snap = (number: number) => Number((Math.round((number - min) / step) * step + min).toFixed(places));
    
      /** Sets one thumb's value, inside the range and clear of its neighbours */
      const setThumb = (index: number, wanted: number, commit = false) => {
        const gap = minStepsBetweenThumbs * step;
        const lower = index > 0 ? (values[index - 1] ?? min) + gap : min;
        const upper = index < count - 1 ? (values[index + 1] ?? max) - gap : max;
        const next = Math.min(upper, Math.max(lower, snap(Math.min(max, Math.max(min, wanted)))));
        if (next === values[index]) {
          if (commit) onValueCommit?.(values);
          return;
        }
        const all = values.map((current, at) => (at === index ? next : current));
        if (value === undefined) setUncontrolled(all);
        onValueChange?.(all);
        if (commit) onValueCommit?.(all);
      };
    
      // Where each thumb is drawn, 0 to 1 along the axis: it glides to a pressed point or a key's step on
      // spring.snappy, and follows at once while dragged. Nothing moves on first paint.
      // biome-ignore lint/correctness/useExhaustiveDependencies: one motion value per thumb, made when the count changes
      const drawn = useMemo(() => values.map((current) => motionValue(progressOf(current))), [count]);
      const glides = useRef(new Map<number, AnimationPlaybackControls>());
      const progresses = values.map(progressOf);
      const progressKey = progresses.join(",");
      // biome-ignore lint/correctness/useExhaustiveDependencies: only a change of the values or the drag moves them
      useEffect(() => {
        progresses.forEach((target, index) => {
          const mark = drawn[index];
          if (!mark || mark.get() === target) return;
          glides.current.get(index)?.stop();
          if (reduce || (active?.index === index && active.dragging)) {
            mark.jump(target);
            return;
          }
          glides.current.set(index, animate(mark, target, spring.snappy));
        });
      }, [progressKey, active?.dragging, reduce, drawn]);
      useEffect(() => {
        const running = glides.current;
        return () => {
          for (const glide of running.values()) glide.stop();
        };
      }, []);
    
      /** Where along the track (0 to 1, from the lowest value's end) the pointer is */
      const fractionAt = (event: PointerEvent<HTMLElement>) => {
        const box = event.currentTarget.getBoundingClientRect();
        const length = vertical ? box.height : box.width;
        if (length <= 0) return 0;
        const along = vertical ? (event.clientY - box.top) / length : (event.clientX - box.left) / length;
        const clamped = Math.min(1, Math.max(0, along));
        return flip ? 1 - clamped : clamped;
      };
      const valueAt = (fraction: number) => min + fraction * (max - min);
    
      const onKeyDown = (index: number) => (event: KeyboardEvent) => {
        if (disabled) return;
        const current = values[index] ?? min;
        const big = step * BIG_STEP;
        // Left and right follow the page's direction (and inverted); up and down always mean more and less
        const forwards = (dir === "rtl") !== inverted ? -1 : 1;
        const by = (amount: number) => (event.shiftKey ? big : step) * amount;
        const moves: Record<string, number> = {
          ArrowRight: by(vertical ? 1 : forwards),
          ArrowLeft: by(vertical ? -1 : -forwards),
          ArrowUp: by(1),
          ArrowDown: by(-1),
          PageUp: big,
          PageDown: -big,
        };
        if (event.key in moves) setThumb(index, current + (moves[event.key] ?? 0), true);
        else if (event.key === "Home") setThumb(index, min, true);
        else if (event.key === "End") setThumb(index, max, true);
        else return;
        event.preventDefault();
      };
    
      /** Each thumb's accessible name, from thumbLabels or the slider's own name */
      const nameOf = (index: number) => {
        const own = thumbLabels?.[index];
        if (own) return { "aria-label": own };
        if (count === 1) return { "aria-label": ariaLabel, "aria-labelledby": ariaLabelledby };
        const part = count === 2 ? (index === 0 ? "minimum" : "maximum") : `value ${index + 1} of ${count}`;
        const id = `${thumbId}-${index}`;
        // "Price minimum": the label's text, then the thumb's own part
        if (ariaLabelledby) return { id, "aria-label": part, "aria-labelledby": `${ariaLabelledby} ${id}` };
        if (ariaLabel) return { "aria-label": `${ariaLabel}, ${part}` };
        return {};
      };
    
      const isOpen = (index: number) =>
        !disabled &&
        (hovered ||
          (focused?.index === index && focused.keyboard) ||
          (active?.index === index && active.dragging));
      const anyOpen = values.some((_, index) => isOpen(index));
    
      // The range: the whole track's fill, moved to the lower thumb and scaled to reach the upper one
      const low = count > 1 ? drawn[0] : undefined;
      const high = drawn[count - 1];
      const zero = useMotionValue(0);
      // In percent of the fill's own (full) length, so the server's HTML already draws it in place
      const fillStart = useTransform(low ?? zero, (p) => `${(flip ? -1 : 1) * p * 100}%`);
      const fillLength = useTransform([low ?? zero, high ?? zero], ([from = 0, to = 0]: number[]) =>
        Math.max(0, to - from),
      );
    
      return (
        <div
          ref={(node) => {
            root.current = node;
            if (typeof ref === "function") ref(node);
            else if (ref) ref.current = node;
          }}
          data-slot="slider"
          data-orientation={orientation}
          data-disabled={disabled || undefined}
          className={cn(
            "relative touch-none select-none",
            vertical ? "h-full min-h-44 w-11" : "h-11 w-full",
            disabled ? "cursor-not-allowed opacity-(--opacity-disabled)" : "cursor-pointer",
            className,
          )}
          onPointerEnter={(event) => {
            onPointerEnter?.(event);
            if (event.pointerType === "mouse") setHovered(true);
          }}
          onPointerLeave={(event) => {
            onPointerLeave?.(event);
            setHovered(false);
          }}
          onPointerDown={(event) => {
            onPointerDown?.(event);
            if (event.defaultPrevented || disabled || event.button !== 0) return;
            event.currentTarget.setPointerCapture?.(event.pointerId);
            const wanted = valueAt(fractionAt(event));
            // The nearest thumb moves; between two equally near, the one on the side pressed
            let index = 0;
            values.forEach((current, at) => {
              const best = Math.abs((values[index] ?? 0) - wanted);
              const here = Math.abs(current - wanted);
              if (here < best || (here === best && wanted > current)) index = at;
            });
            setActive({ index, dragging: false });
            setThumb(index, wanted);
            thumbs.current[index]?.focus({ preventScroll: true });
            event.preventDefault();
          }}
          onPointerMove={(event) => {
            onPointerMove?.(event);
            if (!active) return;
            if (!active.dragging) setActive({ index: active.index, dragging: true });
            setThumb(active.index, valueAt(fractionAt(event)));
          }}
          onPointerUp={(event) => {
            onPointerUp?.(event);
            if (active) onValueCommit?.(values);
            setActive(null);
          }}
          onPointerCancel={(event) => {
            onPointerCancel?.(event);
            setActive(null);
          }}
          {...props}
        >
          <motion.span
            aria-hidden
            data-slot="slider-track"
            className={cn(
              "absolute overflow-hidden rounded-full bg-muted",
              vertical ? "inset-y-0 left-1/2 w-1 -translate-x-1/2" : "inset-x-0 top-1/2 h-1 -translate-y-1/2",
            )}
            initial={false}
            // Thicker while a thumb is open: the slider wakes under the hand
            animate={
              vertical ? { scaleX: anyOpen && !reduce ? THICK : 1 } : { scaleY: anyOpen && !reduce ? THICK : 1 }
            }
            transition={reduce ? { duration: 0 } : spring.snappy}
          >
            <motion.span
              data-slot="slider-range"
              className={cn(
                "absolute inset-0 bg-primary",
                vertical ? (flip ? "origin-bottom" : "origin-top") : flip ? "origin-right" : "origin-left",
              )}
              style={vertical ? { y: fillStart, scaleY: fillLength } : { x: fillStart, scaleX: fillLength }}
            />
          </motion.span>
          {values.map((thumbValue, index) => (
            <Thumb
              // biome-ignore lint/suspicious/noArrayIndexKey: a thumb is its place in the sorted values
              key={index}
              ref={(node) => {
                thumbs.current[index] = node;
              }}
              value={thumbValue}
              drawn={drawn[index] ?? zero}
              size={size}
              vertical={vertical}
              flip={flip}
              open={isOpen(index)}
              disabled={disabled}
              min={min}
              max={max}
              names={nameOf(index)}
              valueText={format ? formatter.format(thumbValue) : undefined}
              format={format}
              locale={locale}
              reduce={reduce}
              onKeyDown={onKeyDown(index)}
              onFocus={() => setFocused({ index, keyboard: keyboardLast })}
              onBlur={() => setFocused((current) => (current?.index === index ? null : current))}
            />
          ))}
          {name
            ? values.map((current, index) => (
                <input
                  // biome-ignore lint/suspicious/noArrayIndexKey: one input per thumb, in order
                  key={index}
                  type="hidden"
                  name={count > 1 ? `${name}[]` : name}
                  form={form}
                  value={current}
                  disabled={disabled}
                />
              ))
            : null}
        </div>
      );
    }
    
    type ThumbProps = {
      ref: (node: HTMLSpanElement | null) => void;
      value: number;
      drawn: MotionValue<number>;
      size: MotionValue<number>;
      vertical: boolean;
      flip: boolean;
      open: boolean;
      disabled: boolean;
      min: number;
      max: number;
      names: { id?: string; "aria-label"?: string; "aria-labelledby"?: string };
      valueText?: string;
      format?: Intl.NumberFormatOptions;
      locale: string;
      reduce: boolean;
      onKeyDown: (event: KeyboardEvent) => void;
      onFocus: () => void;
      onBlur: () => void;
    };
    
    /** One thumb: a dot at rest, a pill with the value while it's reached for. It's the slider screen readers see. */
    function Thumb({
      ref,
      value,
      drawn,
      size,
      vertical,
      flip,
      open,
      disabled,
      min,
      max,
      names,
      valueText,
      format,
      locale,
      reduce,
      onKeyDown,
      onFocus,
      onBlur,
    }: ThumbProps) {
      // Its centre sits at its value's point on the track, so it travels the whole length. Placed in percent of
      // the slider, so it's in place from the server's HTML on, before anything is measured (a measured length
      // drew it at a guess until the page woke up, owner 2026-10-22).
      const at = useTransform(drawn, (p) => `${(flip ? 1 - p : p) * 100}%`);
      // The faster it moves, the longer the pill (its speed in px a second, from the measured length); at rest,
      // round again
      const speed = useVelocity(drawn);
      const stretch = useTransform([speed, size], ([velocity = 0, length = 0]: number[]) =>
        reduce ? 1 : 1 + STRETCH * Math.min(1, Math.abs(velocity * length) / FULL_SPEED),
      );
      // The pill fits its value: as wide as the text plus its room, never narrower than the Lab's 46px, so a long
      // value ("51 hours") never spills out (owner, 2026-10-22); a change of width glides on the same spring
      const content = useRef<HTMLSpanElement>(null);
      const [fit, setFit] = useState(0);
      useLayoutEffect(() => {
        const node = content.current;
        if (!node) return;
        const measure = () => setFit(node.offsetWidth);
        measure();
        const observer = new ResizeObserver(measure);
        observer.observe(node);
        return () => observer.disconnect();
      }, []);
      const pill = { width: Math.max(PILL.width, fit + PILL_PAD * 2), height: PILL.height };
    
      return (
        <motion.span
          ref={ref}
          role="slider"
          tabIndex={disabled ? -1 : 0}
          {...names}
          aria-valuemin={min}
          aria-valuemax={max}
          aria-valuenow={value}
          aria-valuetext={valueText}
          aria-orientation={vertical ? "vertical" : "horizontal"}
          aria-disabled={disabled || undefined}
          data-slot="slider-thumb"
          data-open={open || undefined}
          onKeyDown={onKeyDown}
          onFocus={onFocus}
          onBlur={onBlur}
          className={cn(
            "absolute flex items-center justify-center overflow-hidden rounded-full bg-primary text-xs font-medium whitespace-nowrap text-primary-foreground tabular-nums",
            "outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)",
            vertical ? "left-1/2 -translate-x-1/2 -translate-y-1/2" : "top-1/2 -translate-x-1/2 -translate-y-1/2",
          )}
          style={vertical ? { top: at, scaleY: stretch } : { left: at, scaleX: stretch }}
          initial={false}
          animate={open ? pill : { width: DOT, height: DOT }}
          transition={reduce ? { duration: 0 } : spring.snappy}
        >
          <motion.span
            ref={content}
            aria-hidden
            className="shrink-0"
            initial={false}
            animate={
              open
                ? { opacity: 1, filter: blur.shown, scale: 1 }
                : { opacity: 0, filter: reduce ? blur.shown : blur.hidden, scale: reduce ? 1 : 0.6 }
            }
            transition={
              reduce
                ? { duration: 0 }
                : { duration: open ? duration.base : duration.fast, ease: open ? ease.out : ease.in }
            }
          >
            {/* The value flows as it changes; nothing rolls when the pill first opens */}
            <NumberTicker value={value} from={value} startOnView={false} format={format} locale={locale} />
          </motion.span>
        </motion.span>
      );
    }
    
    export { Slider };
  6. 6

    Update the import paths to match your project.

Examples

With a label

A visible label names the thumb through aria-labelledby, with the value beside it. An Intl format (hours) shows in the pill and gives screen readers the same text.

Keep backups for24 hours
24 hours
labelled.tsx
"use client";

import { Slider } from "@/components/ui/slider";
import { useId, useState } from "react";

/*
 * A visible label with the value beside it, controlled. aria-labelledby names the thumb from the label, and
 * the Intl format ("24 hours") shows in the thumb's pill and reaches screen readers as aria-valuetext.
 */
export default function SliderLabelled() {
  const id = useId();
  const [value, setValue] = useState([24]);
  const hours = { style: "unit", unit: "hour", unitDisplay: "long" } as const;
  const format = (value: number) => new Intl.NumberFormat("en-US", hours).format(value);

  return (
    <div className="grid w-full max-w-xs gap-3">
      <div className="flex items-center justify-between gap-4">
        <span id={id} className="text-sm font-medium">
          Keep backups for
        </span>
        <span className="text-sm text-muted-foreground tabular-nums">{format(value[0] ?? 0)}</span>
      </div>
      <Slider aria-labelledby={id} value={value} onValueChange={setValue} min={1} max={72} format={hours} />
    </div>
  );
}

Vertical

orientation="vertical": at least 11rem tall, filling a taller container. Up and Right raise the value, and the pill grows from the thumb just the same.

  • 60
    Bass
  • 40
    Mid
  • 70
    Treble
vertical.tsx
import { Slider } from "@/components/ui/slider";

const bands = [
  { label: "Bass", value: 60 },
  { label: "Mid", value: 40 },
  { label: "Treble", value: 70 },
];

/**
 * orientation="vertical": at least 11rem tall, filling a taller container. Up and Right raise the value,
 * and the thumb's pill shows the value.
 */
export default function SliderVertical() {
  return (
    <ul className="flex items-start justify-center gap-16">
      {bands.map((band) => (
        <li key={band.label} className="flex flex-col items-center gap-3">
          <Slider orientation="vertical" aria-label={band.label} defaultValue={[band.value]} />
          <span className="text-xs text-muted-foreground">{band.label}</span>
        </li>
      ))}
    </ul>
  );
}

API reference

Also accepts every native div prop. One number in value for one thumb, two for a range. aria-label or aria-labelledby names the thumbs; each thumb is a role="slider" with the full keyboard.

PropTypeDefaultDescription
value?number[]–The values, when you control them: one per thumb.
defaultValue?number[][min]The values at first. Two numbers make a range.
onValueChange?(value: number[]) => void–Called on every change, while dragging too.
onValueCommit?(value: number[]) => void–Called once a drag ends or a key changes the value.
min?number0The lowest value.
max?number100The highest value.
step?number1The values snap to steps of this size.
minStepsBetweenThumbs?number0How many steps a range's thumbs keep apart.
format?Intl.NumberFormatOptions–How the value is written in the thumb's pill and for screen readers (aria-valuetext), for example { style: "currency", currency: "USD" }.
locale?string"en-US"The locale to format in. Fixed by default so the server and the browser agree.
thumbLabels?string[]–A name for each thumb. Without it a range's thumbs are named from aria-label, for example “Price, minimum”.
orientation?"horizontal" | "vertical""horizontal"Vertical is at least 11rem tall and fills a taller container.
inverted?booleanfalsePuts the lowest value at the other end.
disabled?booleanfalsePrevents interaction.
name?string–Submits the values with a form, through a hidden input per thumb.

Related components

  • 3
    Number Input

    An accessible React number input for Next.js and Tailwind with step buttons, keyboard control, min and max, and Intl number formatting.

  • Switch

    An accessible React switch for on/off settings, with a spring-animated thumb, built on Radix Switch.

  • Input

    An accessible React text input with three sizes and clear invalid, disabled and file states, built on a native input.