Agents (llms.txt)

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

KeyAction
ArrowRight / ArrowUpIncrease by step
ArrowLeft / ArrowDownDecrease by step
PageUpIncrease by 10x step
PageDownDecrease by 10x step
HomeSet to minimum
EndSet to maximum

Best Practices

  1. 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
  2. Accessibility:

    • Always provide a label for screen reader context
    • The component announces value changes via aria-valuetext
    • Full keyboard navigation is supported
  3. 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