Meter

A segmented pixel bar for levels and resources.

zweiundeins <sb-meter> level bar battery fuel Since 2026-09-21 MIT licence 1.2 kB0 Open in playground Edit on GitHub

Playground

A row of pixel blocks lit up to value, from min to max. Any value above min lights at least one block, and only max lights them all. When the value changes, blocks light up in a quick cascade from the edge that moves.

The whole bar changes colour at the warn and danger thresholds, given in value units. Left unset, they sit at 70% and 90% of the range (70 and 90 on 0 to 100). When danger is below warn, low values are the bad ones, as with fuel or batteries. To keep the bar in its normal colour, set both above max.

Examples

Driven by a signal

<div data-signals:_shields="58" style="display: grid; gap: 16px; inline-size: min(100%, 22rem)">
  <sb-meter label="Shields" unit="%" data-attr:value="$_shields" data-preserve-attr="value"></sb-meter>
  <sb-slider label="Power to shields" unit="%" data-bind:_shields__prop.value></sb-slider>
</div>

Thresholds

<div style="display: grid; gap: 16px; inline-size: min(100%, 22rem)">
  <sb-meter label="Cargo" value="40" unit="t" max="60" warn="45" danger="55"></sb-meter>
  <sb-meter label="Fuel" value="12" unit="%" warn="30" danger="15" segments="20"></sb-meter>
  <sb-meter label="Heat" value="92" unit="%" segments="8"></sb-meter>
</div>

Live from the server

<div data-signals="{_tm: {fuel: 100}}" style="inline-size: min(100%, 22rem)">
  <div id="telemetry" hidden data-init="@get('/demo/telemetry', {retry: 'error', retryMaxCount: Infinity, retryInterval: 400, retryMaxWait: 4000})"></div>
  <sb-meter label="Fuel" unit="%" warn="30" danger="15" segments="20" data-attr:value="Math.round($_tm.fuel)" data-preserve-attr="value"></sb-meter>
</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-meter-width (default 22rem) is the widest it gets, --sb-meter-height (default 12px) the height of the blocks; segments sets how many there are.
  • Fonts: the label and the value use your page's font.
  • Colours: lit blocks are --sb-ok, --sb-warn past the warn threshold and --sb-danger past danger. The track is --sb-surface-inset with a --sb-border edge, and unlit blocks are a tint of that border. The label is --sb-text-2, the value --sb-text-1.
  • Motion: a block lights up in two steps; --sb-notch: 0 fades it in smoothly instead.
  • Parts: label, value and bar, and segment for every block; the lit ones are also lit: ::part(segment lit). Your page's ::part() rules win over the component's own, without !important.
  • States: the host has :state(ok), :state(warn) or :state(danger), so the tone can restyle any part: sb-meter:state(danger)::part(label).
<style>
  .my-meter { --sb-meter-width: 16rem; --sb-meter-height: 20px; --sb-ok: var(--sb-accent); }
  .my-meter::part(value) { font-size: 1.25rem; }
</style>
<sb-meter class="my-meter" label="Shields" value="45" unit="%"></sb-meter>

Accessibility

The element itself is the meter: role="meter" with min, max, the value (held within the range) and the shown text, such as 75%, as its value text. They are set through ElementInternals, so page morphs can't strip them. Its name is label; an aria-label on the element wins over it, and without either the name is "Meter". The caption and number above the bar are hidden from assistive technology, so nothing is read twice.

The fill and the number show the level; the tone is colour only, and assistive technology gets the value, not whether it is past warn or danger. Where that matters, say it in the label or next to the meter. In forced-colours mode (Windows High Contrast), the lit blocks are drawn in the text colour inside an outlined frame.

sb-meter shows a level within a known range, like the native <meter>. For the progress of a task, use a progress bar (role="progressbar").

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-meter label="Shields" value="75" unit="%"></sb-meter>

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/meter@77e7b41410bd/meter.bundle.min.js" integrity="sha384-mfmvABCMLQK0BkFkOFR9GYGgYgIgt9rXLp5PjovG6+9OpHfzlXapeDpp9WiVMJeK"></script>

<sb-meter label="Shields" value="75" unit="%"></sb-meter>

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/meter@77e7b41410bd/meter.min.js": "sha384-SPEu08o/AoXK1DBrxJ7khuJi1qlGU15EQnq797LPhPGrVqTHq5aD7XchW7SJmCOp"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/meter@77e7b41410bd/meter.min.js" integrity="sha384-SPEu08o/AoXK1DBrxJ7khuJi1qlGU15EQnq797LPhPGrVqTHq5aD7XchW7SJmCOp"></script>

<sb-meter label="Shields" value="75" unit="%"></sb-meter>

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/meter@77e7b41410bd/meter.bundle.min.js": "sha384-mfmvABCMLQK0BkFkOFR9GYGgYgIgt9rXLp5PjovG6+9OpHfzlXapeDpp9WiVMJeK"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@339249aca48f/autoloader.js" integrity="sha384-ZNpMayI1XShztIEX0iDHyCDDbhT8H+t+zEESUq4BCrN4b+7m931LQEl5x0vbY+Vo"></script>

<sb-meter label="Shields" value="75" unit="%"></sb-meter>

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-meter>
    • meter.bundle.min.js one file 1.2 kB brotli
<script type="importmap">
  { "imports": { "datastar": "/js/datastar-rocket.js" } }
</script>
<script type="module" src="/js/meter/meter.bundle.min.js"></script>

<sb-meter label="Shields" value="75" unit="%"></sb-meter>

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/meter/meter.min.js"></script>

<sb-meter label="Shields" value="75" unit="%"></sb-meter>

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
meter.bundle.min.js 2.6 kB1.4 kB1.2 kB
As module files: one request, 1.2 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
meter.js 4.9 kB2.2 kB1.9 kB1.2 kB

API reference

Props

AttributeTypeDefaultDescription
valuenumber0Current value.
minnumber0Minimum.
maxnumber100Maximum.
segmentsnumber12Number of blocks.
warnnumber70Warning threshold, in value units. Unset, it sits at 70% of the range (70 on 0–100). Below danger means low values are bad (like fuel).
dangernumber90Danger threshold, in value units. Unset, it sits at 90% of the range (90 on 0–100).
labelstring""Caption, and the accessible name.
unitstring""Unit after the value.
show-valuebooleantrueShow the value.
decimalsnumber0Decimals shown.