ControlsSlider
A draggable value on a track, with a bubble that answers “where am I”.
Volume
←→ step by 1, PgUpPgDn leap by 10
The thinking
A slider exists to set a number you feel rather than type, so the one decision that shaped this build is to let Radix own everything numeric — clamping, stepping,
aria-valuenow — and add exactly one thing on top: a bubble that reads the live value back to you. I mirror Radix into internalValue only so the bubble can show the number; Radix stays the source of truth, never a second copy I have to keep honest. The detail that earns the bubble is where its visibility comes from. Keyboard focus shows it through pure :focus-visible CSS, and the only script state it costs is dragging — the one thing CSS can't see — which flips twice per drag, not once per frame. I also catch onPointerCancel, because a touch stream can die in cancel rather than up, and without it the bubble would stick open after the drag was already gone.Interface design
At rest the control spends color in exactly one place. The track is an
h-1.5 hairline on bg-input, the quiet well form surfaces share, and the filled range is the only bg-primary on the line — you read the value before you read any chrome. The thumb is deliberately not a colored dot: it's an h-4 w-4 card, bg-card with a border-border edge, the same surface treatment as the touchable objects around it, and hover only warms the edge to border-border-hover. The bubble inverts all of that — bg-primary fill, text-primary-foreground, text-[10px] mono in a px-1.5 py-px box — so it reads as a tag stamped with data, not a tooltip full of prose. It sits at -top-7, above the thumb, because the finger that needs the number is the thing most likely to be covering it.Interaction
Keyboard parity is not a fallback: arrows step by one, page keys leap ten steps, home and end jump to the rails — Radix gives all of it, and the bubble shows for keyboard focus exactly as it does for a drag. On a pointer, the intent-reading starts before the drag does: press anywhere on the track and Radix hands the nearest thumb to your pointer, so nobody has to land a fingertip on an
h-4 w-4 target, and touch-none on the root means that once the drag starts, the page has lost the scroll argument. The motion itself stays small on purpose — the bubble rises from translate-y-1 and fades in over duration-150 on ease-swift, opacity and transform only, and the thumb hover is a transition-colors at the same 150ms. Nothing animates layout. A slider has one job while you drag — track the finger — and everything above is scoped to stay out of that job's way.Sound design
Silent on purpose. Sound spends attention, and frequency times weight is the budget — a drag emits dozens of value changes a second, so any per-step note fails on frequency before it can matter. The slider carries no audible role, so the SoundLayer never taps it either; there's no opt-out to manage, the same way text fields stay quiet.
Empathy
Disabled fades the whole control through
data-[disabled]:opacity-50 and Radix stops taking input; there is no half-working slider. Keyboard focus draws focus-visible:ring-1 on the ring token and shows the bubble through group-focus-visible; a pointer drag gets the bubble through dragging and never the ring, so each input method gets its own marker. Under reduced motion, motion-reduce:transition-none on both the thumb and the bubble keeps every state and drops the easing — the bubble still appears, it just snaps. For a screen reader the bubble is aria-hidden and pointer-events-none, because Radix already announces aria-valuenow from the thumb; a bubble that also spoke would read every number twice. The one thing this component can't do for itself is carry a name: the accessible name lives on the thumb, a label's htmlFor would point at the root div and go nowhere, so the demo passes aria-label and so should you."use client";
import * as React from "react";
import * as SliderPrimitive from "@radix-ui/react-slider";
import { cn } from "@/lib/utils";
// silent on purpose: a continuous control would chatter — no audio code,
// and the slider carries no audible role for the SoundLayer to tap.
export interface SliderProps
extends React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root> {
ref?: React.Ref<React.ComponentRef<typeof SliderPrimitive.Root>>;
}
const Slider = ({
ref,
className,
value,
defaultValue,
onValueChange,
onPointerDown,
onPointerUp,
min = 0,
...props
}: SliderProps) => {
// the bubble needs the live numbers — mirror radix, which stays the
// source of truth for clamping and stepping
const [internalValue, setInternalValue] = React.useState<number[]>(
() => value ?? defaultValue ?? [min]
);
const values = value ?? internalValue;
// pointer drag is the one state CSS can't see; keyboard visibility is
// pure :focus-visible below, so this is all the JS the bubble costs
const [dragging, setDragging] = React.useState(false);
return (
<SliderPrimitive.Root
ref={ref}
min={min}
value={value}
defaultValue={defaultValue}
onValueChange={(next) => {
setInternalValue(next);
onValueChange?.(next);
}}
onPointerDown={(event) => {
setDragging(true);
onPointerDown?.(event);
}}
onPointerUp={(event) => {
setDragging(false);
onPointerUp?.(event);
}}
// touch streams can end in cancel, not up (system gesture, tab
// switch) — without this the bubble sticks open after the drag died
onPointerCancel={(event) => {
setDragging(false);
props.onPointerCancel?.(event);
}}
className={cn(
"relative flex w-full touch-none select-none items-center data-[disabled]:opacity-50",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-input">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
{values.map((thumbValue, index) => (
<SliderPrimitive.Thumb
key={index}
className="group relative block h-4 w-4 rounded-full border border-border bg-card transition-colors duration-150 ease-swift hover:border-border-hover focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none motion-reduce:transition-none"
>
{/* the bubble answers "where am i" — 10px mono like a tag,
opacity+translate only, 150ms. radix already announces
aria-valuenow, so this stays decorative */}
<span
aria-hidden
className={cn(
"pointer-events-none absolute -top-7 left-1/2 -translate-x-1/2 translate-y-1 rounded-sm bg-primary px-1.5 py-px font-mono text-[10px] leading-[1.8] text-primary-foreground opacity-0 transition duration-150 ease-swift group-focus-visible:translate-y-0 group-focus-visible:opacity-100 motion-reduce:transition-none",
dragging && "translate-y-0 opacity-100"
)}
>
{thumbValue}
</span>
</SliderPrimitive.Thumb>
))}
</SliderPrimitive.Root>
);
};
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };