Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

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.tsx
Live · interactive

Card

Radius18px
Padding14px
Shadow24px
Tilt0°

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 min and a max to 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:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/scrub-slider.json

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

corner-radius.tsx
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.
PropTypeDefaultDescription
labelstring—The visible label, which also names the slider for screen readers. Required.
valuenumber—The value, when the parent holds it (with onChange).
defaultValuenumber0The 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.
minnumber0The lowest value.
maxnumber100The highest value.
stepnumber1The step of a drag and of the arrow keys; Page Up and Page Down take ten.
fineStepnumberstep / 10The step while Shift is held.
unitstring''Shown after the value: px, °, %.
valueText(value: number) => string—What screen readers say for a value; by default, the value and its unit.
ticksnumber20How 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.
classNamestring—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.

scrub-slider.tsx
// 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.module.css
/* 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',
preview.tsx
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      }
panel.module.css
/* 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;
icons.tsx
/* * 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-autoplay.ts
'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.