A Radix Slider with a tabular-nums value tooltip that fades in while dragging or focused, plus optional tick marks the fill swallows as it passes.
npx shadcn@latest add @paragon/slider"use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
export interface SliderProps
extends React.ComponentProps<typeof SliderPrimitive.Root> {
/** Show a value tooltip above the thumb while dragging or focused. */
showTooltip?: boolean;
/** Format the tooltip value, e.g. `(v) => \`${v} req/min\``. */
formatValue?: (value: number) => string;
/** Track values to render tick marks at. Passed ticks sit under the fill. */
ticks?: number[];
}
/**
* Radix Slider with a tabular-nums value tooltip that fades in while
* dragging (or while the thumb has keyboard focus) and optional tick marks.
*/
export function Slider({
className,
showTooltip = true,
formatValue = (value) => String(value),
ticks,
min = 0,
max = 100,
value,
defaultValue,
onValueChange,
"aria-label": ariaLabel,
...props
}: SliderProps) {
const [internal, setInternal] = React.useState<number[]>(
value ?? defaultValue ?? [min],
);
const values = value ?? internal;
const [dragging, setDragging] = React.useState(false);
React.useEffect(() => {
if (!dragging) return;
const stop = () => setDragging(false);
window.addEventListener("pointerup", stop);
window.addEventListener("pointercancel", stop);
return () => {
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
};
}, [dragging]);
const percent = (v: number) =>
max === min ? 0 : ((v - min) / (max - min)) * 100;
return (
<SliderPrimitive.Root
data-slot="slider"
min={min}
max={max}
value={value}
defaultValue={defaultValue}
onValueChange={(next) => {
setInternal(next);
onValueChange?.(next);
}}
onPointerDown={() => setDragging(true)}
className={cn(
"relative flex w-full touch-none items-center select-none",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-secondary">
{/* Ticks render under the range, so the fill swallows passed marks. */}
{ticks?.map((tick) => (
<span
key={tick}
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(tick)}%` }}
/>
))}
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
{values.map((thumbValue, index) => (
<SliderPrimitive.Thumb
key={index}
// The root is a span, so the accessible name belongs on the thumb.
aria-label={
values.length > 1 && ariaLabel
? `${ariaLabel} (${index + 1} of ${values.length})`
: ariaLabel
}
className={cn(
"group relative block size-4 shrink-0 rounded-full border border-ring/40 bg-background shadow-sm",
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
>
{showTooltip && (
<span
aria-hidden
className={cn(
"pointer-events-none absolute bottom-full left-1/2 mb-2 -translate-x-1/2",
"rounded-md bg-foreground px-1.5 py-0.5 text-xs font-medium whitespace-nowrap text-background tabular-nums shadow-overlay",
"origin-bottom transition-[opacity,scale] duration-150 ease-out motion-reduce:transition-[opacity]",
"group-focus-visible:scale-100 group-focus-visible:opacity-100",
dragging ? "scale-100 opacity-100" : "scale-95 opacity-0",
)}
>
{formatValue(thumbValue)}
</span>
)}
</SliderPrimitive.Thumb>
))}
</SliderPrimitive.Root>
);
}