Playground
A half-dial drawn in pixels. The needle eases toward value on a slightly bouncy spring, and the arc behind it is coloured by zone: ok, warn or danger. When danger is below warn, low values are the bad ones, as with fuel. The dial only animates while it is on screen: one that scrolls back in eases to the latest value.
Examples
Driven by a slider
<div data-signals:_thrust="64" style="display: flex; gap: 32px; align-items: center; flex-wrap: wrap">
<sb-gauge label="Thrust" unit="%" data-attr:value="$_thrust" data-preserve-attr="value"></sb-gauge>
<sb-slider label="Throttle" unit="%" style="inline-size: 14rem" data-bind:_thrust__prop.value></sb-slider>
</div>
Low is bad
<sb-gauge label="Fuel" unit="%" value="18" warn="35" danger="15"></sb-gauge>
<sb-gauge label="Hull temp" unit="°" value="940" min="0" max="1200" warn="800" danger="1000"></sb-gauge>
Live from the server
This site's demo endpoint streams telemetry as signal patches. The gauge follows $_tm.vel without any client code.
<div data-signals="{_tm: {vel: 0, temp: 18}}" style="display: flex; gap: 32px; flex-wrap: wrap">
<div id="telemetry" hidden data-init="@get('/demo/telemetry', {retry: 'error', retryMaxCount: Infinity, retryInterval: 400, retryMaxWait: 4000})"></div>
<sb-gauge label="Velocity" unit=" km/s" max="8" warn="9" danger="10" decimals="2" data-attr:value="$_tm.vel" data-preserve-attr="value"></sb-gauge>
<sb-gauge label="Hull" unit="°C" max="1200" warn="800" danger="1000" data-attr:value="$_tm.temp" data-preserve-attr="value"></sb-gauge>
</div>
The endpoint is Go with the sse package of lith, Starbase's runtime; any Datastar SDK works the same way. TelemetryAt computes the sample from the clock, so every viewer watches the same flight. When the server stops for a deploy, its last event replaces the hidden element by a copy, which opens the stream again, and retry: 'error' keeps asking until the next process answers:
// demoTelemetry streams the demo mission as signal patches ($_tm): a pure
// query stream, no commands, no database. When the server stops (a deploy),
// the last event asks the page to open it again from #telemetry.
func (s *Server) demoTelemetry(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*") // public; used from the playground sandbox
if s.ctx.Err() != nil {
// Stopping: the page retries the 503 until the next process answers.
http.Error(w, "the server is restarting", http.StatusServiceUnavailable)
return
}
// Uncompressed: an event is 130 bytes, where an encoder would hold 430 KiB
// or more for as long as a page shows the telemetry.
stream, err := sse.Open(w, r, sse.Options{NoCompression: true})
if err != nil {
return
}
var last [][]byte
defer func() { stream.CloseWith(last...) }()
tick := time.NewTicker(250 * time.Millisecond)
defer tick.Stop()
for {
tm, _ := json.Marshal(map[string]any{"_tm": TelemetryAt(time.Now())})
if err := stream.Write(sse.PatchSignals(tm, false)); err != nil {
return
}
select {
case <-tick.C:
case <-r.Context().Done():
return
case <-s.ctx.Done():
last = append(last, sse.Reconnect(ui.TelemetryID))
return
}
}
}
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:
--sb-gauge-size(default12rem) sets the width. The dial keeps its proportions and the value and label scale with it (never below a readable size). - Fonts: the value and the label use your page's font. A site that sets a display font (
--sb-font-display, like Starbase's pixel font) gets it for the value; set--sb-font-displayon the gauge to choose the value's font on its own. The label is in capitals unless--sb-display-caseisnone(as withdata-sb-style="smooth"). - Colours: the dial paints with theme tokens and repaints whenever one of them changes, whatever changed it: a theme switch, a theme scoped to a container, the system's light or dark mode, or your own override on the gauge.
--sb-ok,--sb-warnand--sb-dangercolour the zones,--sb-borderthe track,--sb-text-1the needle and the value,--sb-brandthe hub, in any colour syntax,light-dark()included. The label is--sb-text-2. - 8-bit details: the dial is an 80 × 44 pixel raster, scaled up with crisp pixels. At
--sb-notch: 0(data-sb-style="smooth") it is drawn smoothly at the screen's resolution instead, and it redraws when the token changes. - Parts:
value,labelanddial(the canvas), for anything the tokens don't cover. Your page's::part()rules win over the component's own, without!important.
<style>
.my-gauge {
--sb-gauge-size: 14rem;
font-family: Georgia, serif;
--sb-font-display: Georgia, serif; /* only needed where a display font is set, like here */
}
.my-gauge::part(label) { text-transform: none; letter-spacing: 0; }
</style>
<sb-gauge class="my-gauge" value="64" label="Oxygen" unit="%"></sb-gauge>
A font you load yourself works inside the component too: load it in the page (a <link>, or an @import at the very top of your main stylesheet). A component's own styles are a constructed stylesheet, which can't @import.
Accessibility
The readout is a role="meter" with min, max and current value. Like a native <meter>, the current value it reports stays within min and max; its text is the reading as shown, with the unit. The dial canvas is decorative. Under prefers-reduced-motion the needle jumps straight to the value.
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-gauge value="72" label="Thrust" unit="%"></sb-gauge>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/gauge@c7d5d107c103/gauge.bundle.min.js" integrity="sha384-/uehVWWR8A70kj5YlixuAl+ht/D70L3JDAbMeAghN9sfU6FhjugRQMya12A75m+s"></script>
<sb-gauge value="72" label="Thrust" unit="%"></sb-gauge>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/gauge@c7d5d107c103/gauge.min.js": "sha384-+k2cS+UBl5keVJNt7v8e6u62y+Rz0t3+IGUkMnVgSMepJLbSL5AfJy+uBbXdo2NC"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/gauge@c7d5d107c103/gauge.min.js" integrity="sha384-+k2cS+UBl5keVJNt7v8e6u62y+Rz0t3+IGUkMnVgSMepJLbSL5AfJy+uBbXdo2NC"></script>
<sb-gauge value="72" label="Thrust" unit="%"></sb-gauge>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/gauge@c7d5d107c103/gauge.bundle.min.js": "sha384-/uehVWWR8A70kj5YlixuAl+ht/D70L3JDAbMeAghN9sfU6FhjugRQMya12A75m+s"
}
}
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@339249aca48f/autoloader.js" integrity="sha384-ZNpMayI1XShztIEX0iDHyCDDbhT8H+t+zEESUq4BCrN4b+7m931LQEl5x0vbY+Vo"></script>
<sb-gauge value="72" label="Thrust" unit="%"></sb-gauge>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-gauge>gauge.bundle.min.jsone file 2.4 kB brotli
<script type="importmap">
{ "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/gauge/gauge.bundle.min.js"></script>
<sb-gauge value="72" label="Thrust" unit="%"></sb-gauge>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/gauge/gauge.min.js"></script>
<sb-gauge value="72" label="Thrust" unit="%"></sb-gauge>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 |
|---|---|---|---|
gauge.bundle.min.js | 5.1 kB | 2.7 kB | 2.4 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 |
|---|---|---|---|---|
gauge.js | 10.2 kB | 4.4 kB | 3.8 kB | 2.3 kB |
API reference
Props
| Attribute | Type | Default | Description |
|---|---|---|---|
value | number | 0 | Current value; the needle eases toward it. |
min | number | 0 | Minimum. |
max | number | 100 | Maximum. |
warn | number | 70 | Warning threshold. Below danger means low values are bad (like fuel). |
danger | number | 90 | Danger threshold. |
label | string | "" | Caption below the value. |
unit | string | "" | Unit after the value, e.g. "%" or " km/s". |
decimals | number | 0 | Decimals shown in the readout. |