Skip to content

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.

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.

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.

Two real inputs stacked. Tab reaches each thumb; arrows move the one that holds focus.

Code
<Slider
range
min={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 onValueChange is 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. snapToMarks does the same with one thumb.
  • The dragged thumb looks pressed for the whole drag — the ring, the 1.16 scale and the grabbing cursor of :active. A track press lands on the track, not on the input, so the input is never :active then; the component marks it data-dragging while 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.
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.

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.

<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.

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.

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.

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.

  • 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 a dir="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. startLabel and endLabel are required props, not defaults, so the pair is translated by construction.
  • Named without a visible label: aria-label and aria-labelledby land on the input, never on the wrapper <div>. In a range, aria-label folds into both thumb names the way a string label does (Precio: Mínimo), and aria-labelledby is 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-valuetext speaks mark labels when the value sits on one; marks, groove, labels and bubble are all aria-hidden decoration.
  • 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 turns Highlight, the cap ButtonFace/ButtonText, the marks turn solid dots in the text colour (HighlightText over the fill), and the ring is redrawn as a CanvasText outline.
  • 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

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.