Scrub slider
A compact number control from a design tool’s panel: drag anywhere on the row to scrub, click the value to type.
scrub-slider/scrub-slider.tsxCard
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- Tuning a number in a dense properties panel: radius, padding, opacity, angle, as in a design tool’s inspector.
- Values people adjust by feel and watch change live: a drag across the row covers the whole range, Shift makes it ten times finer.
- Numbers that also need to be typed exactly: click the value, type, Enter.
- Stacks of related settings where every row must stay one line high (34 px) and line up under the same label column (
--scrub-label-width).
When not to use
- A handful of named steps (Low, Medium, High). Use a stepped slider, like the effort slider, whose stops have names.
- Settings people rarely change in a plain form. A number field with a label is clearer than a gesture to discover.
- Unbounded values (a price, a count with no ceiling). The track needs a
minand amaxto mean anything; use a number field. - Two values at once, like a range from–to. It holds one value; use a two-thumb range slider.
- Touch-first screens where sideways swipes already mean something (carousels, swipe actions): the scrub would fight them.
You can add this component with the shadcn CLI or by hand:
Into components/scrub-slider of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/scrub-slider.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Copy the media files
To the same paths under your project’s public folder:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { useState } from 'react';import { ScrubSlider } from './scrub-slider';export function CornerRadius() { const [radius, setRadius] = useState(12); return ( <ScrubSlider label="Radius" value={radius} onChange={setRadius} defaultValue={12} min={0} max={32} unit="px" valueText={(value) => `${value} pixels`} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
A slider that fits in a single row: the label on the left, the value on the right, fine ticks between them. Drag anywhere on it to scrub the value (hold Shift for finer steps), click the number to type one, double-click to reset; the fill and its thin handle follow on a soft spring. Here four of them tune a card’s corners, padding, shadow and tilt, live.
- Scrubbing is relative. A press anywhere on the row notes the value, and each pointer move adds its distance times a rate: a full track’s width covers the whole range, and Shift makes it ten times finer. The unrounded sum is kept, so slow drags add up before snapping to the step.
- Pointer capture keeps a scrub going wherever the pointer goes. It only starts after a few pixels of sideways travel, so a vertical swipe on a phone still scrolls the page and a tap on the value still opens the field.
- The fill is one layer as wide as the track, slid by a CSS variable, --p, from 0 to 1, and its right edge carries the handle. A spring integrated each frame moves --p toward the value without a render per frame, and stops when it settles.
- The label always sits on the filled side and the value on the open side; the ticks are one repeating gradient. The colours are --scrub-* custom properties whose defaults are light-dark() pairs, so the slider follows the page’s color-scheme, or the colorScheme prop, and a parent can still theme it by setting them. The demo’s panel opens in the page’s scheme until its switch is flipped.
- In the demo the card reads the four values as CSS variables. Its cover is a rendered 3D coastal scene with a fantasy-game finish; the image and button take the card’s radius minus its padding, so the corners stay concentric.
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | The visible label, which also names the slider for screen readers. Required. |
value | number | — | The value, when the parent holds it (with onChange). |
defaultValue | number | 0 | The value to start from when the slider holds its own, and the one a double-click restores. |
onChange | (value: number) => void | — | Called with each new value: scrubbed, stepped, typed or reset. |
min | number | 0 | The lowest value. |
max | number | 100 | The highest value. |
step | number | 1 | The step of a drag and of the arrow keys; Page Up and Page Down take ten. |
fineStep | number | step / 10 | The step while Shift is held. |
unit | string | '' | Shown after the value: px, °, %. |
valueText | (value: number) => string | — | What screen readers say for a value; by default, the value and its unit. |
ticks | number | 20 | How many intervals the ticks divide the track into. |
defaultState | 'focus' | 'scrubbing' | 'editing' | — | Opens drawn as focused, as scrubbing, or with the value’s field open (not focused), for a still picture. The first press or key goes back to normal. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the slider’s root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type FocusEvent, type KeyboardEvent, type PointerEvent, type RefObject,} from 'react';import { MoonIcon, ResetIcon, SunIcon } from './icons';import { PreviewCard, type CardStyle } from './preview';import { useAutoplay } from './use-autoplay';import styles from './scrub-slider.module.css';import panel from './panel.module.css';/* * Scrub slider: a compact number control, as in a design tool's panel. The label sits on the/* Scrub slider (scrub-slider.tsx): a label, a track and a value in one row. The fill is a layer as wide as the track, moved by `--p` (0 to 1), which the component springs towards the value; its right edge carries the handle. Colours are custom properties: set the --scrub-* properties on any ancestor to theme it. Their defaults, below, are light-dark(light, dark): the slider takes the color-scheme it inherits from the page, or the one its colorScheme prop sets (data-color-scheme). */.slider { --base: var(--scrub-base, #fff); --fill: var(--scrub-fill, #ebebed); --line: var(--scrub-line, rgb(0 0 0 / 0.06)); --ink: var(--scrub-ink, #1d1d1f); --muted: var(--scrub-muted, #616166); --tick: var(--scrub-tick, rgb(0 0 0 / 0.16)); --handle: var(--scrub-handle, #1d1d1f); /* The focus ring, the handle while scrubbing, and the field's outline: the same on both. */ --accent: var(--scrub-accent, #0a84ff); position: relative; min-width: 0; font: 500 13px/1 Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI',import type { CSSProperties } from 'react';import styles from './panel.module.css';/* * The panel's live preview: a small route card whose corner radius, padding, shadow and tilt come * from the sliders, as CSS variables. The cover and the button take the inner radius (the card's, * less its padding), so the corners stay concentric whatever the values. Sample content: hidden * from screen readers, which hear the sliders instead. */export type CardStyle = { radius: number; padding: number; shadow: number; tilt: number };export function PreviewCard({ radius, padding, shadow, tilt }: CardStyle) { return ( <div className={styles.card} style={ { '--radius': `${radius}px`, '--pad': `${padding}px`, '--shadow': `${shadow}px`, '--tilt': `${tilt}deg`, } as CSSProperties }/* The scrub slider's demo (ScrubSliderPanel in scrub-slider.tsx): a stage, light or dark, with a live card (preview.tsx) and a panel of four sliders. From 620px wide (a container query) the card and the panel stand side by side; narrower, the card is on top. Colours are the tokens below, light-dark(light, dark), and the sliders' own defaults (scrub-slider.module.css): the stage takes the color-scheme it inherits from the page, or the one the colorScheme prop or the panel's switch sets (data-color-scheme on the root). */.root { container-type: inline-size; min-width: 0; /* For the script: a colour the root never shows (it has no outline), white in light and black in dark, which tells the switch the scheme the root is drawn in. */ outline-color: #fff;}@supports (color: light-dark(#fff, #000)) { .root { outline-color: light-dark(#fff, #000); }}/* The colorScheme prop, or the reader's pick on the switch. Without either, nothing is set: color-scheme is inherited from the page. */.root[data-color-scheme='light'] { color-scheme: light;/* * The panel's icons: Reset's arrow, and the sun and moon of the theme switch. Decorative: the * buttons carry the words. */export function ResetIcon() { return ( <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" > <path d="M3 8a5 5 0 1 0 1.5-3.6M3 2.8v2.6h2.6" /> </svg> );}export function SunIcon({ className }: { className?: string }) { return ( <svg'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- Each slider is a role="slider" named by its visible label, with aria-valuenow and an aria-valuetext that speaks its unit (“24 pixels”).
- The arrow keys step (Shift for fine steps), Page Up and Page Down take ten steps, and Home and End go to the ends.
- Enter, or typing a digit, opens a labelled text field: Enter keeps the value, Escape drops it, and focus returns to the slider. Everything a drag or a double-click does can be done from the keyboard.
- The demo’s cursor waits while keyboard focus is in the panel. Under reduced motion there is no demo and the fill jumps straight to its value.
