A slider over named stops — retention tiers, instance sizes, rollout stages — with detent dots the fill swallows, clickable stop captions, and a crossfading description.
npx shadcn@latest add @paragon/segmented-slider"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>
);
}