Segmented Slider
Inputs & Forms

Segmented Slider

A slider over named stops — retention tiers, instance sizes, rollout stages — with detent dots the fill swallows, clickable stop captions, and a crossfading description.

Install

npx shadcn@latest add @paragon/segmented-slider

segmented-slider.tsx

"use client";

import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export interface SegmentedSliderOption {
  value: string;
  label: string;
  /** Shown under the control while this option is active. */
  description?: string;
}

export interface SegmentedSliderProps
  extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
  /** The ordered stops. Two minimum. */
  options: SegmentedSliderOption[];
  /** Accessible name for the slider. */
  label: string;
  /** Controlled selected value. */
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Clickable captions under each stop. */
  showLabels?: boolean;
  /** Form field name; renders a hidden input. */
  name?: string;
  disabled?: boolean;
  /** Disables the description crossfade. */
  static?: boolean;
}

/**
 * A slider over named stops instead of a number line — retention tiers,
 * instance sizes, rollout stages. The thumb snaps between detents (every
 * stop is a dot the fill swallows as it passes), each caption is a real
 * button that jumps straight to its stop, and the active option announces
 * itself via `aria-valuetext`. An optional description crossfades below.
 */
export function SegmentedSlider({
  options,
  label,
  value: valueProp,
  defaultValue,
  onValueChange,
  showLabels = true,
  name,
  disabled = false,
  static: isStatic = false,
  className,
  ...props
}: SegmentedSliderProps) {
  const reduced = useReducedMotion() ?? false;
  const count = Math.max(options.length, 2);
  const [uncontrolled, setUncontrolled] = React.useState(
    defaultValue ?? options[0]?.value ?? "",
  );
  const selected = valueProp ?? uncontrolled;
  const index = Math.max(
    0,
    options.findIndex((option) => option.value === selected),
  );
  const active = options[index];

  const setIndex = (next: number) => {
    const option = options[Math.min(count - 1, Math.max(0, next))];
    if (!option || option.value === selected) return;
    if (valueProp === undefined) setUncontrolled(option.value);
    onValueChange?.(option.value);
  };

  const percent = (i: number) => (count === 1 ? 0 : (i / (count - 1)) * 100);

  return (
    <div
      data-slot="segmented-slider"
      className={cn(
        "w-full",
        disabled && "pointer-events-none opacity-50",
        className,
      )}
      {...props}
    >
      <SliderPrimitive.Root
        min={0}
        max={count - 1}
        step={1}
        value={[index]}
        onValueChange={(next) => setIndex(next[0] ?? index)}
        disabled={disabled}
        className="relative flex w-full touch-none items-center py-2 select-none"
      >
        <SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-secondary">
          {/* Detents — the fill swallows the ones already passed. */}
          {options.map((option, i) => (
            <span
              key={option.value}
              aria-hidden
              className="pointer-events-none absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-muted-foreground/40"
              style={{ left: `${percent(i)}%` }}
            />
          ))}
          <SliderPrimitive.Range className="absolute h-full bg-primary" />
        </SliderPrimitive.Track>
        <SliderPrimitive.Thumb
          aria-label={label}
          aria-valuetext={active?.label}
          className={cn(
            "relative block size-4 shrink-0 rounded-full border border-ring/40 bg-background shadow-sm",
            "transition-[scale,border-color] duration-150 ease-out motion-reduce:transition-[border-color]",
            "hover:border-ring/70 active:scale-110",
            "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
            "after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-x-1/2 after:-translate-y-1/2",
          )}
        />
      </SliderPrimitive.Root>

      {showLabels && (
        <div
          className="mt-1 grid"
          style={{ gridTemplateColumns: `repeat(${count}, minmax(0, 1fr))` }}
        >
          {options.map((option, i) => (
            <button
              key={option.value}
              type="button"
              tabIndex={-1}
              aria-hidden
              disabled={disabled}
              onClick={() => setIndex(i)}
              className={cn(
                "relative min-w-0 truncate py-1 text-xs select-none",
                "transition-[color] duration-150 ease-out",
                i === 0
                  ? "text-left"
                  : i === count - 1
                    ? "text-right"
                    : "text-center",
                i === index
                  ? "font-medium text-foreground"
                  : "text-muted-foreground hover:text-foreground",
                "after:absolute after:inset-x-0 after:top-1/2 after:h-10 after:-translate-y-1/2",
              )}
            >
              {option.label}
            </button>
          ))}
        </div>
      )}

      {active?.description && (
        <div className="mt-1.5 h-4 overflow-hidden">
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.p
              key={active.value}
              initial={
                isStatic || reduced
                  ? { opacity: 0 }
                  : { opacity: 0, y: 8, filter: "blur(4px)" }
              }
              animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
              exit={
                isStatic || reduced
                  ? { opacity: 0 }
                  : { opacity: 0, y: -8, filter: "blur(4px)" }
              }
              transition={{ type: "spring", duration: 0.3, bounce: 0 }}
              className="truncate text-xs text-muted-foreground"
            >
              {active.description}
            </motion.p>
          </AnimatePresence>
        </div>
      )}

      {name && <input type="hidden" name={name} value={selected} />}
    </div>
  );
}