Range

A two-thumb pixel slider for a start and an end, committed as one value.

zweiundeins <sb-range> range slider interval input form Since 2026-09-22 MIT licence 2.3 kB0 Open in playground Edit on GitHub

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); set max-inline-size on 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-inset with a --sb-border edge. The thumbs are --sb-slider-thumb (default --sb-text-1) with a --sb-slider-thumb-edge edge (default --sb-brand-light), which is also the focus ring. They are the same properties as sb-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: 0 rounds the track and the thumbs instead of notching them.
  • Parts: label, value (the shown range), rail (the track) and input for both range inputs, plus start or end for one of them (::part(input end)). The thumbs are drawn inside the inputs and have no part: colour them with --sb-slider-thumb and --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.js one 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.

FileOriginalgzipbrotli
range.bundle.min.js 5.9 kB2.6 kB2.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.

FileOriginalgzipbrotliminified
range.js 12.9 kB5.1 kB4.4 kB2.3 kB

API reference

Props

AttributeTypeDefaultDescription
valuejson{}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.
minnumber0Minimum.
maxnumber100Maximum.
stepnumber1Step size (0 for continuous).
labelstring""Visible label; the thumbs are announced as "<label> start" and "<label> end".
unitstring""Suffix after the shown values, e.g. "°" or " kg".
show-valuebooleantrueShow the current range next to the label.
ticksbooleanfalseShow min and max below the track.
disabledbooleanfalseDisable interaction.
confirmbooleanfalseServer-confirmed value: :state(pending) while the local range differs from the server's value attribute (see revert()).
namestring""Name reported in sb-change (e.g. the field of a command) and submitted with a <form>.

Events

NameDescription
inputWhile dragging either thumb (native, re-targeted to the host).
changeWhen the range is committed.
sb-changeWhen the range is committed. detail: { name, value: { start, end } }: the whole range, one command.