Lunia DS

Base components

Slider

A track with one or two handles for picking a value or a range, where the handle and the fill follow the pointer with no delay and the value label rides along with the handle.

Loading preview

A Volume slider with a floating label, a Price range slider from $20 to $80 with labels under the handles and a 10 step gap, and a disabled slider. Replay moves the volume from 20 to 70 with key steps, then the range ends inward.

Installation

Run this in your app after the one-time setup on the Getting started page.

$ npx @ganisigit/lunia-ds add slider

Usage

The demo above, as code.

demo.tsx
'use client';

import * as React from 'react';
import { Slider } from './slider';
import type { SliderValue } from './slider-variants';

const VOLUME_STEPS = [20, 30, 40, 50, 60, 70];
const money = (value: number) => `$${value}`;
const percent = (value: number) => `${value}%`;

function Field({ title, children }: { title: string; children: React.ReactNode }) {
  return (
    <div className="flex flex-col gap-3">
      <p className="text-sm font-medium text-secondary">{title}</p>
      {children}
    </div>
  );
}

export default function SliderDemo({ replay = 0 }: { replay?: number }) {
  const [volume, setVolume] = React.useState<SliderValue>(40);
  const [price, setPrice] = React.useState<SliderValue>([20, 80]);

  React.useEffect(() => {
    if (replay === 0) return;
    const timers: number[] = [];
    const at = (ms: number, run: () => void) => timers.push(window.setTimeout(run, ms));
    setVolume(VOLUME_STEPS[0] ?? 20);
    setPrice([20, 80]);
    VOLUME_STEPS.slice(1).forEach((value, index) => {
      at(400 + index * 180, () => setVolume(value));
    });
    at(1500, () => setPrice([30, 70]));
    return () => {
      for (const timer of timers) window.clearTimeout(timer);
    };
  }, [replay]);

  return (
    <div className="flex w-full max-w-sm flex-col gap-8">
      <Field title="Volume">
        <Slider
          aria-label="Volume"
          label="top-floating"
          format={percent}
          value={volume}
          onValueChange={setVolume}
        />
      </Field>
      <Field title="Price">
        <Slider
          aria-label="Price"
          label="bottom"
          format={money}
          minStepsBetweenThumbs={10}
          value={price}
          onValueChange={setPrice}
        />
      </Field>
      <Field title="Brightness">
        <Slider aria-label="Brightness" defaultValue={60} format={percent} disabled />
      </Field>
    </div>
  );
}

API

Parts: root, track, fill (from the start, or between the two handles), handle (one or two), value label (under the handle, or a floating label above it).

PropTypeDefaultDescription
valuenumber | [number, number]undefinedControlled value. A pair makes a range slider with two handles.
defaultValuenumber | [number, number]0Value when uncontrolled.
minnumber0Lowest value.
maxnumber100Highest value.
stepnumber1Step for the arrow keys and for dragging.
largeStepnumber10Step for Page Up, Page Down and Shift with an arrow key.
minStepsBetweenThumbsnumber0Range only: how close the two handles may come.
label"none" | "bottom" | "top-floating""none"Value label under each handle, in a floating label above it, or none.
format(value: number) => stringthe number, for example "40"Text of the value label and of aria-valuetext, for example "$40" or "40%".
disabledbooleanfalseFades the slider to 50% and turns it off.
namestringundefinedForm field name.
EventDescription
valueChangeThe value changed while dragging or with the keys. Payload: the new value.
valueCommitThe pointer was released or a key step ended. Payload: the final value. Use it for slow work such as requests.

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
SLD1drag a handlehandle, fill, value labeltranslate x (handle and label), width (fill)pointer start → pointer position, snapped to step0ease-linearMoves at once
SLD2press the track away from a handlenearest handle, fill, value labeltranslate x, widthold value → value at the pointerduration-md (260)ease-outMoves at once
SLD3arrow keys, Page Up, Page Down, Home or Endfocused handle, fill, value labeltranslate x, widthold value → new valueduration-2xs (120)ease-outMoves at once
SLD4hover or drag a handlehandlebackground-color, box-shadowslider-handle-bg, shadow-md → bg-brand-secondary, shadow-lgduration-sm (200)ease-outSwaps at once
SLD5keyboard focus on a handlehandlebox-shadowshadow-md → focus ring (2px bg-primary gap, 4px focus-ring)duration-2xs (120)ease-outShows at once
SLD6the value changes (any way)value label texttextold value → new value, at once (no roll while dragging)0ease-linearSwaps at once

Accessibility

  • Built on a slider primitive: each handle is role slider with aria-valuenow, aria-valuemin, aria-valuemax and aria-valuetext from format.
  • Name each handle: one label for a single slider, and "Minimum price" and "Maximum price" style names for a range.
  • Keyboard: Left and Down step down, Right and Up step up, Page Up and Page Down (or Shift with an arrow) use largeStep, Home and End jump to min and max.
  • The handle is 24px, which meets the 24 x 24px target (WCAG 2.5.8); the track takes presses along its full height plus 8px above and below.
  • Value labels are aria-hidden; screen readers read aria-valuetext.
  • Reduced motion: dragging still moves the handle (it is direct), and track presses and key steps jump with no tween.