Elements

Slider

A slider picks a value along a track (a volume, a brightness), or a range of two values with a thumb each (a price range). It owns its behavior: a press on the track moves the nearer thumb there, a thumb follows a drag, with a mouse or a finger; values snap to the step; each thumb is a stop of the keyboard focus and moves with the arrow keys, and is an adjustable element for screen readers with its own value. The look is the app’s: a Slider has no colors, sizes, radii or motion of its own. It is made of a track with an indicator inside it, the range and the thumbs on the indicator, then its children (a label, the value as text), and the app styles each part.

el-slider.tsx
el-slider.tsxLive
import { type JSX, mount, signal, Text, View } from "nsh";
import { Slider, type SliderValue } from "nsh/base";
import { desktop, Log, Panel, size, sliderFill, sliderLook, sliderValueClass, sliderValueStyle, Split, ui } from "./palette";

// The look is the shared layer's `sliderLook` and `sliderFill` (BJORK UI's
// slider in the palette's tones): an 8-point rail, a fill that deepens at its
// start and fades into the thumb at the thumb, a 16 × 8 pill thumb that
// brightens under the pointer and while dragged, the keyboard's focus ring
// around the rail, and 35% when disabled. The spread holds only what never
// changes; the fill follows the value as a prop of its own. The demo shows a slider with its value column, a
// bare one, a range, a disabled one and vertical bands, each named above it.
const show = (v: SliderValue) => (typeof v === "number" ? `${v}` : `${v[0]}–${v[1]}`);
const clock = (seconds: number) => `${Math.floor(seconds / 60)}:${String(Math.round(seconds) % 60).padStart(2, "0")}`;
const LENGTH = 225;

function Row(props: { label: string; value?: string; children: JSX.Element }) {
  return (
    <View class={desktop ? "gap-2 px-3 py-3" : "gap-2 px-4 py-4"}>
      <View class="flex-row items-baseline gap-3">
        <Text style={{ flex: 1, fontSize: size.label, fontWeight: "500", color: ui().text }}>{props.label}</Text>
        <Text style={{ fontSize: size.label, color: ui().muted, fontVariant: ["tabular-nums"] }}>{props.value ?? ""}</Text>
      </View>
      {props.children}
    </View>
  );
}

function App() {
  const strength = signal(64);
  const position = signal(84);
  const price = signal<[number, number]>([200, 800]);
  const bands = [signal(0), signal(3), signal(-2)];
  const events = signal<string[]>([]);
  const log = (name: string) => (next: SliderValue) => events.set([`${name} → ${show(next)}`, ...events()].slice(0, 5));
  const line = () => <View style={{ height: 1, marginLeft: desktop ? 12 : 16, backgroundColor: ui().line }} />;
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>slider</Text>
      <Text style={{ color: ui().muted }}>
        {desktop ? "Drag a thumb or click the track, or Tab to a thumb and use the arrow keys" : "Drag a thumb, or tap the track"}
      </Text>

      <Split
        gap={14}
        inspector={
          <>
            <Panel title="value">
              <Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>
                strength {strength()} · position {position()} · price [{price()[0]}, {price()[1]}]
              </Text>
              <Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>
                bands {bands.map((b) => b()).join(" / ")}
              </Text>
            </Panel>
            <Log title="onChangeEnd(value)" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ ...ui().card, borderRadius: 16, overflow: "hidden" }}>
          <Row label="Option strength">
            <Slider
              value={strength()}
              onChange={(next) => strength.set(next as number)}
              onChangeEnd={log("strength")}
              accessibilityLabel="Option strength"
              rangeStyle={sliderFill(0, strength() / 100)}
              {...sliderLook({ value: true })}
            >
              <Text class={sliderValueClass} style={sliderValueStyle()}>{`${strength()}%`}</Text>
            </Slider>
          </Row>
          {line()}
          <Row label="Playback position" value={`${clock(position())} / ${clock(LENGTH)}`}>
            <Slider
              value={position()}
              max={LENGTH}
              onChange={(next) => position.set(next as number)}
              onChangeEnd={log("position")}
              accessibilityLabel="Playback position"
              rangeStyle={sliderFill(0, position() / LENGTH)}
              {...sliderLook()}
            />
          </Row>
          {line()}
          <Row label="Price" value={`$${price()[0]} – $${price()[1]}`}>
            <Slider
              value={price()}
              min={0}
              max={1000}
              step={10}
              onChange={(next) => price.set(next as [number, number])}
              onChangeEnd={log("price")}
              startAccessibilityLabel="Minimum price"
              accessibilityLabel="Maximum price"
              rangeStyle={sliderFill(price()[0] / 1000, price()[1] / 1000)}
              {...sliderLook()}
            />
          </Row>
          {line()}
          <Row label="Locked intensity">
            <Slider value={72} disabled accessibilityLabel="Locked intensity" rangeStyle={sliderFill(0, 0.72)} {...sliderLook({ value: true })}>
              <Text class={sliderValueClass} style={sliderValueStyle()}>72%</Text>
            </Slider>
          </Row>
          {line()}
          <Row label="Equalizer" value="dB">
            <View class="flex-row justify-around pt-1">
              {["Bass", "Mid", "Treble"].map((name, i) => (
                <View class="items-center gap-2">
                  <Slider
                    value={bands[i]()}
                    min={-12}
                    max={12}
                    axis="vertical"
                    onChange={(next) => bands[i].set(next as number)}
                    onChangeEnd={log(name.toLowerCase())}
                    accessibilityLabel={name}
                    rangeStyle={sliderFill(0, (bands[i]() + 12) / 24, true)}
                    {...sliderLook({ vertical: true })}
                  />
                  <Text style={{ fontSize: size.detail, color: ui().muted, fontVariant: ["tabular-nums"] }}>
                    {`${name} ${bands[i]() > 0 ? "+" : ""}${bands[i]()}`}
                  </Text>
                </View>
              ))}
            </View>
          </Row>
        </View>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{SliderValue, slider, theme};
use nsh_core::prelude::*;

fn strength(level: Signal<SliderValue>) -> impl IntoElement {
    let percent = move || format!("{}%", level.get().end());
    slider()
        .value(move || level.get())
        .accessibility_label("Option strength")
        .on_change(move |next: &SliderValue| level.set(*next))
        // The value in a column after the rail.
        .flex_row()
        .items_center()
        .gap_2()
        // An 8-point rail; the thumbs travel inside it.
        .track_style(|s| s.h(px(8.0)).flex_1().px(px(8.0)).rounded_full().bg(move || theme().muted))
        .indicator_style(|s| s.h_full())
        // The fill, from the rail's edge, fading into the thumb's tone.
        .range_style(|s| {
            s.ml(-px(8.0))
                .rounded_full()
                .bg_linear_to_r()
                .gradient_from(move || theme().primary)
                .gradient_to(move || theme().muted_foreground)
        })
        // A 16 x 8 pill sitting in the rail.
        .thumb_style(|s| {
            s.w(px(16.0))
                .h(px(8.0))
                .rounded_full()
                .bg(move || theme().muted_foreground)
                .hover(|s| s.brightness(1.1))
                .active(|s| s.brightness(1.25))
        })
        .child(
            text(percent)
                .w(px(42.0))
                .text_right()
                .text_xs()
                .tabular_nums()
                .text_color(move || theme().muted_foreground),
        )
}

Slider comes from nsh/base, the nsh-base crate in Rust. In Rust, slider() makes one with a setter per prop (.value(..), .thumb_style(..)), a listener method per event (.on_change(..)), the typed style methods for the slider itself and .child(..) for its label. Its value is a SliderValue: Single(f64) or Range(f64, f64).

#Value

The value is a number, or [start, end] for a range with two thumbs; the shape of the value decides how many thumbs there are. The app owns it with value and sets the one onChange reports, as for every control of nsh/base; or it leaves the value to the slider with defaultValue, and hears the changes all the same.

const price = signal<[number, number]>([200, 800]);

<Slider
  value={price()}
  onChange={(next) => price.set(next as [number, number])}
  onChangeEnd={(next) => save(next)}
  min={0}
  max={1000}
  step={10}
  accessibilityLabel="Maximum price"
  startAccessibilityLabel="Minimum price"
/>;
  • onChange reports each new value the user asks for: on a press on the track, at each step of a drag, for each key. Each report is a new value.
  • onChangeEnd reports the value the change ended at, once: when the pointer lifts after a press or a drag that set a value, and after each key or screen reader step (each is a whole change). Save or send the value there.
  • Values snap to step, counted from min (min={1} step={2} gives 1, 3, 5, …), and stay between min and max; max is always reachable, so the last step is shorter when the range is not a whole number of steps. step={0} takes any value.
  • A range’s thumbs never cross: a dragged thumb stops at the other one. A drag that starts on two thumbs at one value moves the one that can go the pointer’s way.
  • The value shown is the app’s, held to min and max (a range’s ends in order), and a value off the step’s grid shows where it is; the user’s next change snaps.

#Props

PropRustTypeDefaultDescription
value.value(..)number | [number, number]noneThe value (controlled): a number, or a range with two thumbs. The app sets the one onChange reports.
defaultValue.default_value(..)number | [number, number]0The value at first while value is not set; the slider then keeps its own.
min.min(..)number0The smallest value.
max.max(..)number100The largest value; one below min is min.
step.step(..)number1The step values snap to, counted from min; 0 takes any value.
scale.scale(..)"linear" | "logarithmic""linear"How values spread along the track: evenly, or by orders of magnitude (from 1 to 1000, a third of the way is 10). Logarithmic needs min above 0; with a lower min it is linear.
axis.axis(..)"horizontal" | "vertical""horizontal"The track’s direction: min at the left, or at the bottom. Read when the slider is made.
disabled.disabled(..)booleanfalseNo pointer, keyboard or screen reader changes; disabled: classes apply to the slider and its parts.
accessibilityLabel.accessibility_label(..)stringnoneWhat screen readers say for the thumb (on a range, the end’s).
startAccessibilityLabel.start_accessibility_label(..)stringaccessibilityLabelWhat screen readers say for a range’s start thumb.
trackClass, trackStyle.track_style(|s| ..)string, StyleProp<ViewStyle>noneThe track: the area that takes presses and drags.
indicatorClass, indicatorStyle.indicator_style(|s| ..)string, StyleProp<ViewStyle>noneThe indicator, the rail the values map onto.
rangeClass, rangeStyle.range_style(|s| ..)string, StyleProp<ViewStyle>noneThe range, from min to the thumb (between a range’s thumbs), placed by the slider.
thumbClass, thumbStyle.thumb_style(|s| ..)string, StyleProp<ViewStyle>noneEach thumb, centered on its value.
children.child(..)JSX.ElementnoneThe content after the track: a label, the value as text.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe slider itself: the column (or row) around the track and the label.

#Events

EventRustPayloadWhen
onChange.on_change(|value: &SliderValue| ..)number | [number, number]The user asked for a new value: a press on the track, a step of a drag, a key, a screen reader’s increment or decrement.
onChangeEnd.on_change_end(|value: &SliderValue| ..)number | [number, number]The change ended: the pointer lifted, a key or a screen reader’s step was done. Once per change.

#Parts

PartInsideStyled with
The sliderclass, style
The trackthe slider, firsttrackClass, trackStyle
The indicatorthe trackindicatorClass, indicatorStyle
The rangethe indicator, placed by the valuesrangeClass, rangeStyle
A thumb (two on a range)the indicator, centered on its valuethumbClass, thumbStyle
The labelthe slider, after the trackits own elements (children)

The track takes presses and drags. The indicator’s length is the range of values, from min at its leading edge (at its bottom when vertical); the slider places the range and the thumbs on it, a thumb centered on its value whatever its size. Two ways to lay them out:

  • The track is the rail (the example above): h-2 px-2 rounded-full bg-muted with an h-full indicator. The padding keeps the thumbs inside the rail at both ends, and the range’s -ml-2 starts the fill at the rail’s own edge.
  • The track is a band around the rail, taller so a finger finds it on a touch screen: h-8 justify-center with an h-2 rounded-full indicator (w-8 items-center and w-2 h-full on a vertical slider).

States the parts show:

  • A thumb: hover: under the pointer, active: while it is dragged, focus: with the keyboard focus, disabled:.
  • The track: active: while a press on it drags; it is a group, so the indicator, the range and the thumbs take group-hover: and group-active: from it.
  • Every part: disabled: while the slider is.

The thumbs move as the value changes, and follow a drag at once. Motion of the thumbs’ states is the app’s choice (transition for a thumb that brightens on hover: and active:), and motion-reduce: keeps it still for people who ask for less motion. The keyboard focus is a thumb’s, which it reports while it has a focus: class: focus-within: on the track then rings the whole rail, and the thumb’s focus:outline-none takes the place of the platform’s own focus outline.

#Keyboard

On macOS and the web each thumb is a stop of the keyboard focus (Tab), and the focused thumb takes WAI-ARIA’s slider keys:

KeyDoes
Right, UpOne step up (also on a vertical slider).
Left, DownOne step down.
Page Up, Page DownA tenth of the range up or down (along the scale), by at least one step.
Home, EndAs far down or up as the thumb goes (a range’s start up to its end).

Each key that changes the value reports onChange and then onChangeEnd. Arrows and pages repeat while held. The keys reach the slider only while no text input edits.

#Accessibility

  • Each thumb is an adjustable element ("adjustable", a slider in the browser) with its label, its value and the values it can take: a range’s start from min up to the end, its end from the start up to max, so screen readers read and set both. The value is spoken as the number with the step’s decimals (2.5).
  • A screen reader’s increment and decrement actions (VoiceOver’s swipe up and down) step the thumb, as the arrow keys do.
  • The thumbs are the slider’s elements for screen readers, named by accessibilityLabel (and a range’s start by startAccessibilityLabel); a visible label among its children is read where it is.
  • While disabled, screen readers say the thumbs are dimmed, and they take no actions.

See Accessibility for labels, roles and actions in general.

#Platform notes

  • Every platform draws what the app styles. Presses and drags are followed in the core, for a mouse, a pen or a finger, and a scroll view around the slider does not take a drag that started on it.
  • Tab moves the keyboard focus between thumbs on macOS and the web, where the focused thumb takes the slider keys.
  • Screen readers: VoiceOver (iOS, macOS) and TalkBack adjust each thumb; UI Automation (Windows), GTK’s accessibility (Linux) and the browser (role="slider", aria-valuenow, aria-valuetext) get its range and value.