Scrubber
A compact labeled slider with an inline value readout — ideal for settings rows and media controls
Volume0.50
Opacity0.75
Progress42
import { Scrubber } from '@/components/scrubber';
export default function ScrubberPreview() {
return (
<div className="flex w-sm flex-col gap-6">
<Scrubber label="Volume" defaultValue={0.5} />
<Scrubber label="Opacity" defaultValue={0.75} ticks={11} />
<Scrubber
label="Progress"
min={0}
max={100}
step={1}
decimals={0}
defaultValue={42}
/>
</div>
);
} Source Code
'use client';
import type { VariantProps } from 'cva';
import { useCallback, useRef, useState } from 'react';
import { cn, cva } from '@/lib/utils/classnames';
const scrubberStyle = cva({
base: [
'group relative flex w-full cursor-pointer items-center overflow-hidden rounded-xl',
'border border-border bg-surface-sunken',
'outline-none',
'focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-offset-2',
],
variants: {
size: {
sm: 'h-10 text-sm [--scrubber-pad-x:14px] [--scrubber-thumb-h:26px]',
md: 'h-13 text-sm [--scrubber-pad-x:18px] [--scrubber-thumb-h:34px]',
lg: 'h-16 text-base [--scrubber-pad-x:22px] [--scrubber-thumb-h:42px]',
},
disabled: {
true: 'pointer-events-none opacity-50',
false: '',
},
surface: {
default: '',
raised: 'border-0 bg-surface-overlay',
},
},
defaultVariants: {
size: 'md',
disabled: false,
surface: 'default',
},
});
interface ScrubberProps
extends Omit<React.ComponentPropsWithRef<'div'>, 'onChange' | 'defaultValue'>,
VariantProps<typeof scrubberStyle> {
/** Number of decimal places to display */
decimals?: number;
/** Default value for uncontrolled usage */
defaultValue?: number;
/** Label displayed on the left side of the track */
label?: string;
/** Maximum value */
max?: number;
/** Minimum value */
min?: number;
/** Callback when value changes */
onValueChange?: (value: number) => void;
/** Step increment */
step?: number;
/** Number of tick marks to display (0 for none) */
ticks?: number;
/** Controlled value */
value?: number;
/** Input name for form submission */
name?: string;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function roundToStep(value: number, step: number, min: number): number {
const steps = Math.round((value - min) / step);
return min + steps * step;
}
const Scrubber = ({
className,
decimals = 2,
defaultValue,
disabled = false,
label,
max = 1,
min = 0,
name,
onValueChange,
ref,
size,
surface,
step = 0.01,
ticks = 0,
value: valueProp,
...props
}: ScrubberProps) => {
const trackRef = useRef<HTMLDivElement>(null);
const [internalValue, setInternalValue] = useState(defaultValue ?? min);
const [isDragging, setIsDragging] = useState(false);
// Tracks the raw (unsnapped) drag position for smooth visual feedback
const [dragProgress, setDragProgress] = useState<number | null>(null);
const value = valueProp ?? internalValue;
const snappedProgress = max === min ? 0 : (value - min) / (max - min);
// During drag, show the raw pointer position; at rest, show snapped value
const progress = dragProgress ?? snappedProgress;
const commitValue = useCallback(
(raw: number) => {
const clamped = clamp(raw, min, max);
const stepped = roundToStep(clamped, step, min);
const final = clamp(stepped, min, max);
if (valueProp === undefined) setInternalValue(final);
onValueChange?.(final);
},
[min, max, step, valueProp, onValueChange]
);
const getRawFromPointer = useCallback(
(clientX: number) => {
const track = trackRef.current;
if (!track) return value;
const rect = track.getBoundingClientRect();
const fraction = clamp((clientX - rect.left) / rect.width, 0, 1);
return min + fraction * (max - min);
},
[min, max, value]
);
const getProgressFromPointer = useCallback(
(clientX: number) => {
const track = trackRef.current;
if (!track) return snappedProgress;
const rect = track.getBoundingClientRect();
return clamp((clientX - rect.left) / rect.width, 0, 1);
},
[snappedProgress]
);
const handlePointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (disabled) return;
e.currentTarget.setPointerCapture(e.pointerId);
const raw = getRawFromPointer(e.clientX);
const prog = getProgressFromPointer(e.clientX);
setIsDragging(true);
setDragProgress(prog);
commitValue(raw);
},
[disabled, getRawFromPointer, getProgressFromPointer, commitValue]
);
const handlePointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!isDragging || disabled) return;
// Update visual position freely without snapping
setDragProgress(getProgressFromPointer(e.clientX));
// Commit the raw value — snapping happens inside commitValue
commitValue(getRawFromPointer(e.clientX));
},
[
isDragging,
disabled,
getProgressFromPointer,
getRawFromPointer,
commitValue,
]
);
const handlePointerUp = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
e.currentTarget.releasePointerCapture(e.pointerId);
// Snap visual back to the committed stepped value on release
setDragProgress(null);
setIsDragging(false);
},
[]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
let newValue = value;
const largeStep = step * 10;
switch (e.key) {
case 'ArrowRight':
case 'ArrowUp':
e.preventDefault();
newValue = value + step;
break;
case 'ArrowLeft':
case 'ArrowDown':
e.preventDefault();
newValue = value - step;
break;
case 'PageUp':
e.preventDefault();
newValue = value + largeStep;
break;
case 'PageDown':
e.preventDefault();
newValue = value - largeStep;
break;
case 'Home':
e.preventDefault();
newValue = min;
break;
case 'End':
e.preventDefault();
newValue = max;
break;
default:
return;
}
commitValue(newValue);
},
[disabled, value, step, min, max, commitValue]
);
const formattedValue = value.toFixed(decimals);
const ariaValueText = label ? `${label}: ${formattedValue}` : formattedValue;
// Generate tick marks
const tickElements =
ticks > 0
? Array.from({ length: ticks }, (_, i) => {
const tickProgress = i / (ticks - 1);
const isFilled = tickProgress <= progress;
return (
<span
key={i}
className={cn(
'absolute top-1/2 h-2 w-px -translate-y-1/2',
'transition-[background-color] duration-(--duration-hover) ease-(--ease)',
isFilled
? 'bg-foreground/15 group-data-[dragging]:bg-foreground/30'
: 'bg-foreground/25'
)}
style={{ left: `${tickProgress * 100}%` }}
aria-hidden="true"
/>
);
})
: null;
const thumbLeft = `calc(var(--scrubber-pad-x) + (100% - 2 * var(--scrubber-pad-x)) * ${progress})`;
return (
<div
ref={ref}
role="slider"
tabIndex={disabled ? -1 : 0}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={ariaValueText}
aria-disabled={disabled || undefined}
aria-label={label}
data-dragging={isDragging || undefined}
data-disabled={disabled || undefined}
className={cn(scrubberStyle({ size, disabled, surface }), className)}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onKeyDown={handleKeyDown}
{...props}
>
{/* Hidden input for form submission */}
{name && <input type="hidden" name={name} value={value} />}
{/* Label (left side) */}
{label && (
<span
className="pointer-events-none absolute z-10 select-none font-medium text-foreground"
style={{ left: 'var(--scrubber-pad-x)' }}
>
{label}
</span>
)}
{/* Track with fill + ticks */}
<div
ref={trackRef}
className="absolute inset-x-0 top-1/2 h-full -translate-y-1/2 overflow-hidden rounded-none bg-background"
aria-hidden="true"
>
{/* Fill bar */}
<span
className={cn(
'absolute inset-y-0 left-0 overflow-auto rounded-none bg-background-secondary',
'transition-[width] duration-(--duration-hover) ease-(--ease)',
'group-data-[dragging]:transition-none'
)}
style={{ width: `${progress * 100}%` }}
/>
{tickElements}
</div>
{/* Thumb */}
<span
className={cn(
'absolute top-1/2 w-1 -translate-x-1/2 -translate-y-1/2 rounded-full',
'h-(--scrubber-thumb-h)',
'bg-foreground/50',
'transition-[opacity,transform,background-color] duration-(--duration-base) ease-(--ease)',
// Rest: small and dim — present but not distracting
'scale-x-100 scale-y-75',
// Hover/focus: full height, muted foreground
'group-hover:scale-y-100 group-hover:bg-foreground/60',
'group-focus-visible:scale-y-100 group-focus-visible:bg-foreground/60',
// Drag: full foreground, widened for grip feel
'group-data-[dragging]:scale-x-130 group-data-[dragging]:scale-y-100 group-data-[dragging]:bg-foreground'
)}
style={{ left: thumbLeft }}
aria-hidden="true"
/>
{/* Value readout (right side) */}
<span
className="pointer-events-none absolute z-10 select-none text-foreground tabular-nums"
style={{ right: 'var(--scrubber-pad-x)' }}
>
{formattedValue}
</span>
</div>
);
};
export type { ScrubberProps };
export { Scrubber, scrubberStyle }; API Reference
A horizontal drag slider that combines a label on the left and a tabular-num value readout on the right inside the track itself, with a thin capsule thumb that grows on hover/drag and optional tick marks.
Scrubber
| Prop | Default | Type | Description |
|---|---|---|---|
value | - | number | Controlled value of the scrubber. |
defaultValue | - | number | Default value for uncontrolled usage. |
min | 0 | number | Minimum value. |
max | 1 | number | Maximum value. |
step | 0.01 | number | Step increment. |
decimals | 2 | number | Number of decimal places to display. |
label | - | string | Label displayed on the left side of the track. |
ticks | 0 | number | Number of tick marks to display (0 for none). |
size | "md" | "sm" | "md" | "lg" | Size variant. |
disabled | false | boolean | Whether the scrubber is disabled. |
name | - | string | Input name for form submission. |
onValueChange | - | (value: number) => void | Callback when value changes. |
The component uses CSS variables for customization:
--scrubber-thumb-h— Height of the thumb capsule--scrubber-pad-x— Horizontal padding (label/value inset)
Examples
Default
Basic usage with a label and default value.
Volume0.50
Opacity0.75
Progress42
import { Scrubber } from '@/components/scrubber';
export default function ScrubberPreview() {
return (
<div className="flex w-sm flex-col gap-6">
<Scrubber label="Volume" defaultValue={0.5} />
<Scrubber label="Opacity" defaultValue={0.75} ticks={11} />
<Scrubber
label="Progress"
min={0}
max={100}
step={1}
decimals={0}
defaultValue={42}
/>
</div>
);
} Sizes
Three size variants: sm, md (default), and lg.
Small0.30
Medium0.50
Large0.70
import { Scrubber } from '@/components/scrubber';
export default function ScrubberSizesPreview() {
return (
<div className="flex w-sm flex-col gap-6">
<Scrubber label="Small" size="sm" defaultValue={0.3} />
<Scrubber label="Medium" size="md" defaultValue={0.5} />
<Scrubber label="Large" size="lg" defaultValue={0.7} />
</div>
);
} Disabled
Disabled state blocks interaction and dims the control.
Disabled0.50
Disabled with ticks0.75
import { Scrubber } from '@/components/scrubber';
export default function ScrubberDisabledPreview() {
return (
<div className="flex w-sm flex-col gap-6">
<Scrubber label="Disabled" defaultValue={0.5} disabled />
<Scrubber
label="Disabled with ticks"
defaultValue={0.75}
ticks={11}
disabled
/>
</div>
);
} Controlled
Controlled usage with external state management.
Brightness0.50
Controlled value:0.50
import { useState } from 'react';
import { Scrubber } from '@/components/scrubber';
export default function ScrubberControlledPreview() {
const [value, setValue] = useState(0.5);
return (
<div className="flex w-sm flex-col gap-4">
<Scrubber label="Brightness" value={value} onValueChange={setValue} />
<div className="flex items-center justify-between text-foreground-secondary text-sm">
<span>Controlled value:</span>
<span className="tabular-nums">{value.toFixed(2)}</span>
</div>
</div>
);
} Keyboard Support
| Key | Action |
|---|---|
ArrowRight / ArrowUp | Increase by step |
ArrowLeft / ArrowDown | Decrease by step |
PageUp | Increase by 10x step |
PageDown | Decrease by 10x step |
Home | Set to minimum |
End | Set to maximum |
Best Practices
-
Usage Guidelines:
- Use for compact inline controls where label + value need to be visible together
- Prefer over a separate label + slider combination in settings rows
- Set
decimals={0}for integer ranges to avoid decimal noise
-
Accessibility:
- Always provide a
labelfor screen reader context - The component announces value changes via
aria-valuetext - Full keyboard navigation is supported
- Always provide a
-
Design:
- The thumb is intentionally subtle at rest and prominent when interacting
- Tick marks help communicate discrete steps or key positions
- Uses family motion tokens for consistent feel with other Playstack primitives
Previous
Radio
Next
Section