Slider
A control for picking a number by dragging it.
src/components/rahti_ui/slider.rs
Basics
Drag one, or focus it and press an arrow key, `Home`, `End` or `Page Up` — none of that is written anywhere in this library. The thumb moves with no JavaScript at all; the track behind it fills live in Firefox for free, and in Chrome and Safari once the runtime has mounted. The section below says why the two engines are different.
Min, max and step
`min` and `max` are shadcn's own `0` and `100` by default, and both are always written onto the element even at those defaults — the fill expression reads them back off it, and an absent attribute would make the arithmetic `NaN`. The middle slider is the one to check: its value is `0` but its thumb is halfway, because half of `-50..50` is where `0` sits.
States
`disabled:opacity-50` is shadcn's `data-[disabled]:opacity-50`, read as the attribute a native input has. The second slider is the documented way out of the fill handler: it still drags, still posts, and on Firefox its track still fills — it is only Chrome and Safari that leave the paint behind.
With a Label
The tag form, which is what a slider this page does not drive should be. `Label`'s association does the same work here as on every other control, and the range input supplies its own value, minimum and maximum to assistive technology.
A `<Label for>` names the control, so a screen reader reads "Brightness, slider, 70" — the value comes from the element, not from an `aria-valuenow` anybody had to write.
As a form posts it
One `name`, and `?quality=4` in the query string — a range input always posts something, since it always has a value. No hidden field beside the control to carry it, and no JavaScript to put it there.
Attributes
`.attr(…)` writes anything; `.unset(…)` takes one away. `list` is worth knowing about — it is HTML's own tick marks, drawn by the browser from a `<datalist>`, and Radix has an equivalent only if you build one.
No visible label, so it carries an `aria-label` instead.
Class override
`class` is merged, not appended — but almost every utility in this component carries a pseudo-element prefix, and a merge only replaces a utility carrying the same prefix. So an override that means to reach the thumb has to name the thumb, and has to name it twice: `::-webkit-slider-thumb` and `::-moz-range-thumb` are the same thing to a reader and two unrelated selectors to a browser, which discards a whole rule it does not understand.
How the track gets filled
This is the one component in the library that asks for the runtime to finish looking like itself, so it is worth being precise about what that means.
| engine | what fills the track | with no JavaScript |
|---|---|---|
| Firefox | ::-moz-range-progress | Live. The browser owns the pseudo-element and keeps it in step itself. |
| Chrome, Safari | linear-gradient(…var(--slider-fill)) | Correct on arrival, then frozen. There is no progress pseudo-element on either, so the stop is a custom property an `oninput` handler moves. |
And the one rule that comes with it
`--slider-fill` is an inline style, and the handler moves it by writing to the element's `style`. PulsePoint re-applies an element's rendered attributes on every render of its block — so in a block *whose state changes*, the `style` the server wrote comes back and takes the fill with it. The thumb stays where the user put it; the paint jumps back.
Every slider on this page above the reactive section sits in a block with no state, so nothing ever re-renders it and the handler is the whole story. A slider in a block that *has* state wants the fill to be state as well — `.fill_from("volume")`, one call, which hands the property to the runtime with this slider's `min` and `max` already baked into the arithmetic. That is better than surviving: the fill is then derived from the same state as the thumb rather than tracked beside it.
Why a binding and not an inline handler
An inline `oninput="…"` attribute would need no runtime at all — and would need `script-src 'unsafe-inline'`, which the policy a packaged Rahti application serves does not grant. The runtime is already covered by the `'unsafe-eval'` it does grant, so the handler goes through PulsePoint. That is why every slider on this page mounts a block, and why `needs_scope` is true for all of them.
What still works with the runtime absent is the whole control: drag, arrow keys, `Home`/`End`, the value in the form post, and the announcement to a screen reader. Only the paint behind the thumb stops following, and only on two of the three engines.
What this port changed
shadcn's Slider is four elements — a Root, a Track, a Range that fills it, and a Thumb per value — over Radix's pointer maths. This is one `<input type="range">`, and the other three are pseudo-elements the class list reaches through.
| shadcn | here | why |
|---|---|---|
| Root / Track / Range / Thumb | one input, three pseudo-elements | Each of shadcn's utilities moves to the selector for the part it styled — twice for the parts the two engines name differently. |
| — | h-4 on the input | shadcn's Root is the height of its track and lets the Thumb overflow. An input clips its own thumb, so the input is the thumb's height and `-mt-[5px]` — which is `(6 - 16) / 2` — centres it on the 6px track. |
| value={[25, 75]} | — not ported | Radix renders a thumb per entry. One input has one value and one thumb, and there is no native two-ended range — two inputs overlaid plus the code to stop them crossing is a different component. |
| orientation="vertical" | — not ported | A native vertical range is `writing-mode: vertical-lr`, which is recent in every engine and needs a second copy of every track and thumb utility with its axis swapped. Worth doing; not done here. |
| — (Radix does it in JS) | drag, keys, touch, ARIA | Arrow keys, `Home`/`End`, `Page Up`/`Page Down`, a touch target the browser sizes for the device, and the value read out — none of it written. |
Everything else is shadcn's, copied rather than reinterpreted — including the `bg-white` thumb, which is white in both themes because shadcn's is.
The component tag, and its boundary
The tag is the right call for a slider this page does not drive — a `name`, a `value`, a `min` and a `max`. What a plain form posts, the tag renders.
Where the tag stops
Twice, and the second is specific to this component. A binding does not cross the boundary: it is compiled in the scope the element is mounted in, which through a tag is the component's.
// Compiles against a scope with no `volume`, and does nothing.
<Slider value={volume} oninput={setVolume(target.value)} />And an `oninput` written at a call site *replaces* the fill handler rather than running beside it — so a page that writes one has to say both things: write `FILL_EXPRESSION` (exported for exactly this) beside its own expression, or hand the fill to `.fill_from(…)` as the controlled section below does.
Controlled, page scope
A slider reports itself through `target.value`, and `input` fires on every step of a drag where `change` waits for the release. This block has state, so the fill is state too — `.fill_from("volume")` hands `--slider-fill` to the runtime, which recomputes it on every render instead of re-applying the one the server wrote. The section above the port table says what happens without it.
{volume >= 80 ? "Loud." : volume >= 30 ? "Comfortable." : "Quiet."}
Both buttons move the thumb and the fill with it, because `value` is bound and the fill is derived from the same state. Drag the thumb and the readout follows; press a button and the thumb follows.
Drag either slider and both move, with the readout and the buttons in step — `value` is bound to `volume` and the fill is `.fill_from("volume")` on both, so everything here is derived from the one piece of state. Without `.fill_from(…)` this one would flicker: its handler would paint the fill, the state change would re-render the block, and the server's `style` would come back and overwrite it on every tick.