Lunia DS

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.

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-ruler

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
labelstringundefinedVisible label; also names the group and the needles ("Minimum price", "Maximum price"). Without it the group is named "Range".
value[number, number]undefinedControlled range.
defaultValue[number, number][20, 80]Starting range when uncontrolled.
minnumber0Lowest value.
maxnumber100Highest value.
stepnumber1Snap step; also sets the decimals shown.
minDistancenumber0Smallest gap between the two values.
unitstring""Text after each value, for example "kg".
disabledbooleanfalseTurns both needles off.
EventDescription
valueChangeFires with [lower, upper] on every change while dragging or pressing keys.
valueCommitFires 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
RR1drag a needle or press on the trackneedle + range bandleft, width (follows the pointer, snapped to step; the nearest needle jumps to a track press)current value → pointer value0ease-linearMoves at once
RR2drag one needle into the other (Pushing)other needleleft (pushed ahead, keeping minDistance)its value → dragged value +/- minDistance0ease-linearMoves at once
RR3while draggingvalue pillsleft (follows its needle)old place → new placeduration-sm (200)ease-outMoves at once
RR4drag ends, or a key pressvalue pill numbersnumber roll (plain text while dragging, rolls when the value settles)old value → new valueduration-xl (380)ease-outSwaps at once
RR5needles come within 48px, or move apartsplit pills <-> joined pillopacity crossfadesplit pills → joined pillduration-sm (200)ease-outSwaps at once
RR6Arrow keys, Page Up or Down, Home or End, or value set from outsideneedles + range bandleft, width (glide to the new value)old value → new valueduration-xl (380)ease-outMoves at once
RR7Escape while dragging, or the drag is interruptedneedles + range bandleft, width (glide back to where the drag started)drag value → start valueduration-xl (380)ease-outMoves 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.