Playground
A slider with two thumbs, for a start and an end: a price band, a time window, an altitude range. It is the sibling of sb-slider, and is built on two native range inputs, so arrow keys, Page Up/Down, Home/End and touch work on each thumb. The thumbs can meet but not cross. Where they meet, the direction you drag picks the thumb: towards min moves the start, towards max the end.
The range is one value: value='{"start":20,"end":60}' in, { start, end } out. Both ends change together in one sb-change and one command. See Components with several values.
Examples
Basic
<div style="display: grid; gap: 20px; inline-size: min(100%, 22rem)">
<sb-range label="Orbit" value='{"start":300,"end":800}' min="160" max="2000" step="10" unit=" km" ticks></sb-range>
<sb-range label="Launch window" value='{"start":9,"end":17}' min="0" max="24" unit=":00"></sb-range>
<sb-range label="Signal" value='{"start":0.2,"end":0.8}' min="0" max="1" step="0.01"></sb-range>
</div>
A missing (or null) part is the bound: value='{"end":40}' starts at min, and without a value the whole range is selected. Values snap to step as the thumbs do, so the text, the fill and sb-change always match the thumbs; when max is off the step grid, the end stops at the last step below it. With step="0" (continuous) the text shows two decimals.
The whole range in one event
sb-change fires once per committed drag or key press, with both ends. A key press that can't move a thumb (the other one is in the way) sends nothing:
<div data-signals="{_window: ''}" style="display: grid; gap: 12px; inline-size: min(100%, 22rem)">
<sb-range name="window" label="Launch window" value='{"start":9,"end":17}' min="0" max="24" unit=":00"
data-on:sb-change="$_window = JSON.stringify(evt.detail)"></sb-range>
<code data-text="$_window || 'Drag a thumb…'"></code>
</div>
The live value is the value property: el.value returns { start, end }, and setting it accepts { start, end } or [start, end].
With commands
Post the detail as it is. The server validates the range as a whole and renders the new value attribute:
<sb-range name="window" confirm value='{"start":9,"end":17}' min="0" max="24"
data-on:sb-change="@post('/cmd/window', {payload: {tabid: $tabid, ...evt.detail}})"
data-on:datastar-fetch="evt.detail.el === el && evt.detail.type === 'error' && el.revert()"></sb-range>
With confirm, the host has :state(pending) while either end differs from the server's attribute, and revert() restores both ends. A command can never leave the server with only half a change. See Commands and components.
sb-range:state(pending) { opacity: 0.7; }
Forms
Inside a <form>, sb-range submits its range under its name, in the format of its value attribute (window={"start":9,"end":17}), and nothing while disabled. new FormData(form) and Datastar's contentType: 'form' include it, and a form reset brings back the server's range. It is not a form-associated element yet (Rocket can't declare one), so required and validity, <fieldset disabled>, <label for> and the form attribute don't reach it. With commands, sb-change carries { name, value } (see With commands).
Styling
Style it from your page's CSS, without changing the component or importing anything into it. Custom properties, inherited properties and ::part() all reach into its shadow root.
- Size: it fills the width it is given (at least
8rem); setmax-inline-sizeon the element to cap it. - Fonts: the label, the range and the tick labels use your page's font.
- Colours: the part of the track between the thumbs is
--sb-brand, the rest--sb-surface-insetwith a--sb-borderedge. The thumbs are--sb-slider-thumb(default--sb-text-1) with a--sb-slider-thumb-edgeedge (default--sb-brand-light), which is also the focus ring. They are the same properties assb-slider's, so one rule styles both. The label is--sb-text-2, the range--sb-text-1, the ticks--sb-text-muted.--sb-notch: 0rounds the track and the thumbs instead of notching them. - Parts:
label,value(the shown range),rail(the track) andinputfor both range inputs, plusstartorendfor one of them (::part(input end)). The thumbs are drawn inside the inputs and have no part: colour them with--sb-slider-thumband--sb-slider-thumb-edge. Your page's::part()rules win over the component's own, without!important.
<style>
.my-range { max-inline-size: 20rem; --sb-brand: #F97316; --sb-brand-light: #FDBA74; --sb-notch: 0; --sb-slider-thumb: #FFFFFF; --sb-slider-thumb-edge: #F97316; }
.my-range::part(value) { color: #F97316; }
</style>
<sb-range class="my-range" label="Burn window" value='{"start":20,"end":45}' max="60" unit=" s"></sb-range>
Accessibility
The field is a group named by its label. Each thumb is a native slider, announced as "<label> start" and "<label> end" with its value and unit (aria-valuetext). Without a label they are "Range start" and "Range end". The range shown next to the label is plain text, not a live region, so a step is announced once, by the thumb that moved. The focused thumb gets a ring inside its edge.
Installation
Add Datastar with Rocket and the Starbase autoloader once per page, then use the tag: the autoloader imports each component the first time its tag appears, including tags a Datastar morph adds later. The Datastar here is v1.0.4 with Starbase's fixes until they are released; the components also run on the official release.
<script type="importmap">
{ "imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>
<sb-range label="Price" value='{"start":20,"end":80}' min="0" max="100" unit=" €"></sb-range>No flash of undefined elements: put class="sb-cloak" on <html> and add .sb-cloak :not(:defined) { visibility: hidden } to your CSS. The autoloader removes the class once the page's components are defined.
Load just this component, pinned to this version: all its modules in one minified file with its integrity hash, so the browser refuses it if a single byte changes.
<script type="importmap">
{
"imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
"integrity": {
"https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/range@c939fb5d9d96/range.bundle.min.js" integrity="sha384-JvDS4Wi+wZNxUPwyQBW9Dzze4y0fhddgjMMsYgz6S7GW8Q1rCsTlnjTMqGg6a6SB"></script>
<sb-range label="Price" value='{"start":20,"end":80}' min="0" max="100" unit=" €"></sb-range>Or the minified module files (one file, which the browser requests as it finds their imports), each pinned in the import map:
<script type="importmap">
{
"imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
"integrity": {
"https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
"https://starbase.zweiundeins.gmbh/c/range@c939fb5d9d96/range.min.js": "sha384-sCF2HqXsQ0Yg7FIephL1MuxfRdZml5BnToOo+ACDrZOFJz3fK2Owk+UwqwxeeEad"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/range@c939fb5d9d96/range.min.js" integrity="sha384-sCF2HqXsQ0Yg7FIephL1MuxfRdZml5BnToOo+ACDrZOFJz3fK2Owk+UwqwxeeEad"></script>
<sb-range label="Price" value='{"start":20,"end":80}' min="0" max="100" unit=" €"></sb-range>In production, pin today's whole catalog: its autoloader and an import map with integrity hashes, so the browser refuses any file that changed. This map covers Datastar and this component; importmap.json has the hashes of every component, to merge into yours.
<script type="importmap">
{
"imports": { "datastar": "https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js" },
"integrity": {
"https://starbase.zweiundeins.gmbh/c/datastar@f602fbe19d74/datastar-rocket.js": "sha384-mvTnynHaljKNMleD0bj6/rWROnluHlNmbc5rOQTd+fQH3YBDYhlvHdNquEX99by5",
"https://starbase.zweiundeins.gmbh/c/range@c939fb5d9d96/range.bundle.min.js": "sha384-JvDS4Wi+wZNxUPwyQBW9Dzze4y0fhddgjMMsYgz6S7GW8Q1rCsTlnjTMqGg6a6SB"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@339249aca48f/autoloader.js" integrity="sha384-ZNpMayI1XShztIEX0iDHyCDDbhT8H+t+zEESUq4BCrN4b+7m931LQEl5x0vbY+Vo"></script>
<sb-range label="Price" value='{"start":20,"end":80}' min="0" max="100" unit=" €"></sb-range>Components import only datastar and their own files, so copying is enough: save each component's one file (and a file it loads on first use, keeping its folder), and point the import map at your own copy of datastar-rocket.js (the /js/ paths stand for yours).
<sb-range>range.bundle.min.jsone file 2.3 kB brotli
<script type="importmap">
{ "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/range/range.bundle.min.js"></script>
<sb-range label="Price" value='{"start":20,"end":80}' min="0" max="100" unit=" €"></sb-range>Or copy the module files, keeping each component's folder, and load each component's module:
<script type="importmap">
{ "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/range/range.min.js"></script>
<sb-range label="Price" value='{"start":20,"end":80}' min="0" max="100" unit=" €"></sb-range>Size
A page loads this component as one file, its bundle, compressed the way it is served (gzip -9, brotli -11): brotli is what it downloads. Datastar and Rocket are shared by every component and not counted.
| File | Original | gzip | brotli |
|---|---|---|---|
range.bundle.min.js | 5.9 kB | 2.6 kB | 2.3 kB |
As module files: one request, 2.3 kB minified with brotli
For a page that loads the module files as they are, requesting each one as it finds its import, or bundles them itself. Each file compressed on its own; the minified one is what the page downloads, and the readable source is always there too.
| File | Original | gzip | brotli | minified |
|---|---|---|---|---|
range.js | 12.9 kB | 5.1 kB | 4.4 kB | 2.3 kB |
API reference
Props
| Attribute | Type | Default | Description |
|---|---|---|---|
value | json | {} | The range, as JSON: {"start": 20, "end": 60}; a missing or null part is min or max. A new value from the server replaces it; the live value is the value property. |
min | number | 0 | Minimum. |
max | number | 100 | Maximum. |
step | number | 1 | Step size (0 for continuous). |
label | string | "" | Visible label; the thumbs are announced as "<label> start" and "<label> end". |
unit | string | "" | Suffix after the shown values, e.g. "°" or " kg". |
show-value | boolean | true | Show the current range next to the label. |
ticks | boolean | false | Show min and max below the track. |
disabled | boolean | false | Disable interaction. |
confirm | boolean | false | Server-confirmed value: :state(pending) while the local range differs from the server's value attribute (see revert()). |
name | string | "" | Name reported in sb-change (e.g. the field of a command) and submitted with a <form>. |
Events
| Name | Description |
|---|---|
input | While dragging either thumb (native, re-targeted to the host). |
change | When the range is committed. |
sb-change | When the range is committed. detail: { name, value: { start, end } }: the whole range, one command. |