Split slider
A value slider whose dark fill carries its label and value, and whose handle parts around the text it meets.
split-slider/split-slider.tsxThe 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 one number by feel, where the effect shows as it moves: blur, radius, opacity, zoom.
- Stacks of settings in an editor or an inspector, where each row needs its name and its value and little else.
- Bounded ranges with a fixed step, from
mintomax, read with their unit:76 px,58%,−1.5 °C. - Narrow panels: the label and the value live inside the bar, so a row costs only its height.
When not to use
- Values that must be typed exactly, or ranges too wide to drag to one number. Use a number field, or pair one with the slider.
- A low and a high bound at once. Use a range slider with two thumbs.
- A few named steps, like Low, Medium, High. A segmented control or a stepped slider names them.
- Long labels: the label and the value share one bar, so keep the label to a word or two.
- Settings that apply only on Save, where nothing changes as it moves: the drag gives no feedback worth having.
You can add this component with the shadcn CLI or by hand:
Into components/split-slider of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/split-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:
- 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 { SplitSlider, SplitSliderDemo } from './split-slider';// One slider in your own panel.export function BlurControl() { const [blur, setBlur] = useState(76); return <SplitSlider label="Blur" value={blur} onChange={setBlur} unit="px" unitName="pixels" />;}// Or the stage of three, without its demo.export function SliderStage() { return <SplitSliderDemo autoplay={false} />;}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.
Below are variations of this component, each live, with its code.
Below zero
A frost alert from −10 to 10 °C in half degrees, with a true minus sign below zero.
Coarse steps
A wide zoom slider from 25 to 200% in steps of 25: the fill follows the pointer and settles on the nearest step when let go.
Compact
Smaller sliders, 44 px high, for a dense brush panel, with the ticks turned off.
Colour mixer
Hue, saturation and lightness held by the parent, mixing the swatch above as they move.
A slider for a tool’s panel: a dark, rounded fill grows over a light track, with a thin white handle near its end. Where the fill covers the label or the value they turn white, and when the handle reaches one of them it parts into two dots above and below the text. Drag it past an end and it stretches like a rubber band; every step ticks with a soft synthesised click.
- The slider is drawn twice: its label, value and handle once in dark on the light track, and once in white on top, clipped with clip-path to the fill’s rounded shape. Wherever the fill reaches, the text turns white, down to half a letter.
- The fill’s end, its stretch past either end and a slight thinning are CSS variables. A spring integrated each frame moves them toward the value and stops once it settles, so React renders only when the value changes.
- The handle is two halves, each a short bar seen through a clip-path. Near the label or the value the clips shrink to dots at their outer ends: the handle splits a little ahead of a text in the direction it travels, and closes as soon as it is past. The texts’ places are measured once, and again when the slider or its font changes.
- Dragged past an end, the slider stretches along a rubber-band curve that gives less and less, up to a third of its height; let go, a lightly damped spring brings it back with a small wobble.
- Each step ticks with a click made by the Web Audio API: a short sine that drops in pitch over a burst of filtered noise, lower and quieter as the value falls. One AudioContext serves the page; it is created on the reader’s first press or key, suspended when the ticks stop and closed with the last slider.
- Every colour is a light-dark() pair on the root, so the slider takes the page’s color-scheme, or the colorScheme prop. The pairs sit in @supports, where builds that rewrite light-dark() leave them as written, so a page that sets no color-scheme stays light, and so do browsers without light-dark(). At night the fill is a lit grey over a dark grey track.
| 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 | 50 | The value to start from when the slider holds its own. |
onChange | (value: number) => void | — | Called with each new value, as it is dragged or stepped. |
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. |
unit | string | 'px' | Shown after the value; a percent sign sits against the number. |
unitName | string | 'pixels' | The unit as screen readers say it: “76 pixels”. |
sound | boolean | true | Ticks on each step, once the reader has pressed or keyed the slider. |
height | number | 58 | Its height in px; its text, corners and handle scale with it. |
ref | Ref<SplitSliderHandle> | — | Drives the slider as a pointer would (press, drag, release); the demo uses it. |
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. |
autoplay | boolean | true | On SplitSliderDemo, the stage of three sliders (which takes className too): while on screen and untouched, a ghost pointer drags the sliders now and then. |
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, useImperativeHandle, useLayoutEffect, useRef, useState, type CSSProperties, type FocusEvent, type KeyboardEvent, type PointerEvent, type Ref,} from 'react';import { clickSound, type ClickSound } from './click-sound';import { GhostPointer, runDemo, type Point } from './ghost-pointer';import { useAutoplay } from './use-autoplay';import styles from './split-slider.module.css';/* * Split slider: a value slider whose dark fill carries its label, a thin handle and, as it grows, * its value./* Split slider (split-slider.tsx). Its proportions follow its height, --h (the height prop sets it inline). The component writes where the fill is as it moves: --p (its end, 0 to 1 of the width), --over and --under (how far it is stretched past the right or the left end, in px) and --squash (how much a stretch thins it). --handle-inset and --handle-start come from the component's GEOMETRY. *//* The palette, on both roots (the slider and the demo's stage). Each colour is a light-dark() pair, so it follows the color-scheme in force: the page's, or the one the colorScheme prop sets. The pairs sit in @supports, where a build step that rewrites light-dark() for older browsers leaves them as written: rewritten, they would need a color-scheme the same step had rewritten too, and on a page that sets none every colour would drop. Without light-dark(), the light colours stay. */.stage,.slider { --surface: #fff; --surface-2: #eee; --hover: #f2f2f4; --line: rgb(0 0 0 / 0.06); --ink: #111; --ink-muted: #7c7c7c; --icon: #8e8e93; --icon-hover: #3a3a3c; --accent: #303030; --accent-2: #141414; --accent-ink: #fff;/* * The slider's click: a soft, short tick synthesised with the Web Audio API, no audio file. Each * tick is a sine blip that drops in pitch, over a breath of filtered noise for its edge, shaped by * an envelope a few milliseconds long. Higher values tick higher and louder. * * Browsers allow sound only after a gesture of the reader's, so nothing plays before their first * press or key on a slider. One AudioContext serves every slider on the page: it is created on * that first gesture, suspended when the ticks stop (an idle context still runs its audio thread), * and closed when the last slider is gone. *//** A tick's length, in seconds. */const LENGTH = 0.05;/** Ticks closer than this (ms) are skipped: a fast drag would buzz. */const MIN_GAP = 22;/** After this long without a tick (ms), the audio is suspended. */const REST = 1500;let context: AudioContext | null = null;let noise: AudioBuffer | null = null;let users = 0;let lastTick = 0;let restTimer = 0;'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps: * wait, glide the pointer somewhere, press, drag, let go. A glide is a curved flight played by the * Web Animations API; a drag runs frame by frame, so that the script can move a slider along with * the pointer. Stopping the demo (the reader takes over, or it scrolls away) ends the step in * progress at once: its promise rejects, which ends the script right there, and the pointer fades * out. */export type Point = { x: number; y: number };export type Demo = { /** Resolves after `ms`. */ wait: (ms: number) => Promise<void>; /** Glides the pointer to `to`, in the stage's own px. */ moveTo: (to: Point) => Promise<void>; /** Presses and holds: the arrow dips. */ press: () => Promise<void>; /** Drags to `to` over `ms`, eased, handing each point on the way to `each`. */ drag: (to: Point, ms: number, each: (point: Point) => void) => Promise<void>;'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 label, with aria-valuenow and an aria-valuetext that speaks its unit (“76 pixels”). The two drawn copies of the text and the handle are aria-hidden, so the value is heard once.
- The arrow keys step (Shift for ten steps), Page Up and Page Down take ten, Home and End go to the ends, and the focus ring follows the slider’s rounded shape.
- The click is never the only feedback. It starts only after the reader’s own press or key, never plays for the demo, and the mute button (a toggle with aria-pressed) silences it.
- On a phone a vertical swipe still scrolls the page. Under reduced motion there is no demo, no spring and no stretch: the fill moves straight to its value.