Busy

Spinner, bar or skeleton that follows Datastar's in-flight requests.

zweiundeins <sb-busy> loading spinner skeleton progress indicator fetch Since 2026-09-23 3.2 kB0Open in playground Edit on GitHub

Playground

One loading indicator in three shapes, and it knows when the page is waiting. sb-busy listens for Datastar's datastar-fetch events and shows itself while a request it watches is in flight, so a button plus an indicator needs no signal, no data-indicator and no server round trip to appear.

Two things decide whether it is up, and they are kept strictly apart:

  • The server owns busy. A long job the server knows about sets busy on the element, and it wins: the indicator stays up until the server takes the attribute away (busy="false").
  • The component owns the rest locally. Which watched requests are in flight, the delay/min timers and the animation state live in $$ signals and closure variables, never in attributes: a morph resets attributes, and the indicator must not blink every time the server re-renders the page.

The visible state is busy or a watched request in flight, put through delay and min. Every flip emits sb-busy-change with { busy }.

Examples

The three variants

<div style="display: grid; gap: 24px; inline-size: min(100%, 24rem)">
  <sb-busy busy show-label label="Docking"></sb-busy>
  <sb-busy busy variant="bar" label="Uploading"></sb-busy>
  <sb-busy busy variant="bar" value="64" label="Uploading" show-label></sb-busy>
  <sb-busy busy variant="skeleton" lines="3" label="Loading crew"></sb-busy>
</div>

A bar with no value sweeps (indeterminate); with value between 0 and 100 it becomes a determinate role="progressbar". Sizes are sm, md and lg:

<div style="display: flex; align-items: center; gap: 24px">
  <sb-busy busy size="sm"></sb-busy>
  <sb-busy busy size="md"></sb-busy>
  <sb-busy busy size="lg"></sb-busy>
</div>

Server-driven

busy is an ordinary attribute, so anything that can set an attribute can drive it: the server's markup, or a signal on the page.

<div data-signals:_working="false" style="display: flex; align-items: center; gap: 16px">
  <sb-toggle label="Long job running" data-bind:_working__prop.checked></sb-toggle>
  <sb-busy data-attr:busy="$_working" data-preserve-attr="busy" show-label label="Working"></sb-busy>
</div>

Following a real request

This is the point of the component. The button fires a request at a demo endpoint that answers after 900 ms; the sb-busy next to it shows itself for exactly as long as the request is in flight. Nothing is wired between them: with no for, the indicator watches every request coming from inside its own parent element.

Load the catalog

<div data-signals="{_crew: []}" style="display: grid; gap: 16px; justify-items: start">
  <div style="display: flex; align-items: center; gap: 16px">
    <sb-button data-on:click="@get('/demo/data/children?into=_crew&delay=900')">Load the catalog</sb-button>
    <sb-busy label="Loading the catalog" show-label></sb-busy>
  </div>
  <p style="margin: 0; color: var(--sb-text-2)" data-text="$_crew.length ? $_crew.length + ' galaxies' : 'nothing loaded yet'"></p>
</div>

datastar-fetch is dispatched on the document and reaches every listener, so sb-busy checks event.detail.el before counting anything. Requests from elsewhere on the page leave it alone.

Which requests to watch

for watches
empty any request triggered from inside the host's parent element (the host itself included)
a CSS selector requests whose triggering element matches it, or has an ancestor that does (for="#crew-form" covers every control in that form)
* every request on the page
Left request watched
Right request ignored
<div data-signals="{_a: [], _b: []}" style="display: grid; gap: 16px">
  <div id="sb-busy-left" style="display: flex; align-items: center; gap: 16px">
    <sb-button variant="outline" data-on:click="@get('/demo/data/children?into=_a&delay=900')">Left request</sb-button>
    <span style="color: var(--sb-text-2); font-size: 0.8125rem">watched</span>
  </div>
  <div style="display: flex; align-items: center; gap: 16px">
    <sb-button variant="outline" data-on:click="@get('/demo/data/search?q=star&into=_b&delay=900')">Right request</sb-button>
    <span style="color: var(--sb-text-2); font-size: 0.8125rem">ignored</span>
  </div>
  <sb-busy for="#sb-busy-left" variant="bar" label="Loading the left panel" show-label></sb-busy>
</div>

A skeleton usually stands in for the content it is waiting for, so give it the shape of that content and hide the content while it is up:

Scan for moons
<div data-signals="{_moons: []}" style="display: grid; gap: 16px">
  <sb-button data-on:click="@get('/demo/data/search?q=moon&into=_moons&delay=1200')">Scan for moons</sb-button>
  <sb-busy variant="skeleton" lines="4" label="Scanning"></sb-busy>
  <ul data-show="$_moons.length" style="margin: 0; color: var(--sb-text-2)">
    <template data-for="m in $_moons"><li data-text="m?.label"></li></template>
  </ul>
</div>

A spinner inside the button

The most common loading state there is. For a button, reach for sb-button's own loading prop first: it draws the same eight blinking dots as sb-busy, sized from the button's own text and colour, and it blocks clicks and Enter in the capture phase — which is what stops a double submit, and which a spinner sitting inside the button cannot do.

Sync catalog
<div data-signals="{_synced: []}" style="display: flex; align-items: center; gap: 16px">
  <sb-button
    data-indicator:_syncing
    data-attr:loading="$_syncing"
    data-preserve-attr="loading"
    data-on:click="@get('/demo/data/children?into=_synced&delay=900')"
  >Sync catalog</sb-button>
  <span style="color: var(--sb-text-2); font-size: 0.8125rem" data-text="$_synced.length ? 'synced ' + $_synced.length + ' galaxies' : 'not synced yet'"></span>
</div>

data-indicator sets the signal while that element's request is in flight, and data-preserve-attr keeps the attribute through a server morph. That is one signal, and in exchange the button cannot be clicked twice.

Or with no signal at all. Drop an sb-busy into the button instead and it wires itself: it watches the request the button fires and shows itself for exactly as long as it is in flight.

Count galaxies
<div data-signals="{_counted: []}" style="display: flex; align-items: center; gap: 16px">
  <sb-button variant="outline" data-on:click="@get('/demo/data/children?into=_counted&delay=900')">
    <sb-busy size="sm" label="Counting" style="display: contents; --sb-brand: currentColor"></sb-busy>
    Count galaxies
  </sb-button>
  <span style="color: var(--sb-text-2); font-size: 0.8125rem" data-text="$_counted.length ? 'counted ' + $_counted.length + ' galaxies' : 'not counted yet'"></span>
</div>

Which one: sb-button loading when a click starts a command, because only the button itself can swallow the second click; the composed sb-busy when you want no page state at all, or a shape sb-button does not draw — a bar, a skeleton — or the delay and min timings the button prop deliberately leaves out.

The composed version needs no for: the default rule watches any request from inside the host's parent element, and here that parent is the button it sits in. Point for at the button (for="#save") only when the spinner lives somewhere else on the page. The same wiring works for @post('/cmd/…')sb-busy watches the element, not the method.

Two details make the composed spinner behave inside a button:

  • display: contents so the idle spinner costs nothing. The host is an inline-block, and a zero-width child still takes the button's flex gap, which would leave a blank notch in an idle button forever. With display: contents the host generates no box at all: while idle the button is exactly as wide as a button without it, and when the request starts the spinner and its gap appear and the button grows. Nothing is reserved for a state the button is not in. It arrives at once, undecorated; sb-busy::part(spinner) is there if you want to animate it in.
  • --sb-brand: currentColor so the dots take the button's own text colour instead of the brand purple they would be invisible in on a filled button.

When a plain indicator is enough

sb-busy earns its place when you want the spinner, the bar or the skeleton, with delay and min around them. If all the page needs is a control that shows it is working while a request is in flight, data-indicator already does that on its own, and it is lighter:

<sb-button data-indicator:_saving data-attr:loading="$_saving" data-preserve-attr="loading"
  data-on:click="@post('/cmd/save')">Save</sb-button>

data-indicator sets $_saving while that element's request is in flight; the same signal drives data-attr:disabled or a CSS class on anything that has no loading of its own. Reach for sb-busy when the wait needs a shape, a delay, a minimum, or a place of its own.

No flash, no flicker

delay is how long a request may take before anything appears, and min is how long the indicator stays once it did appear. A request that finishes inside delay never shows an indicator at all; one that finishes right after it keeps it up for min. The failure of a watched request skips min and drops the indicator at once.

100 ms request 1200 ms request
<div data-signals="{_fast: [], _slow: []}" style="display: grid; gap: 16px; justify-items: start">
  <div style="display: flex; align-items: center; gap: 16px">
    <sb-button variant="outline" data-on:click="@get('/demo/data/children?into=_fast&delay=100')">100 ms request</sb-button>
    <sb-button variant="outline" data-on:click="@get('/demo/data/children?into=_slow&delay=1200')">1200 ms request</sb-button>
  </div>
  <sb-busy delay="300" min="600" label="Loading" show-label></sb-busy>
</div>

Disabling a form while it waits

sb-busy-change fires on every visible flip, so the page can do more than show the indicator.

Search

<div data-signals="{_busy: false, _found: []}" style="display: grid; gap: 16px; justify-items: start">
  <div style="display: flex; align-items: center; gap: 16px">
    <sb-button data-attr:disabled="$_busy" data-on:click="@get('/demo/data/search?q=a&into=_found&delay=900')">Search</sb-button>
    <sb-busy variant="bar" label="Searching" data-on:sb-busy-change="$_busy = evt.detail.busy" style="inline-size: 12rem"></sb-busy>
  </div>
  <p style="margin: 0; color: var(--sb-text-2)" data-text="$_busy ? 'searching…' : $_found.length + ' results'"></p>
</div>

The indicator never shows a result before the server produced one: it only says that the page is waiting. The list, the count and the message come from the server's render.

Styling

Parts: base (the status region), spinner, bar, fill, skeleton and label.

Token Used for
--sb-brand, --sb-brand-light the dots, the bar fill and the shimmer
--sb-surface-inset, --sb-border the bar track and the skeleton blocks
--sb-text-2 the label
--sb-notch pixel corners on the bar and the skeleton (0 falls back to a border radius)

inline-size on the host sizes the bar and the skeleton. The host also carries :state(busy) while the indicator is up — a CSS custom state, so a server morph cannot reset it:

form:has(sb-busy:state(busy)) { opacity: 0.6; }

The live visible state is on the element as a read-only visible property; busy stays the server's attribute.

A watched request that was already in flight before the element was upgraded is not counted: the component starts watching when it connects. Long-lived streams (@get on an SSE endpoint) stay in flight until the stream ends, so point for at elements that make one-shot requests.

Accessibility

  • The indicator is a role="status" region with aria-live="polite", holding the label text. Without show-label that text is visually hidden but still read out, so "Loading systems" is announced when the wait starts.
  • The host reflects aria-busy. It is set through ElementInternals, so the semantics survive a morph; the matching attribute is written too, for CSS and tests.
  • A determinate bar is a role="progressbar" with aria-valuemin, aria-valuemax, aria-valuenow and a percentage aria-valuetext, labelled by the same label. An indeterminate bar has no aria-valuenow, which is how "unknown progress" is expressed.
  • The spinner dots and the skeleton lines are aria-hidden decoration inside the status region: the text carries the meaning, never the animation.
  • With prefers-reduced-motion: reduce nothing spins, sweeps or shimmers. Each variant keeps a static state instead: one lit dot, a dimmed full bar, plain blocks.

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 added later by a Datastar morph.

<!-- Once per page: Datastar with Rocket, and the Starbase autoloader.
     It loads every <sb-…> component the first time its tag appears. -->
<script type="importmap">
  { "imports": { "datastar": "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/autoloader.js"></script>
<!-- Optional, no flash of undefined elements: class="sb-cloak" on <html>, and -->
<style>.sb-cloak :not(:defined) { visibility: hidden }</style>

<sb-busy busy show-label label="Plotting course"></sb-busy>

<!-- In production, pin today's catalog instead of the latest: the browser then
     refuses any file that changed. Add "integrity" to the import map above: the
     hashes from https://starbase.zweiundeins.gmbh/c/@722e189276ac/importmap.json and Datastar's, below. -->
<!--
<script type="importmap">
  { "imports": { "datastar": "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js" },
    "integrity": { "https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar-rocket.js": "sha384-vUxZojLrF1Ar3de5h7VINqhJXBgjyZS4U49pHvGa87kum6j5Xn6JRziuARNw5ELG", "…": "…from importmap.json" } }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@722e189276ac/autoloader.js" integrity="sha384-scFa7/4jztDOx1YZT9/1g1ULYm9LsDlQn/8hMTyyD4IkPnZV2pfhHytoNTWTJG6b"></script>
-->

<!-- Or load just this component, pinned to this version. The minified module
     is what the autoloader uses; the readable source is the same URL without .min. -->
<!-- <script type="module" src="https://starbase.zweiundeins.gmbh/c/busy@70c34b7458a2/busy.min.js" integrity="sha384-tsmnrS88WB+kyKAkRf55+1cov6Yp8pjG2UvK5xZ7tTBgbKIcV2qg0lXfcj9pLrDZ"></script> -->

Size

Each file compressed on its own, the way it is served (gzip -9, brotli -11). The autoloader loads the minified files (esbuild), so that last column is what a page downloads; the readable source is always there too. Datastar and Rocket are shared by every component and not counted.

FileOriginalgzipbrotliminified
busy.js 12.6 kB4.9 kB4.2 kB3.2 kB

API reference

Props

AttributeTypeDefaultDescription
busybooleanfalseThe server forces the indicator on. It wins: while this is true the indicator stays up whatever the watched requests do (busy="false" to clear it).
variant"spinner" | "bar" | "skeleton""spinner"Shape: a spinner, a progress bar or skeleton lines. Set it as an attribute (it also picks the host display).
valuestring""Bar only: 0-100 for a determinate bar. Empty means indeterminate.
linesnumber3Skeleton only: how many placeholder lines.
labelstring"Loading"What is being waited for, e.g. "Loading flight plan". Read out by the status region; visible with show-label.
show-labelbooleanfalseShow the label next to the indicator instead of only reading it out.
size"sm" | "md" | "lg""md"Spinner box, bar height and skeleton line height.
forstring""Which requests to watch: a CSS selector matching the element that triggers them (or one of its ancestors), "*" for every request on the page, empty for any request from inside the host's parent element.
delaynumber0Milliseconds to wait before showing, so a fast request never flashes. Try 200.
minnumber0Milliseconds to stay up once shown, so it does not flicker. Try 400.

Events

NameDescription
sb-busy-changeWhen the visible state flips, after delay and min. detail: { busy }: e.g. to disable a form while it is up.