Actions and inputs
Range ruler
A two-handle range slider on a ruler of thin ticks, where one needle pushes the other instead of crossing it, value pills roll to each new number, and the two pills join into one when the needles get close.
Live preview
Loading preview
A filter panel "Price per night" from 0 to 500 with step 10, unit "USD", minDistance 20 and start value [80, 240]. Replay resets the range, drags the lower needle up into the upper one (the upper one is pushed and the pills join), then presses End on the upper needle.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add range-rulerUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { RangeRuler } from './range-ruler';
/** Replay: drag the lower needle into the upper one (it is pushed and the pills join), then press End on the upper needle. */
function runScript(root: HTMLElement, later: (ms: number, run: () => void) => void) {
const track = () => root.querySelector<HTMLElement>('[data-slot="range-track"]');
const needle = (index: number) => root.querySelector<HTMLElement>(`[data-needle="${index}"]`);
const xOf = (value: number) => {
const rect = track()?.getBoundingClientRect();
return rect ? rect.left + (value / 500) * rect.width : 0;
};
const pointer = (type: string, value: number) =>
needle(0)?.dispatchEvent(
new PointerEvent(type, { bubbles: true, pointerId: 1, button: 0, clientX: xOf(value) }),
);
later(300, () => pointer('pointerdown', 80));
const steps = 18;
for (let i = 1; i <= steps; i++) later(300 + i * 60, () => pointer('pointermove', 80 + (180 * i) / steps));
later(300 + steps * 60 + 400, () => pointer('pointerup', 260));
later(2600, () => needle(1)?.dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true })));
}
export default function RangeRulerDemo({ replay = 0 }: { replay?: number }) {
const area = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (replay === 0 || !area.current) return;
const timers: number[] = [];
runScript(area.current, (ms, run) => timers.push(window.setTimeout(run, ms)));
return () => {
for (const timer of timers) window.clearTimeout(timer);
};
}, [replay]);
return (
<div ref={area} className="w-full max-w-[480px]">
{/* Replay mounts the ruler again, so the range starts from the same place. */}
<RangeRuler
key={replay}
label="Price per night"
min={0}
max={500}
step={10}
minDistance={20}
unit="USD"
defaultValue={[80, 240]}
/>
</div>
);
}
API
Parts: root (group), label row (label, current range as text), readout row, lower value pill, upper value pill, joined value pill (shown when the needles are less than 48px apart), ruler (SVG axis, major and minor ticks), track (pointer area), range band, lower needle (slider), upper needle (slider), limit labels, keyboard help (screen reader only, one per needle).
| Prop | Type | Default | Description |
|---|---|---|---|
| label | string | undefined | Visible label; also names the group and the needles ("Minimum price", "Maximum price"). Without it the group is named "Range". |
| value | [number, number] | undefined | Controlled range. |
| defaultValue | [number, number] | [20, 80] | Starting range when uncontrolled. |
| min | number | 0 | Lowest value. |
| max | number | 100 | Highest value. |
| step | number | 1 | Snap step; also sets the decimals shown. |
| minDistance | number | 0 | Smallest gap between the two values. |
| unit | string | "" | Text after each value, for example "kg". |
| disabled | boolean | false | Turns both needles off. |
| Event | Description |
|---|---|
| valueChange | Fires with [lower, upper] on every change while dragging or pressing keys. |
| valueCommit | Fires with [lower, upper] when a drag ends or after a key press. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| RR1 | drag a needle or press on the track | needle + range band | left, width (follows the pointer, snapped to step; the nearest needle jumps to a track press) | current value → pointer value | 0ease-linear | Moves at once |
| RR2 | drag one needle into the other (Pushing) | other needle | left (pushed ahead, keeping minDistance) | its value → dragged value +/- minDistance | 0ease-linear | Moves at once |
| RR3 | while dragging | value pills | left (follows its needle) | old place → new place | duration-sm (200)ease-out | Moves at once |
| RR4 | drag ends, or a key press | value pill numbers | number roll (plain text while dragging, rolls when the value settles) | old value → new value | duration-xl (380)ease-out | Swaps at once |
| RR5 | needles come within 48px, or move apart | split pills <-> joined pill | opacity crossfade | split pills → joined pill | duration-sm (200)ease-out | Swaps at once |
| RR6 | Arrow keys, Page Up or Down, Home or End, or value set from outside | needles + range band | left, width (glide to the new value) | old value → new value | duration-xl (380)ease-out | Moves at once |
| RR7 | Escape while dragging, or the drag is interrupted | needles + range band | left, width (glide back to where the drag started) | drag value → start value | duration-xl (380)ease-out | Moves at once |
Accessibility
- Each needle is a slider (role slider on a button) named "Minimum <label>" and "Maximum <label>", with aria-valuemin and aria-valuemax that respect the other needle and minDistance, aria-valuenow and aria-valuetext with the unit.
- Keyboard: Arrow keys move one step, Shift with an arrow or Page Up and Page Down move ten steps, Home and End go to the limits, Escape during a drag puts the needles back.
- Each needle has a hidden help line, for example "Use the left and right arrow keys to change the minimum. Hold Shift or use Page Up and Page Down to move 10 steps. Home and End go to the limits."
- Needles are 24 by 32px with a larger invisible hit area (12px each side, 8px above and below); the whole track also takes a press and drags the nearest needle.
- The ruler, ticks and limit labels are aria-hidden; the label row repeats the range as plain text ("20 kg - 80 kg").
- touch-action none on the track and needles so dragging does not scroll the page.
- Reduced motion: needles jump to their values, numbers swap and the pills switch at once.