<slider-elemental>
One <input type="range"> inside it is a slider; two is a range, with a low thumb and a
high one that cannot pass each other. The thumbs stay native inputs, so the whole
APG Slider pattern is the browser's.
Light DOM, no shadow root, nothing moved or wrapped โ the one thing it ever inserts is the
value bubble, and only where you asked for one.
<span id="price-label">Price</span>
<slider-elemental aria-labelledby="price-label" gap="50">
<input type="range" aria-label="Lowest price" min="0" max="1000" step="10" value="200" />
<input type="range" aria-label="Highest price" min="0" max="1000" step="10" value="750" />
</slider-elemental>
Usage
Write the range input you would have written anyway and wrap it. Write two and it becomes
a range โ the thumb count is the markup, not an attribute. Inputs must be direct children;
nothing else is enforced. Edit the sample and the preview above it follows as you type โ
add a second <input type="range"> and watch it grow a thumb:
<label for="volume">Volume</label>
<slider-elemental>
<input type="range" id="volume" name="volume" min="0" max="100" value="40" />
<output>40</output>
</slider-elemental>import 'book-of-elementals/slider';
@use "book-of-elementals/slider/style.scss"; // structure
@use "book-of-elementals/slider/theme.scss"; // the look, optional
Or the single-element bundle โ no build step, no script to write:
<script src="https://unpkg.com/book-of-elementals/dist/elementals/slider.min.js"></script>
<link
rel="stylesheet"
href="https://unpkg.com/book-of-elementals/dist/elementals/slider.min.css"
/>
<link
rel="stylesheet"
href="https://unpkg.com/book-of-elementals/dist/elementals/slider-theme.min.css"
/>
It registers itself on include and upgrades on connect. Nothing on window, nothing to
instantiate, no init call to forget.
What the element does, and what the browser does
Nearly all of it is the browser's, because the thumbs are real range inputs:
| Behaviour | Whose |
|---|---|
| Arrows, Home, End, PageUp/PageDown | the browser |
| Dragging a thumb, and touch | the browser |
step, min, max, and snapping to them |
the browser |
| Announced as a slider, with its value | the browser |
name submits, and reset, restore and <fieldset disabled> |
the browser |
| Where the thumbs are, as something CSS can read | this element |
| Two thumbs sharing one track, and not passing each other | this element |
| A press on the track, which stacking would otherwise eat | this element |
Keeping an <output> in step |
this element |
The pointer value bubble, where tooltip asked for one |
this element |
So there is no role="slider" written here, no aria-valuenow, and no event of its own โ
a range input fires input and change, and both bubble.
API
Attributes
| Attribute | Type | Default | Description |
|---|---|---|---|
gap |
number | 0 |
Least distance between the two thumbs, in the scale's own units. Ignored with one thumb. |
tooltip |
token list | absent | A value bubble that follows the pointer. thumb over the thumb it is on, track for the value under it elsewhere, thumb track for both; a bare tooltip is thumb. See below |
There is no attribute for the axis. A track runs down the page when the CSS says so โ see below.
What it writes on itself
| What | Value |
|---|---|
--slider-elemental-start |
Where the low thumb is, 0 to 1. Always 0 with one thumb |
--slider-elemental-end |
Where the high โ or only โ thumb is, 0 to 1 |
data-stacked |
start or end while the two are on the same value: which one is on top |
role="group" |
Two thumbs, and only if you gave it a name, see below |
They are ratios rather than percentages, and that is the point โ see below.
With tooltip set there is one more thing, and it is the only markup this element ever
writes: an <output aria-hidden="true" data-tooltip="thumb|track"> appended as the last
child, carrying --slider-elemental-at โ where the bubble is, on that same 0 to 1
scale. It also carries data-vertical and data-reversed while the track runs down the
page or backwards, because centring it is a translate and translate has no logical form.
It goes again when the attribute does, or when the element leaves the page.
Properties
| Property | Type | Description |
|---|---|---|
inputs |
HTMLInputElement[] |
Read-only. The thumbs' inputs, in document order. |
outputs |
HTMLOutputElement[] |
Read-only. The readouts, in document order, from anywhere inside โ yours, never the tooltip bubble. |
gap |
number | Get/set. Writes the attribute. |
format |
?function |
Get/set. What the tooltip bubble says โ (value, element), returning what lands in it. See below |
clamp(moved) |
โ | Re-apply the gap after moving a value from script. 'start' or 'end' says which one gives way. |
apply() |
โ | Re-read the inputs. Call it after moving a value, min or max from script, or swapping an input out. |
Events
None of its own. The inputs are native, so use input while dragging and change when it
settles โ both fire on the input and bubble, which means one listener on the element, or on
the form, hears every thumb:
const price = document.querySelector('slider-elemental');
price.addEventListener('input', (e) => e.target.value); // during the drag
price.addEventListener('change', (e) => e.target.value); // when it settles
The values are already clamped by the time either reaches you. The element listens in the capture phase for exactly that reason: in the bubble phase your handler would run first and read a value about to be taken back.
Moving a value from script fires nothing โ that is the platform's rule for every form
control, not this element's. Call apply() after:
price.inputs[1].value = 600;
price.apply();
The same call covers a scale that moved rather than a value: the fill is a ratio against
min and max, so a new max leaves every one of them stale even though no thumb was
touched. A media scrubber does both โ max once the duration is known, then the value on
every tick โ and the media player example
is that pair written out.
Styling hooks
| Selector | What it is |
|---|---|
slider-elemental |
The control. Track and fill are drawn on it |
slider-elemental[data-stacked] |
While both thumbs are on one value |
slider-elemental:not(:defined) |
Before the script has run, or without it |
slider-elemental > input[type="range"] |
A thumb's input |
slider-elemental:has(> input[type="range"] ~ input[type="range"]) |
Two thumbs rather than one |
slider-elemental > output[data-tooltip] |
The value bubble, showing or hidden |
slider-elemental > output[data-tooltip="thumb"] / ="track" |
Which of the two it is showing |
slider-elemental > output[data-tooltip][data-vertical] |
The bubble on a track running down the page |
One thumb, or two
Nothing configures this โ the element counts its inputs:
<!-- a slider -->
<slider-elemental>
<input type="range" name="volume" min="0" max="100" value="40" />
</slider-elemental>
<!-- a range -->
<slider-elemental aria-label="Price">
<input type="range" aria-label="Lowest" min="0" max="1000" step="10" value="200" />
<input type="range" aria-label="Highest" min="0" max="1000" step="10" value="750" />
</slider-elemental>
Two thumbs share one scale, so both inputs need the same min, max and step โ the
element reads them off the first, because two rulers drawn on top of each other is not a
control anyone can use. A third input still works as a plain range input; it is not clamped
and not drawn, because the fill is between the first two.
Give a two-thumb control a bigger thumb. Stacked, the two inputs sit exactly on top of each other, so neither has any clear space around it and each is judged on its own size against WCAG 2.2's 24px target minimum โ which the
1remdefault is 8px short of, on either axis. One declaration answers it:slider-elemental:has(> input[type="range"] ~ input[type="range"]) { --slider-elemental-thumb-size: 1.5rem; }The theme does not do this for you. The fix is a size, and a stylesheet that silently made one control a third thicker than the next would be deciding a layout question on your page's behalf โ so it is named here rather than taken. One thumb needs nothing: it has room around it and passes on the spacing clause instead.
Down the page
There is no vertical attribute, because writing-mode already is one. It is
the platform's switch for turning a form control on its side,
the browser keeps the whole keyboard and pointer contract across it, and the track, the fill
and the thumbs here are drawn in logical properties โ so all three turn with it and there is
nothing to configure. The element reads the writing mode and measures the pointer down the
page instead of across it, which is the one part CSS cannot do for itself:
<label for="temperature">Temperature</label>
<slider-elemental class="vertical" tooltip>
<input type="range" id="temperature" name="temperature" min="0" max="40" value="21" />
</slider-elemental>
slider-elemental.vertical {
writing-mode: vertical-rl;
direction: rtl;
height: 10rem;
}
/* Room for the bubble, which hangs off the right of the control and outside its box */
body {
padding-inline-end: 4rem;
}Three declarations, and each settles a different thing:
| Declaration | What it settles |
|---|---|
writing-mode: vertical-rl |
The track runs down the page. vertical-lr does the same thing โ the two differ in which way the block axis runs, and a slider has nothing on that axis to show it, so pick either |
direction: rtl |
The minimum is at the bottom. Without it it is at the top โ MDN: "including direction: ltr on a vertical slider sets the lowest value at the top of the slider and the highest value at the bottom" โ which is right for a scrollbar and wrong for a volume knob |
height |
A track down the page has no length of its own, exactly as one across the page has no width of its own. Without it there is nothing to drag along |
Put them on the element, not on the input. The element is the box the track and the fill are drawn on, so it is the writing mode they resolve against โ turn the input alone and you get a thumb running one way over a track running the other. Nothing here reads an input's own writing mode.
direction is the axis here and not the language: in a vertical writing mode it says which
end the minimum is at and nothing else. That is why the value bubble is laid back out
horizontally and left to decide its own text direction from its own content โ laid out in the
control's, it would be a number on its side in an order nothing asked for. It hangs off the
right of a vertical control, a --slider-elemental-tooltip-gap clear of it, rather than
above; move it with left if that is the wrong side for where the control sits.
An <output> you put inside is your markup and keeps the writing mode it inherits, so a
readout in a vertical slider wants a writing-mode: horizontal-tb of its own.
Two thumbs turn over with it. The press that jumps the nearer thumb is measured down the
track, the clamp is the same clamp, and gap is still in the scale's own units:
<span id="band-label">Frequency band</span>
<slider-elemental
class="vertical"
aria-labelledby="band-label"
gap="10"
tooltip="thumb track"
>
<input type="range" aria-label="Lowest" min="0" max="100" value="30" />
<input type="range" aria-label="Highest" min="0" max="100" value="70" />
</slider-elemental>
slider-elemental.vertical {
writing-mode: vertical-rl;
direction: rtl;
height: 12rem;
/* 24px, so two stacked thumbs meet WCAG 2.2 target size โ see below */
--slider-elemental-thumb-size: 1.5rem;
}
body {
padding-inline-end: 4rem;
}Step, and how fast the keyboard moves
There is no step on this element, because there is one on the input already โ it is the
native attribute, it is the browser that reads it, and a second name for it here would be a
second thing to keep in step:
<slider-elemental aria-label="Price" gap="50">
<input type="range" aria-label="Lowest" min="0" max="1000" step="10" value="200" />
<input type="range" aria-label="Highest" min="0" max="1000" step="10" value="750" />
</slider-elemental>
It is worth setting, because the default is 1 and the keyboard is what pays for it โ a
0โ1000 scale left at the default is a thousand presses of โ to cross. The
browser gives you two gears and neither is this element's:
| Key | Moves by | On 0โ1000 |
|---|---|---|
| โ โ โ โ | one step |
1, or 10 above |
| PageUp PageDown | a tenth of the range, whatever step says |
100 |
| Home End | to min or max |
0 / 1000 |
Measured in Chromium and WebKit, which agree. The coarse gear is already there, so a step
is about the fine one: pick the smallest change that means anything on your scale โ 10 on
a price in pounds, 1 on a percentage, 0.1 on a rating.
A gap that is not a whole number of steps still holds, because the thumb gives way to the
notch past where the gap lands rather than the nearest one. step="10" with gap="25"
opens to 30 rather than closing to 20: erring outwards costs at most one notch, and closing
in would be a gap you asked for and quietly did not get.
The gap
gap is the least distance the two thumbs may be apart, in the scale's own units โ 50 on
a 0โ1000 price range is fifty pounds:
<slider-elemental aria-label="Price" gap="50">
<input type="range" aria-label="Lowest" min="0" max="1000" step="10" value="200" />
<input type="range" aria-label="Highest" min="0" max="1000" step="10" value="750" />
</slider-elemental>
The thumb being moved is the one that gives way, so dragging the low thumb into the high one stops it rather than shoving the high one along โ a drag that changes a value nobody touched is a drag that has misread the gesture. The exception is the ends, where it cannot: drag the low thumb to the floor with a gap set and the high thumb is what moves, because the alternative is a range that refuses to reach its own minimum.
With both thumbs on one value, one is exactly on top of the other and out of the pointer's
reach. data-stacked names the one that is lifted โ the one with somewhere to go, which is
the low thumb at the maximum and the high thumb everywhere else. Tab reaches
either one regardless, which is why this decides the pointer only.
The value readout
An <output> child is kept in step, matched to the input at the same index:
<label for="quality">Quality</label>
<slider-elemental>
<input type="range" id="quality" name="quality" min="1" max="10" value="7" />
<output>7</output>
</slider-elemental>No <output>, no readout โ it costs nothing to leave out. The track is centred on the
thumbs rather than on the element, so a readout under the control, a caption, or a pair of
end labels makes the element taller without moving the track off the thumb it belongs to.
The element sets the <output>'s text, so put any decoration outside it rather than
inside, where it would be overwritten:
<slider-elemental>
<input type="range" min="0" max="1000" step="10" value="200" aria-label="Lowest" />
<input type="range" min="0" max="1000" step="10" value="750" aria-label="Highest" />
<p>ยฃ<output>200</output> to ยฃ<output>750</output></p>
</slider-elemental>
An <output> is a live region, so a screen reader announces it changing โ which is one
announcement too many next to a slider that is already announcing its own value on every
arrow key. Add aria-hidden="true" where the readout is only there for the eye.
The value bubble
Read this first: it is a pointer, so a keyboard reader never sees it. The bubble does not
follow focus and there is nothing in it a screen reader is not already told, so it is a hover
for a mouse and a press for a finger and nothing at all for Tab. That is why
nothing goes in here that is not somewhere else too. If the value has to be visible, that is
the <output> above, and the two compose.
tooltip turns it on. Drag the thumb, then run the pointer along the empty part of the
track โ the number is what a press there would set:
<label for="bitrate">Bitrate</label>
<slider-elemental tooltip="thumb track">
<input type="range" id="bitrate" name="bitrate" min="0" max="320" step="16" value="192" />
</slider-elemental>
/* Room for the bubble, which hangs above the control and outside its box */
body {
padding-block-start: 4rem;
}
body > label {
margin-block-end: 2rem;
}The bubble hangs outside the element's box, a --slider-elemental-tooltip-gap clear of
the thumb, so it is drawn over whatever the page put there โ the label in that sample would
be under it without the margin. Leave the room, or move the bubble with the gap; it is not
clipped and it does not push anything aside. Above the control across the page, and to its
right down it.
| Token | Where the bubble appears | What it says |
|---|---|---|
thumb |
while the pointer is on a thumb, dragging included | that input's own value |
track |
while it is anywhere else on the control | the value a press there would set |
thumb track |
both | whichever of the two the pointer is over |
bare tooltip |
same as thumb |
โ |
One bubble, not one per thumb. A pointer is in one place at a time, so a second one
could only ever be a box stacked on the same spot the moment the pointer reached a thumb.
Which one is showing is on the bubble itself as data-tooltip="thumb" or ="track", so a
theme can tell them apart without the element writing two.
A press pins it for the length of the drag. Where the pointer is decides which bubble
you get โ except while a thumb is being dragged, when it is the wrong question twice over: a
thumb snaps to notches while the pointer moves smoothly, so half a step out the pointer is
already beside the thumb it is holding, and dragged past either end it is off the control
altogether. So the press decides once and the release lets go. Without that, a drag across a
step="16" scale flips between the two readings on nearly every pointer move, showing a
number that disagrees with the thumb under it.
It follows the thumb off the control too โ drag below the slider or past its end and the bubble stays, reading the value the thumb is pinned at. It goes when you let go.
On touch the press is the whole of it. A finger is not a hover โ it is on the glass only while it presses โ so the bubble is drawn on the press, carried by the drag, and taken away by the release, wherever on the control the finger lifted. There is no resting pointer to answer to afterwards, and a bubble left parked where a finger last was is the failure this avoids. A fingertip covering the thumb is the one place the number is genuinely hard to read, which is the case for drawing it there rather than against.
The track number is put on the step the way the input would put it โ counted from min,
ties rounded up, and never past the last notch the scale actually has. min="0" max="100" step="40" stops at 80, so that is what the bubble says at the far end rather than 100, which
the input cannot hold. Set step="any" and it is not rounded at all.
Saying something other than the number
Some values are not readable as numbers. 72 on a media scrubber is 01:12, 40 on a
price is โฌ40, and neither is something an attribute could spell โ which is why format is
a property holding a function rather than a token in the markup:
Run the pointer along this one โ the track reads minutes and seconds, not 132:
<label for="seek">Seek</label>
<slider-elemental tooltip="thumb track" id="scrubber">
<input type="range" id="seek" name="seek" min="0" max="600" step="1" value="132" />
</slider-elemental>
const pad = (n) => String(n).padStart(2, '0');
document.getElementById('scrubber').format = (seconds) =>
`${pad(Math.floor(seconds / 60))}:${pad(seconds % 60)}`;
/* Room for the bubble, which hangs above the control and outside its box */
body {
padding-block-start: 4rem;
}
body > label {
margin-block-end: 2rem;
}It is called as (value, element) on every draw, with the value as a number, and what
it returns is what lands in the bubble. Leave it unset and the bubble reads exactly as it
always did: the browser's own spelling of the value, which matters for a step="0.10" scale
where 3.10 is the input's answer and 3.1 is not. A formatter that returns nothing falls
back to that same spelling rather than emptying the bubble, because a function missing a
return should look like a function missing a return and not like a broken element.
It does not change what is announced. The bubble is aria-hidden; the input underneath
announces its own value, and a screen reader still hears the number. If the formatted
version is the one that matters to every reader, it belongs in an
<output> as well โ where it is text on the page rather than a gesture.
What it is not
| Not announced | aria-hidden="true". The input under it announces its own value on every arrow key, and the same number twice is one announcement too many โ which is what Base UI does with its marks |
| Not left behind on touch | a finger has no resting state, so the press draws it and the release takes it away โ there is no tap-to-pin |
| Not shown on focus | a keyboard reader hears the value already; a bubble that appeared on Tab would be a second copy of it, drawn |
| Not formatted by default | the raw value, like the <output>, until you set format |
| Not always on | there is no "pinned" mode. noUiSlider's tooltips: true and MUI's valueLabelDisplay="on" both have one; here that is an <output> positioned with --slider-elemental-end, and no attribute |
Naming it
Each input is a slider in its own right and needs its own name. The element does not invent one, because a name it guessed would be in the wrong language on most of the pages that use it:
<!-- one thumb: a <label> is enough, and it is the input that is labelled -->
<label for="volume">Volume</label>
<slider-elemental>
<input type="range" id="volume" name="volume" min="0" max="100" value="40" />
</slider-elemental>
<!-- two: one name for the pair, one for each end -->
<span id="price-label">Price</span>
<slider-elemental aria-labelledby="price-label">
<input type="range" aria-label="Lowest price" min="0" max="1000" step="10" value="200" />
<input type="range" aria-label="Highest price" min="0" max="1000" step="10" value="750" />
</slider-elemental>
role="group" is written only where the element carries an aria-label or
aria-labelledby, and only with two thumbs. An aria-label on an element with no role is
read by nothing at all, and adding a role to an unnamed group is a wrapper announced for no
reason โ so both halves have to be there or neither is.
In a form
Nothing to do. Each input submits under its own name, exactly as it would unwrapped:
<form>
<slider-elemental aria-label="Price" gap="50">
<input type="range" name="min" aria-label="Lowest" min="0" max="1000" step="10" value="200" />
<input type="range" name="max" aria-label="Highest" min="0" max="1000" step="10" value="750" />
</slider-elemental>
</form>
reset puts both back and the fill follows; a back-navigation restores them and the fill
follows there too, on pageshow. A <fieldset disabled> takes the pair with it, because
it takes the inputs with it.
Why ratios and not percentages
A range input's thumb does not travel the full length of the control. Its centre starts half
a thumb in and stops half a thumb short, so a fill placed at 45% of the box sits next to
a thumb that is not at 45% of the box โ the misalignment nearly every two-input slider on
the web has at its ends. Handing CSS a ratio instead of a percentage is what lets the
stylesheet do the arithmetic that fixes it:
slider-elemental::after {
inset-inline-start: calc(
var(--slider-elemental-start) * (100% - var(--slider-elemental-thumb-size)) +
var(--slider-elemental-thumb-size) / 2
);
inline-size: calc(
(var(--slider-elemental-end) - var(--slider-elemental-start)) *
(100% - var(--slider-elemental-thumb-size))
);
}
That is the theme's own fill rule, and --slider-elemental-at on the
value bubble is spent the same way. Draw something else along the track
โ a tick, a label pinned over a thumb โ with the same two lines and it lands on the thumb
rather than near it.
What is not written, and why
The multi-thumb pattern asks
that a dependent thumb's aria-valuemin / aria-valuemax be updated as the other one
moves. They are not written here, for two reasons:
- The pattern is written for
div[role="slider"]. These are stacked native inputs sharing a track, and pulling the low input'smaxdown to the high one's value would rescale it โ every pixel on that input would then mean a different value from the same pixel on the other, and the two thumbs would stop agreeing about where anything is. - HTML-ARIA says authors should not put
aria-valueminoraria-valuemaxoninput type=rangeat all. The browser computes both frommin,maxandvalue, and an author's copy can only disagree with it.
What a screen reader hears instead is the clamp: press โ on the low thumb once it has reached the high one and the value does not change, which is what the thumb does visually too.
Limits
Named rather than worked around:
| Limit | Why, and what to do |
|---|---|
| A press on the track jumps the nearer thumb but does not carry on into a drag | Two stacked inputs need their pointer events on the thumbs, so the track press is the element's rather than the input's โ and the input never learns it happened. Grab the thumb to drag |
| Two thumbs, not N | The clamp is a pair. Three inputs still work as plain range inputs, unclamped and undrawn |
| Two thumbs on the default thumb are under WCAG 2.2's target size | 16px against a 24px minimum, on either axis, and stacked inputs have no clear space around them to pass on instead. Not taken by the theme, because the fix is a size and that is a layout question โ set it yourself, one declaration |
| A track down the page has to be given a length | writing-mode turns the control, and a block box down the page is as long as its content โ which is the input asking to be as long as the box. Give it a height, see above |
| The writing mode has to be on the element, not on an input | The track and the fill are drawn on the element in logical properties, so that is the box they resolve against. An input turned on its own is a thumb crossing a track that did not turn |
tooltip is pointer-only, and there is no pinned mode |
A keyboard reader never sees it, and a touch reader only while pressing, so nothing goes in it that is not elsewhere too. Pin a value with an <output> placed on --slider-elemental-end |
| The bubble is centred on the thumb and is not clamped to anything | At either end the thumb's centre is half a thumb from the edge, so a bubble wider than that hangs past the control โ and past whatever box the control is in. Nothing here measures the bubble, so nothing can pull it back. Leave room at the ends, or keep tooltip off the slider that sits against an edge |
| The gap is measured from the slider, not from what it is inside | --slider-elemental-tooltip-gap is the distance from this element's own box. A slider inside a padded, bordered bar wants the bar's padding and border added to it, or the bubble lands on the bar's own edge |
| The buffer-style second bar is not here | That is <progress-elemental>, and the two compose โ see the scrubber |
Degrading
Without the script there are no ratios, so the theme draws nothing at all โ that is what
the :defined it hangs off is for. With style.scss loaded either way:
| State | One thumb | Two thumbs |
|---|---|---|
| No script, no theme | The native slider, untouched | Stacked and both grabbable, on no track, and free to pass each other |
| No script, theme imported | The native slider again | The same โ the theme waits for :defined |
| Script, no theme | Native, plus the ratios and the readout | The same, now clamped, with the ratios written |
| Both | The control above | The control above |
The one thing that never happens is the theme drawing a track with the fill parked at zero while the browser has put the thumb somewhere else.
tooltip degrades to nothing in the honest sense: no script, no bubble, and no gap in the
page where one was going to be โ the element writes it, so it is simply never there. With
the script but no theme it is placed over the thumb by style.scss and painted by nothing,
which is a bare number rather than a bubble, because a number landing in the middle of the
layout would be worse than an unstyled one.
The two things that are not optional are in style.scss rather than the theme: with two
thumbs, the stacking and the pointer routing that makes both of them grabbable. A thumb
that cannot be reached is broken rather than unstyled, and the theme is the part that may
be skipped.
The look
theme.scss is optional and separate. It draws the track and the fill on the element and
takes the native track away, including Firefox's ::-moz-range-progress, which is the one
engine that fills the track on its own and would otherwise draw a second fill from the
start.
The rail is half a thumb short at each end, because that is where the thumb's centre starts and stops. A rail run the full length has a stretch at each end the thumb can never reach โ a gap before zero and another past the maximum โ and at those two values the fill and the rail disagree about where the track begins. The thumb overhangs the rail by half its own width at both extremes, which is what the inset buys.
That is also the one way to get this wrong: resize the thumb through
--slider-elemental-thumb-size and everything follows, but resize it by writing
::-webkit-slider-thumb { inline-size: โฆ } yourself and the rail is still inset for the
old size, so the gaps come back at whatever the difference is.
A thumb pseudo-element does not see the page's color. The browser gives the control
one of its own, so currentcolor written inside a ::-webkit-slider-thumb or
::-moz-range-thumb rule resolves to that rather than to the text around it โ white in
Chromium whatever the page says, mid grey in WebKit, and the author's colour in neither.
The theme names the colour on the input instead, and the thumb rules paint in
currentcolor from there:
slider-elemental > input[type="range"] {
color: var(--slider-elemental-thumb);
}
slider-elemental > input[type="range"]::-webkit-slider-thumb {
background: currentcolor;
}
Worth knowing if you write your own thumb rule: paint it in currentcolor and set color
on the input, or hand it a literal colour. A bare currentcolor on the thumb is the one
that silently draws something else.
| Custom property | Default | What it does |
|---|---|---|
--slider-elemental-thumb-size |
1rem |
Thumb width and height. Also the control's thickness across the track, what the fill is inset by, and the strip the track is centred on. Two thumbs want 1.5rem, for target size |
--slider-elemental-track-size |
0.375rem |
Track thickness |
--slider-elemental-radius |
999px |
Track corners. A big number is a pill |
--slider-elemental-thumb-radius |
50% |
Thumb shape. 50% is a circle, 0 a square |
--slider-elemental-track |
color-mix(in srgb, currentcolor 20%, transparent) |
Outside the selection |
--slider-elemental-fill |
currentcolor |
Inside it |
--slider-elemental-thumb |
var(--slider-elemental-fill) |
Thumb fill. Follows the selection unless you set it |
--slider-elemental-focus-width |
3px |
Ring around a focused thumb |
--slider-elemental-focus-color |
color-mix(in srgb, currentcolor 35%, transparent) |
Ring colour |
--slider-elemental-tooltip-gap |
0.375rem |
Between the thumb and the bubble โ above the control across the page, to its right down it |
--slider-elemental-tooltip-padding-block |
0.25em |
Above and below the number in it |
--slider-elemental-tooltip-padding-inline |
0.5em |
Either side of it |
--slider-elemental-tooltip-radius |
6px |
The bubble's corners |
--slider-elemental-tooltip-surface |
CanvasText |
What the bubble is painted in |
--slider-elemental-tooltip-color |
Canvas |
The number on it |
The colours are mixed out of currentcolor, so the control takes the page's palette with
nothing to configure:
slider-elemental.brand {
--slider-elemental-fill: rebeccapurple;
--slider-elemental-thumb-size: 1.25rem;
}
The thumb comes with it โ it defaults to var(--slider-elemental-fill), because the thumb
is the end of the selection and the two are one thing to look at. Set
--slider-elemental-thumb where it has to differ; forced-colors mode does exactly that, so
a Highlight thumb is not lost on a Highlight track.
Hiding the fill hides the thumb with it. --slider-elemental-fill: transparent is a
real thing to write โ a slider drawn over something else that is already showing the
selection โ and there the thumb has to be named back on the same rule, or the control loses
the one part you still have to grab:
.slider-over-something {
--slider-elemental-fill: transparent;
--slider-elemental-thumb: currentcolor;
}
A var() fallback cannot catch this, because a fallback fires on a property that is
unset and transparent is a value like any other.
The focus ring is a spread box-shadow on the thumb rather than an outline on the input,
because an outline on the input is a rectangle around the whole track and the thing that
took focus is one thumb on it. In forced-colors mode, where shadows are dropped, it goes
back to being an outline and the fill is repainted in Highlight.
The value bubble is the one part that does not mix out of currentcolor โ it takes
CanvasText on Canvas, the page's own two extremes swapped, because it hangs outside the
control over content this element knows nothing about and has to be legible on all of it.
That also means it follows a light/dark switch with nothing to configure. In forced-colors
mode it turns the right way up and grows a CanvasText rim, since a box painted in the
mode's text colour is a solid block where the mode expects a page.
Slider, or something else?
| You have | Use | Why |
|---|---|---|
| A value the reader sets along a range | <slider-elemental> |
One thumb |
| A span the reader sets โ price, dates, sizes | <slider-elemental>, two inputs |
Two thumbs that cannot cross |
| A value the reader watches | <progress-elemental> |
Progress is read, a slider is written |
| A few discrete choices | <segmented-elemental> |
Radios name their options; a slider makes you count notches |
| An exact number | <input type="number"> |
A slider is for about here, and a spinner is for 1247 |