Slider
The slider is the native range input wearing the library's skin: a flat track, an opaque cap that grows under your pointer, marks that change shade as the fill passes them, and an optional value bubble. One thumb or two — the dual range is two real inputs stacked, not divs with drag code — so the keyboard, aria-valuenow, form submission and react-hook-form registration all come from the browser. What JavaScript remains writes three numbers as custom properties and referees the dual gesture; everything painted is CSS.
- Stable
- React 19
- Native input
- react-hook-form
A single slider. The fill runs from --wayra-slider-a to --wayra-slider-b; the thumb is the input's own, restyled.
Code
<Slider label="Volumen" defaultValue={40} onValueChange={setVolume} /><div class="wayra-slider" style="--wayra-slider-a: 0; --wayra-slider-b: 40; --wayra-slider-bubble: 40"> <div class="wayra-slider-control"> <div class="wayra-slider-groove" aria-hidden="true"></div>
<input class="wayra-slider-input" type="range" min="0" max="100" step="1" value="40" aria-label="Volumen">
</div></div>One native <input type="range"> under the paint. min, max and step
default to 0 / 100 / 1 and pass straight through; value /
defaultValue / onValueChange are the committed-value trio — the native
onChange is not exposed, because in range mode there are two inputs and one
callback. label, helper and error wrap the control in the same Label
markup every field shares; labelPlacement puts the label top (the default)
or left, since a slider fills its row and has no right or floating.
Code
<Slider size="xs" … /> <Slider size="sm" … /> <Slider … /> {/* md, the default */}<Slider size="lg" … /> <Slider size="xl" … /><div class="wayra-slider" data-size="xs" style="--wayra-slider-a: 0; --wayra-slider-b: 55; --wayra-slider-bubble: 55"> <div class="wayra-slider-control"> <div class="wayra-slider-groove" aria-hidden="true"></div>
<input class="wayra-slider-input" type="range" min="0" max="100" step="1" value="55" aria-label="xs">
</div></div><div class="wayra-slider" data-size="sm" style="--wayra-slider-a: 0; --wayra-slider-b: 55; --wayra-slider-bubble: 55"> <div class="wayra-slider-control"> <div class="wayra-slider-groove" aria-hidden="true"></div>
<input class="wayra-slider-input" type="range" min="0" max="100" step="1" value="55" aria-label="sm">
</div></div><div class="wayra-slider" style="--wayra-slider-a: 0; --wayra-slider-b: 55; --wayra-slider-bubble: 55"> <div class="wayra-slider-control"> <div class="wayra-slider-groove" aria-hidden="true"></div>
<input class="wayra-slider-input" type="range" min="0" max="100" step="1" value="55" aria-label="md">
</div></div><div class="wayra-slider" data-size="lg" style="--wayra-slider-a: 0; --wayra-slider-b: 55; --wayra-slider-bubble: 55"> <div class="wayra-slider-control"> <div class="wayra-slider-groove" aria-hidden="true"></div>
<input class="wayra-slider-input" type="range" min="0" max="100" step="1" value="55" aria-label="lg">
</div></div><div class="wayra-slider" data-size="xl" style="--wayra-slider-a: 0; --wayra-slider-b: 55; --wayra-slider-bubble: 55"> <div class="wayra-slider-control"> <div class="wayra-slider-groove" aria-hidden="true"></div>
<input class="wayra-slider-input" type="range" min="0" max="100" step="1" value="55" aria-label="xl">
</div></div>Five steps off one ladder: the track walks 4 / 6 / 6 / 8 / 8px at the default unit and the cap is exactly twice it — 8 / 12 / 12 / 16 / 16px. Every value is even on purpose, which is what keeps the groove, the marks and the cap’s overhang on whole pixels at any device ratio.
The ladder ties twice — sm draws like md, lg like xl — because the
even-pixel steps repeat at the half-step ends. That is the price of one
number per size; re-point the five steps to distinct values and the pairs
separate again.
The pointer box never drops under WCAG 2.5.8’s 24px floor: the floor grows the hit area, not the drawing.
Colour
Section titled “Colour”Code
<Slider … /> {/* primary, the default */}<Slider color="success" … /><Slider color="error" … /><div class="wayra-slider" style="--wayra-slider-a: 0; --wayra-slider-b: 45; --wayra-slider-bubble: 45"> <div class="wayra-slider-control"> <div class="wayra-slider-groove" aria-hidden="true"></div>
<input class="wayra-slider-input" type="range" min="0" max="100" step="1" value="45" aria-label="primary">
</div></div><div class="wayra-slider" data-color="success" style="--wayra-slider-a: 0; --wayra-slider-b: 60; --wayra-slider-bubble: 60"> <div class="wayra-slider-control"> <div class="wayra-slider-groove" aria-hidden="true"></div>
<input class="wayra-slider-input" type="range" min="0" max="100" step="1" value="60" aria-label="success">
</div></div><div class="wayra-slider" data-color="error" style="--wayra-slider-a: 0; --wayra-slider-b: 75; --wayra-slider-bubble: 75"> <div class="wayra-slider-control"> <div class="wayra-slider-groove" aria-hidden="true"></div>
<input class="wayra-slider-input" type="range" min="0" max="100" step="1" value="75" aria-label="error">
</div></div>color re-points the accent that paints the fill, the bubble, an accent thumb
and the focus ring together — one variable, the library’s two-token contract.
An error prop (or aria-invalid) re-points the same accent to the error
role, so an invalid slider turns red everywhere at once, ring included.
Code
<Slider marks step={10} … /> {/* one mark per step */}<Slider step={25} marks={[{ value: 0, label: 'Borrador' }, { value: 25, label: 'Baja' },{ value: 50, label: 'Media' }, { value: 75, label: 'Alta' },{ value: 100, label: 'Máxima' },]} … /><div class="wayra-slider" style="--wayra-slider-a: 0; --wayra-slider-b: 40; --wayra-slider-bubble: 40"> <div class="wayra-slider-control"> <div class="wayra-slider-groove" aria-hidden="true"></div> <div class="wayra-slider-marks" aria-hidden="true"> <span class="wayra-slider-mark" style="--wayra-slider-pos: 0"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 10"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 20"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 30"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 40"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 50"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 60"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 70"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 80"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 90"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 100"></span> </div> <div class="wayra-slider-marks" data-fill="true" aria-hidden="true"> <span class="wayra-slider-mark" style="--wayra-slider-pos: 0"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 10"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 20"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 30"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 40"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 50"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 60"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 70"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 80"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 90"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 100"></span> </div>
<input class="wayra-slider-input" type="range" min="0" max="100" step="10" value="40" aria-label="Puntos">
</div></div><div class="wayra-slider" style="--wayra-slider-a: 0; --wayra-slider-b: 50; --wayra-slider-bubble: 50"> <div class="wayra-slider-control"> <div class="wayra-slider-groove" aria-hidden="true"></div> <div class="wayra-slider-marks" aria-hidden="true"> <span class="wayra-slider-mark" style="--wayra-slider-pos: 0"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 25"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 50"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 75"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 100"></span> </div> <div class="wayra-slider-marks" data-fill="true" aria-hidden="true"> <span class="wayra-slider-mark" style="--wayra-slider-pos: 0"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 25"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 50"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 75"></span> <span class="wayra-slider-mark" style="--wayra-slider-pos: 100"></span> </div>
<input class="wayra-slider-input" type="range" min="0" max="100" step="25" value="50" aria-label="Resolución"> <div class="wayra-slider-mark-labels" aria-hidden="true"> <span class="wayra-slider-mark-label" style="--wayra-slider-pos: 0">Borrador</span> <span class="wayra-slider-mark-label" style="--wayra-slider-pos: 25">Baja</span> <span class="wayra-slider-mark-label" style="--wayra-slider-pos: 50">Media</span> <span class="wayra-slider-mark-label" style="--wayra-slider-pos: 75">Alta</span> <span class="wayra-slider-mark-label" style="--wayra-slider-pos: 100">Máxima</span> </div> </div></div>Marks sit at their exact share of the track — 0 at the start edge, 100 at the end edge — and the thumb overhangs the track by half a cap at the extremes so its center always lands on the mark (give the slider that half-cap of breathing room in a container that clips).
Every mark is the same shape: a round pip inscribed in the track, so it never overshoots it and the ones on the ends stay inside the rounded cap. Its box is the track’s own thickness, which is what keeps the two sharing a subpixel phase and the pip dead-centred at any device ratio; the visible circle is a gradient inside that box, so it stays round wherever the pixels fall.
marks is true for one mark per step, or an array of { value, label? }.
The marks a fill has passed change shade with no JavaScript: there are two
identical mark layers, and the top one — accent-side — is clipped to the fill
region with clip-path driven by the same two custom properties (the Rating
technique). Labels render under the track, decorative and aria-hidden; when
the current value lands on a mark whose label is a string, that string becomes
the input’s aria-valuetext, so a screen reader hears Media, not 50.
Marks are not buttons. The legacy component made every mark a tab stop —
a slider with ten marks was eleven stops. Here the track press already reaches
any value, and snapToMarks makes pointer gestures land on the nearest mark
(the keyboard keeps the native step). A mark the step cannot reach — 33 on a
slider that steps by 10 — lands on the step it rounds to, because the input
could not store it: the thumb, the fill, onValueChange and the form all get
30. Put the marks on the step, or pass the step the marks need.
The bubble
Section titled “The bubble”Code
<Slider bubble="hover" … /> {/* appears on hover and on keyboard focus */}<Slider bubble="always" … /><div class="wayra-slider" data-bubble="always" style="--wayra-slider-a: 0; --wayra-slider-b: 60; --wayra-slider-bubble: 60"> <div class="wayra-slider-control"> <div class="wayra-slider-groove" aria-hidden="true"></div>
<span class="wayra-slider-bubble" aria-hidden="true">60</span> <input class="wayra-slider-input" type="range" min="0" max="100" step="1" value="60" aria-label="Valor">
</div></div>bubble="hover" fades the readout in while the control is hovered or holds
focus; "always" keeps it up and reserves the band above the track so it
never sits on the content before it. It is painted accent-on-accent-content
and set in tabular numerals. When the value matches a labelled mark, the
bubble shows the label.
Dual range
Section titled “Dual range”Two real inputs stacked. Tab reaches each thumb; arrows move the one that holds focus.
Code
<Sliderrangemin={0} max={1000} step={10}defaultValue={[120, 780]}startLabel="Precio mínimo"endLabel="Precio máximo"onValueChange={([lo, hi]) => …}/><div class="wayra-slider" data-range="true" style="--wayra-slider-a: 12; --wayra-slider-b: 78; --wayra-slider-bubble: 78"> <div class="wayra-slider-control"> <div class="wayra-slider-groove" aria-hidden="true"></div> <input class="wayra-slider-input" type="range" data-edge="start" min="0" max="100" value="12" aria-label="Mínimo"> <input class="wayra-slider-input" type="range" data-edge="end" data-front="true" min="0" max="100" value="78" aria-label="Máximo"> </div></div>range adds a second native input. Each thumb is its own tab stop with its
own required accessible name (startLabel / endLabel) — the APG shape for
a dual slider, and the reason both are translated by construction.
Three rules govern the pair:
- They clamp, never cross. The start thumb stops at the end thumb and
vice versa; the pair you get from
onValueChangeis always ordered. They can overlap, and while they do the front thumb wears a double ring so you can see there are two. - The track press moves the nearest thumb, and dragging on from the press keeps moving it (pointer capture). On a tie — both thumbs under the press — the one that can travel toward the click wins, so neither gets trapped. Only the main button and the first finger press; a right click or a second finger moves nothing. The thumb comes forward, and the bubble follows it, when it is pressed, moved or focused.
- A press on a thumb grabs it where you pressed. The value does not move
on the press, and a drag moves the thumb by exactly the distance the pointer
travels, so the thumb never jumps to centre itself under the pointer. Only a
press on the bare track jumps.
snapToMarksdoes the same with one thumb. - The dragged thumb looks pressed for the whole drag — the ring, the
1.16 scale and the
grabbingcursor of:active. A track press lands on the track, not on the input, so the input is never:activethen; the component marks itdata-draggingwhile the drag lasts, and the same rule paints both. - The whole input is pointer-transparent except its thumb, which is what lets the lower input stay reachable where the upper one has no thumb.
Accent thumb
Section titled “Accent thumb”Code
<Slider … /> {/* surface cap, the default */}<Slider thumb="accent" … /><div class="wayra-slider" style="--wayra-slider-a: 0; --wayra-slider-b: 55; --wayra-slider-bubble: 55"> <div class="wayra-slider-control"> <div class="wayra-slider-groove" aria-hidden="true"></div>
<input class="wayra-slider-input" type="range" min="0" max="100" step="1" value="55" aria-label="surface">
</div></div><div class="wayra-slider" data-thumb="accent" style="--wayra-slider-a: 0; --wayra-slider-b: 55; --wayra-slider-bubble: 55"> <div class="wayra-slider-control"> <div class="wayra-slider-groove" aria-hidden="true"></div>
<input class="wayra-slider-input" type="range" min="0" max="100" step="1" value="55" aria-label="accent">
</div></div>The default cap is a flat opaque surface with a crisp hairline — the classic
slider thumb. thumb="accent" fills it with the role instead and darkens the
hairline one step, the same step Button’s filled hover takes.
Vertical
Section titled “Vertical”Code
<Slider orientation="vertical" … /><Slider orientation="vertical" range startLabel="Mínima" endLabel="Máxima" … /><div style="display: flex; gap: 3.5rem; align-items: flex-start"><div class="wayra-slider" data-orientation="vertical" style="--wayra-slider-a: 0; --wayra-slider-b: 70; --wayra-slider-bubble: 70"> <div class="wayra-slider-control"> <div class="wayra-slider-groove" aria-hidden="true"></div>
<input class="wayra-slider-input" type="range" min="0" max="100" step="1" value="70" aria-label="Graves">
</div></div><div class="wayra-slider" data-range="true" data-orientation="vertical" data-color="success" style="--wayra-slider-a: 32; --wayra-slider-b: 64; --wayra-slider-bubble: 64"> <div class="wayra-slider-control"> <div class="wayra-slider-groove" aria-hidden="true"></div> <input class="wayra-slider-input" type="range" data-edge="start" min="10" max="35" value="18" aria-label="Mínimo"> <input class="wayra-slider-input" type="range" data-edge="end" data-front="true" min="10" max="35" value="26" aria-label="Máximo"> </div></div></div>Vertical rides writing-mode: vertical-lr with the minimum at the bottom —
the modern replacement for the obsolete slider-vertical appearance, and the
form the browser announces correctly. Its own browser tier: vertical form
controls need Chrome 120 / Safari 17.4. Below that tier the slider still
works — keyboard fully, the thumb travelling horizontally inside the vertical
box — the same way full theming has its light-dark() tier in the README.
react-hook-form
Section titled “react-hook-form”<Form form={form} onSubmit={onSubmit}> <Slider name="volumen" label="Volumen" /> <Slider range name="precio" startLabel="Mínimo" endLabel="Máximo" /></Form>A single slider registers name and posts one value. A dual slider registers
precio.start and precio.end — one per native input — so the payload
nests as { precio: { start, end } }. The halves are strings, which is
react-hook-form’s rule for native inputs, the same as Rating’s score. Context
errors fill the hint slot and mark both inputs invalid; the fields freeze
while the form submits; noRhf opts out. Outside a Form,
{...register("volumen")} spreads onto the component like any other prop.
Inside a Form the store holds the value, so reset() moves the thumbs with
it. With no defaultValues the store has nothing to go back to: the thumbs
return to defaultValue (else min, or [min, max] for a range), and that
is what the next submit posts.
Pick name or value. Inside a Form a named slider paints the store and
posts the store, and a value passed next to name is ignored, with one
console warning — the rule Rating and PinCode follow. It never seeds the store
either: an empty store falls back to defaultValue. To move a form field from
code, call form.setValue. Outside a Form, or with noRhf, value controls
the slider as usual.
A track press takes the same path as an arrow key: the browser writes the
value and fires input. So the form’s mode decides when a press validates,
exactly as it does for the keyboard, and only leaving the thumb marks the
field touched.
Hand-written markup
Section titled “Hand-written markup”Every preview on this page is React-free. The skin needs the classes, the
data-* attributes and three numbers:
<div class="wayra-slider" style="--wayra-slider-a: 0; --wayra-slider-b: 60"> <div class="wayra-slider-control"> <div class="wayra-slider-groove" aria-hidden="true"></div> <input class="wayra-slider-input" type="range" value="60" aria-label="Volumen"> </div></div>const root = document.querySelector('.wayra-slider');root.addEventListener('input', (e) => { root.style.setProperty('--wayra-slider-b', e.target.value); // 0–100 scale});The thumb, keyboard and announcements are the input’s own, so unlike most
composite widgets nothing behavioural is lost by going manual — the dual
gesture (nearest thumb, clamp, the grab offset, data-dragging) is the one
part you would have to rebuild.
Every data-* is optional here. The component writes all of them, defaults
included — data-color="primary", data-size="md",
data-orientation="horizontal", data-thumb="surface" — and a missing one
reads as its default. The presence flags are written only while they are on:
data-range, data-marks, and data-snap-to-marks, which gives the control
touch-action: none for the component’s own gesture and has no use without
it.
Public CSS knobs
Section titled “Public CSS knobs”| Property | Default | What it moves |
|---|---|---|
--wayra-slider-track |
the size step — 6px at md |
Groove thickness, and the component’s only ladder. Even at every step on purpose, so marks center on whole pixels. |
--wayra-slider-thumb |
track × 2 — 12px at md |
Cap diameter. Derived, so overriding the track resizes the whole control; set this to break the ratio for one instance. |
--wayra-slider-length |
100% |
Inline length of the control. |
--wayra-slider-vertical-length |
12rem |
Block length in vertical. |
--wayra-slider-a / -b / -bubble / -pos |
written by the component | The paint contract: fill ends, readout and mark positions, 0–100. |
API reference
Section titled “API reference”SliderProps is the union SliderSingleProps | SliderRangeProps, discriminated by range.
Both extend ComponentProps<"div"> minus aria-label, aria-labelledby, color,
defaultValue, onChange and ref; the leftover props spread onto the root <div>.
| Prop | Type | Default | Description |
|---|---|---|---|
color |
WayraColor |
"primary" |
The fill, the bubble, an accent thumb and the ring. |
size |
"xs" | "sm" | "md" | "lg" | "xl" |
"md" |
Track thickness and cap diameter. |
orientation |
"horizontal" | "vertical" |
"horizontal" |
Vertical has its own browser tier (see Vertical). |
min |
number |
0 |
Low end of the scale. |
max |
number |
100 |
High end of the scale. |
step |
number |
1 |
Selectable distance, and the arrow-key increment. |
marks |
boolean | readonly SliderMark[] |
false |
true is one per step; an array of { value, label? } places them. |
snapToMarks |
boolean |
false |
Pointer gestures land on the nearest mark; the keyboard keeps step. |
bubble |
"hover" | "always" |
— | The value readout. Unset, no bubble renders. |
thumb |
"surface" | "accent" |
"surface" |
Neutral cap, or filled with the role. |
label |
ReactNode |
— | Wraps the slider in the shared Label markup. |
labelPlacement |
"top" | "left" |
"top" |
Above the track or beside it. A slider fills its row, so right and floating are not offered. |
helper |
ReactNode |
— | Hint under the track, in every thumb’s aria-describedby. |
error |
ReactNode |
— | Takes the hint slot, adds role="alert", sets aria-invalid. Beats the form’s message. |
name |
string |
— | The form path; a range registers ${name}.start and ${name}.end. |
disabled |
boolean |
— | Disables every thumb. A submitting Form sets it unless you pass your own. |
noRhf |
boolean |
false |
Opt out of the surrounding Form. |
aria-label |
string |
— | Lands on the input; in a range it prefixes both thumb names. |
aria-labelledby |
string |
— | Lands on the input and outranks aria-label. |
unstyled |
boolean |
false |
Drops the classes, keeps the data attributes. |
Single (range omitted or false):
| Prop | Type | Default | Description |
|---|---|---|---|
value |
number |
— | Controlled value. Ignored inside a Form on a named field. |
defaultValue |
number |
min |
Uncontrolled seed. |
onValueChange |
(value: number) => void |
— | The committed value, after a drag, a click or an arrow key. |
ref |
Ref<HTMLInputElement> |
— | The <input type="range">. |
Range (range: true):
| Prop | Type | Default | Description |
|---|---|---|---|
startLabel |
string |
required | Accessible name of the lower thumb. |
endLabel |
string |
required | Accessible name of the upper thumb. |
value |
readonly [number, number] |
— | Controlled pair. Ignored inside a Form on a named field. |
defaultValue |
readonly [number, number] |
[min, max] |
Uncontrolled seed. |
onValueChange |
(value: [number, number]) => void |
— | The committed pair, always ordered. |
ref |
Ref<HTMLInputElement> |
— | The start input, the lower thumb. |
id lands on the lead input (the only one, or the start thumb), which is what a wrapping
label points at.
Accessibility
Section titled “Accessibility”- The keyboard is the browser’s: arrows step, Home/End jump to the ends,
PageUp/PageDown take the UA’s larger step — per thumb, in both
orientations, mirrored under
dir="rtl"with no extra code. The paint reads the direction from adir="rtl"on the slider or any ancestor, which works down to the Chrome 111 floor; where:dir()exists (Chrome 120+) it also puts an LTR island inside RTL back the right way. - Dual = two named stops.
startLabelandendLabelare required props, not defaults, so the pair is translated by construction. - Named without a visible label:
aria-labelandaria-labelledbyland on the input, never on the wrapper<div>. In a range,aria-labelfolds into both thumb names the way a stringlabeldoes (Precio: Mínimo), andaria-labelledbyis read ahead of each thumb’s own name (Precio Mínimo) — each thumb references the ids you pass and then itself, which is why a bare range gives its thumbs ids. aria-valuetextspeaks mark labels when the value sits on one; marks, groove, labels and bubble are allaria-hiddendecoration.- The focus ring rides the thumb, not the box —
:focus-visible, in the accent, re-pointed by the error state like every field. Under forced colors the fill turnsHighlight, the capButtonFace/ButtonText, the marks turn solid dots in the text colour (HighlightTextover the fill), and the ring is redrawn as aCanvasTextoutline. - Disabled fades to the shared 0.4 (0.65 under
prefers-contrast: more); reduced motion zeroes the grab and bubble transitions by re-pointing one duration variable.
Do and don’t
Section titled “Do and don’t”Do
Marks for a handful of meaningful stops, labels on the ones worth naming, snapToMarks if only those stops are valid.
Don't
A mark per unit on a 0–100 scale. The track becomes noise and no mark means anything — if every value is valid, the groove alone already says so.