Gauge

A pixel dial whose needle springs to its value, with warning zones.

zweiundeins <sb-gauge> meter dial dashboard telemetry Since 2026-09-21 MIT licence 2.4 kB0 Open in playground Edit on GitHub

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 (default 12rem) 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-display on the gauge to choose the value's font on its own. The label is in capitals unless --sb-display-case is none (as with data-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-warn and --sb-danger colour the zones, --sb-border the track, --sb-text-1 the needle and the value, --sb-brand the 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, label and dial (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.js one 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.

FileOriginalgzipbrotli
gauge.bundle.min.js 5.1 kB2.7 kB2.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.

FileOriginalgzipbrotliminified
gauge.js 10.2 kB4.4 kB3.8 kB2.3 kB

API reference

Props

AttributeTypeDefaultDescription
valuenumber0Current value; the needle eases toward it.
minnumber0Minimum.
maxnumber100Maximum.
warnnumber70Warning threshold. Below danger means low values are bad (like fuel).
dangernumber90Danger threshold.
labelstring""Caption below the value.
unitstring""Unit after the value, e.g. "%" or " km/s".
decimalsnumber0Decimals shown in the readout.