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.
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),
)
}import { mount, signal, Text } from "nsh";
import { Slider } from "nsh/base";
function App() {
const strength = signal(64);
return (
<Slider
value={strength()}
onChange={(next) => strength.set(next as number)}
accessibilityLabel="Option strength"
class="flex-row items-center gap-2"
trackClass="h-2 flex-1 px-2 rounded-full bg-muted focus-within:ring-2 focus-within:ring-primary/36 focus-within:ring-offset-2 focus-within:ring-offset-background disabled:opacity-35"
indicatorClass="h-full"
rangeClass="-ml-2 rounded-full bg-linear-to-r from-primary via-primary to-muted-foreground"
thumbClass="h-2 w-4 rounded-full bg-muted-foreground transition hover:brightness-110 active:brightness-125 focus:outline-none disabled:brightness-100 motion-reduce:transition-none"
thumbStyle={{ boxShadow: "inset 0 1px 0 rgba(255, 255, 255, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.42)" }}
>
<Text class="w-[42px] text-right font-mono text-[11px] text-muted-foreground">{`${strength()}%`}</Text>
</Slider>
);
}
mount(App);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"
/>;
onChangereports 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.onChangeEndreports 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 frommin(min={1} step={2}gives 1, 3, 5, …), and stay betweenminandmax;maxis 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
minandmax(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
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
value | .value(..) | number | [number, number] | none | The value (controlled): a number, or a range with two thumbs. The app sets the one onChange reports. |
defaultValue | .default_value(..) | number | [number, number] | 0 | The value at first while value is not set; the slider then keeps its own. |
min | .min(..) | number | 0 | The smallest value. |
max | .max(..) | number | 100 | The largest value; one below min is min. |
step | .step(..) | number | 1 | The 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(..) | boolean | false | No pointer, keyboard or screen reader changes; disabled: classes apply to the slider and its parts. |
accessibilityLabel | .accessibility_label(..) | string | none | What screen readers say for the thumb (on a range, the end’s). |
startAccessibilityLabel | .start_accessibility_label(..) | string | accessibilityLabel | What screen readers say for a range’s start thumb. |
trackClass, trackStyle | .track_style(|s| ..) | string, StyleProp<ViewStyle> | none | The track: the area that takes presses and drags. |
indicatorClass, indicatorStyle | .indicator_style(|s| ..) | string, StyleProp<ViewStyle> | none | The indicator, the rail the values map onto. |
rangeClass, rangeStyle | .range_style(|s| ..) | string, StyleProp<ViewStyle> | none | The range, from min to the thumb (between a range’s thumbs), placed by the slider. |
thumbClass, thumbStyle | .thumb_style(|s| ..) | string, StyleProp<ViewStyle> | none | Each thumb, centered on its value. |
children | .child(..) | JSX.Element | none | The content after the track: a label, the value as text. |
class, style | style methods | string, StyleProp<ViewStyle> | none | The slider itself: the column (or row) around the track and the label. |
#Events
| Event | Rust | Payload | When |
|---|---|---|---|
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
| Part | Inside | Styled with |
|---|---|---|
| The slider | class, style | |
| The track | the slider, first | trackClass, trackStyle |
| The indicator | the track | indicatorClass, indicatorStyle |
| The range | the indicator, placed by the values | rangeClass, rangeStyle |
| A thumb (two on a range) | the indicator, centered on its value | thumbClass, thumbStyle |
| The label | the slider, after the track | its 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-mutedwith anh-fullindicator. The padding keeps the thumbs inside the rail at both ends, and the range’s-ml-2starts 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-centerwith anh-2 rounded-fullindicator (w-8 items-centerandw-2 h-fullon 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 agroup, so the indicator, the range and the thumbs takegroup-hover:andgroup-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:
| Key | Does |
|---|---|
| Right, Up | One step up (also on a vertical slider). |
| Left, Down | One step down. |
| Page Up, Page Down | A tenth of the range up or down (along the scale), by at least one step. |
| Home, End | As 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 fromminup to the end, its end from the start up tomax, 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 bystartAccessibilityLabel); 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.