Button

A versatile button component with Datastar interactions.

zweiundeins <sb-button> button action link cta Since 2026-09-21 MIT licence 1.7 kB0 Open in playground Edit on GitHub

Playground

Blast off

Buttons start things. sb-button comes in five variants, three sizes, and renders a real <a> when given an href. Clicks bubble out of the shadow root, so any Datastar data-on:click just works.

Examples

Variants

Primary Outline Ghost Abort Blast off
<sb-button>Primary</sb-button>
<sb-button variant="outline">Outline</sb-button>
<sb-button variant="ghost">Ghost</sb-button>
<sb-button variant="danger">Abort</sb-button>
<sb-button variant="pixel" caret>Blast off</sb-button>

Sizes

Small Medium Large
<sb-button size="sm">Small</sb-button>
<sb-button size="md">Medium</sb-button>
<sb-button size="lg">Large</sb-button>

With Datastar

Local signals start with an underscore, so they stay in the browser and are never sent to the server.

Launches: Reset
<div data-signals:_count="0">
  <sb-button data-on:click="$_count++">Launches: <span data-text="$_count"></span></sb-button>
  <sb-button variant="ghost" data-on:click="$_count = 0" data-attr:disabled="$_count === 0">Reset</sb-button>
</div>

Talking to the backend works the same way: data-on:click="@post('/launch')".

Read the Datastar docs
<sb-button href="https://data-star.dev" variant="outline" caret>Read the Datastar docs</sb-button>

Icon only

A button with nothing but an icon needs a name for screen readers: aria-label on sb-button is passed on to the inner button (or link).

<sb-button variant="outline" aria-label="Close">
  <svg slot="prefix" viewBox="0 0 12 12" stroke="currentColor"><path d="M3 3l6 6M9 3 3 9"/></svg>
</sb-button>

While the action runs

loading shows an inline spinner in the button, blocks its clicks (mouse, Enter and Space), and sets aria-busy. The button keeps its focus, so nobody is thrown out of the page mid-action, while Tab, Escape and the page's own shortcuts keep working. It takes no extra space until it is loading.

Sync catalog
<div data-signals="{_sync: []}" 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=_sync&delay=900')"
  >Sync catalog</sb-button>
  <span class="muted" data-text="$_sync.length ? 'synced ' + $_sync.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. For a wait that is not a button (a panel, a table, a whole region), use sb-busy, which also knows delay, min and progress shapes.

Forms

sb-button is not a submit button: in a <form> it neither submits nor resets it, and Enter in a field doesn't press it. Post the form from its click instead: data-on:click="@post('/save', {contentType: 'form'})" sends the fields of the form around it, once they pass the browser's validation.

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: size (sm, md, lg) sets the height, the padding and the text size. For anything in between, set block-size, padding-inline or font-size on ::part(button): icons and the loading spinner are sized in em, so they follow the text.
  • Fonts: the label uses your page's font. The pixel variant uses --sb-font-display when a site sets one (like Starbase's pixel font); set it on the button to choose that font on its own. Its label is in capitals unless --sb-display-case says otherwise (none keeps it as written, as data-sb-style="smooth" does).
  • Colours: primary fills with --sb-brand (--sb-brand-hover on hover) and writes in --sb-text-on-brand; outline draws a --sb-brand-light border and hovers with --sb-brand-subtle; ghost hovers with --sb-surface-hover; danger fills with --sb-danger, writes in --sb-text-on-danger and brightens on hover. The other variants write in --sb-text-1. pixel is a --sb-text-1 plate with --sb-bg text in a --sb-frame-color frame, --sb-frame-step thick. Corners are --sb-control-radius, the focus ring --sb-focus-ring.
  • Motion: the loading spinner lights one block at a time; at --sb-notch: 0 they are round dots, and each one fades out, so a trail follows the lit one round.
  • Parts: button (the <button>, or the <a> with href) and spinner (while loading). Your page's ::part() rules win over the component's own, without !important.
Launch
<style>
  .my-button { --sb-brand: #0F766E; --sb-brand-hover: #115E59; --sb-text-on-brand: #FFFFFF; --sb-control-radius: 999px; }
  .my-button::part(button) { font-size: 1.125rem; padding-inline: 2rem; }
</style>
<sb-button class="my-button">Launch</sb-button>

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

A native <button> (or <a>) inside the shadow root does the work: keyboard focus, Enter and Space, and screen reader semantics. Disabled buttons are removed from the tab order and ignore clicks, a script's el.click() included.

aria-label is passed on to the inner button, so give every icon-only button one. Other aria-* attributes stay on sb-button itself, where they don't describe the button.

In forced colours (Windows High Contrast), where box shadows are not drawn, the focus ring becomes an outline, the pixel frame a thin edge, and the spinner keeps its dots.

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-button variant="pixel" size="lg" caret>Blast off</sb-button>

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/button@a837cfc18d3a/button.bundle.min.js" integrity="sha384-T6DcKZPGwk18WjsY157sSINBZCAvDwuzdqPrynZSNwfrnQzR50sDz4+kB8NUb5vf"></script>

<sb-button variant="pixel" size="lg" caret>Blast off</sb-button>

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/button@a837cfc18d3a/button.min.js": "sha384-dEuAIaD6rxoamzsvdRPyj9rIQpb6ABzZ2vHNKB2YRbuTgST4WiPHQLg28EOgsHxa"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/button@a837cfc18d3a/button.min.js" integrity="sha384-dEuAIaD6rxoamzsvdRPyj9rIQpb6ABzZ2vHNKB2YRbuTgST4WiPHQLg28EOgsHxa"></script>

<sb-button variant="pixel" size="lg" caret>Blast off</sb-button>

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/button@a837cfc18d3a/button.bundle.min.js": "sha384-T6DcKZPGwk18WjsY157sSINBZCAvDwuzdqPrynZSNwfrnQzR50sDz4+kB8NUb5vf"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@339249aca48f/autoloader.js" integrity="sha384-ZNpMayI1XShztIEX0iDHyCDDbhT8H+t+zEESUq4BCrN4b+7m931LQEl5x0vbY+Vo"></script>

<sb-button variant="pixel" size="lg" caret>Blast off</sb-button>

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

<sb-button variant="pixel" size="lg" caret>Blast off</sb-button>

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

<sb-button variant="pixel" size="lg" caret>Blast off</sb-button>

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
button.bundle.min.js 4.7 kB2.0 kB1.7 kB
As module files: one request, 1.7 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
button.js 7.2 kB2.9 kB2.5 kB1.7 kB

API reference

Props

AttributeTypeDefaultDescription
variant"primary" | "outline" | "ghost" | "pixel" | "danger""primary"Visual style.
size"sm" | "md" | "lg""md"Height and padding.
hrefstring""Render as a link to this URL.
caretbooleanfalseShow a trailing chevron.
disabledbooleanfalseDisable interaction.
loadingbooleanfalseThe button's action is running: an inline spinner, clicks (mouse, Enter, Space) blocked, aria-busy. Bind it to data-indicator (and add data-preserve-attr="loading").
aria-labelstring""Accessible name, passed on to the inner button or link: set it on icon-only buttons.

Slots

NameDescription
defaultThe label.
prefixAn icon before the label.
suffixAn icon after the label.