Toggle

A switch component with smooth Datastar interactions.

zweiundeins <sb-toggle> switch checkbox boolean Since 2026-09-21 MIT licence 1.5 kB0 Open in playground Edit on GitHub

Playground

An on/off switch with chunky pixel corners and stepped motion. It exposes a checked property and fires change, so Datastar can bind to it like a native checkbox.

Examples

Basic

<sb-toggle label="Warp drive"></sb-toggle>
<sb-toggle label="Shields" checked></sb-toggle>
<sb-toggle label="Self-destruct" disabled></sb-toggle>

Sizes

<sb-toggle size="sm" checked aria-label="Small"></sb-toggle>
<sb-toggle size="md" checked aria-label="Medium"></sb-toggle>
<sb-toggle size="lg" checked aria-label="Large"></sb-toggle>

Two-way binding

Bind the checked property with data-bind and the __prop and __event modifiers.

<div data-signals:_thrusters="false">
  <sb-toggle label="Thrusters" data-bind:_thrusters__prop.checked__event.change></sb-toggle>
  <p data-text="$_thrusters ? 'Thrusters engaged' : 'Thrusters idle'"></p>
</div>

With commands

Give it a name, and it emits sb-change with { name, value } when a value is committed: ready to post as a command. With confirm, it sets :state(pending) until the server's re-rendered attribute matches, and revert() goes back to the server's value when a command is rejected. See Commands and components and the Showcase.

The server's checked wins when it sends a new one, even onto a toggle rendered without it; the same markup again leaves the user's switch alone. A removed attribute changes nothing, so send checked="false" to switch it off. Write it as checked, checked="true" or checked="false": checked="checked" (like disabled="disabled") reads as false.

Forms

Inside a <form>, sb-toggle submits like a checkbox: name=on when it is on, nothing when it is off or disabled, and nothing without a name. new FormData(form) and Datastar's contentType: 'form' include it, and a form reset brings back the server's checked without firing change or sb-change. It is not a form-associated element yet (Rocket can't declare one), so required and validity, <fieldset disabled>, <label for> and the form attribute don't reach it. With commands, sb-change carries { name, value } (see With commands).

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 switch.
  • Fonts: the label uses your page's font, size and colour.
  • Colours: the track is --sb-border-strong when off and --sb-brand when on; the knob is --sb-toggle-knob (default --sb-text-1, and --sb-text-on-brand on the brand track), the focus ring --sb-brand-light. --sb-notch: 0 rounds the switch instead of notching it, and its knob and colour move smoothly instead of in steps.
  • Parts: switch (the track) and label. The knob has no part: colour it with --sb-toggle-knob. Your page's ::part() rules win over the component's own, without !important.
<style>
  .my-toggle { --sb-brand: #16A34A; --sb-notch: 0; --sb-toggle-knob: #FFFFFF; }
  .my-toggle::part(label) { color: var(--sb-text-2); font-size: 0.875rem; }
</style>
<sb-toggle class="my-toggle" label="Autopilot" checked size="lg"></sb-toggle>

Accessibility

The switch is a <button role="switch"> with aria-checked, so it is focusable and toggles with Space and Enter. Give it a label, or an aria-label on <sb-toggle> when the name shouldn't show: the switch takes it over (aria-labelledby doesn't reach inside). Without either it is announced as "Toggle". In forced colours (Windows High Contrast) the track is outlined and the on state uses the system highlight colours.

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-toggle label="Notifications"></sb-toggle>

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/toggle@46880f06cfc3/toggle.bundle.min.js" integrity="sha384-jq5uNQA0WHDb6bMACFqSurqw9TZWSmWHbbTP7FgK9kGKcKY+cepdrhB/M9dxP0HY"></script>

<sb-toggle label="Notifications"></sb-toggle>

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/toggle@46880f06cfc3/toggle.min.js": "sha384-4Ef5TBvxfIWFtftzSu4YZcZXCDC5f/SMFD7EMubp6LjOlS+XyjC7KvOaNt19Y4hj"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/toggle@46880f06cfc3/toggle.min.js" integrity="sha384-4Ef5TBvxfIWFtftzSu4YZcZXCDC5f/SMFD7EMubp6LjOlS+XyjC7KvOaNt19Y4hj"></script>

<sb-toggle label="Notifications"></sb-toggle>

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/toggle@46880f06cfc3/toggle.bundle.min.js": "sha384-jq5uNQA0WHDb6bMACFqSurqw9TZWSmWHbbTP7FgK9kGKcKY+cepdrhB/M9dxP0HY"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@339249aca48f/autoloader.js" integrity="sha384-ZNpMayI1XShztIEX0iDHyCDDbhT8H+t+zEESUq4BCrN4b+7m931LQEl5x0vbY+Vo"></script>

<sb-toggle label="Notifications"></sb-toggle>

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

<sb-toggle label="Notifications"></sb-toggle>

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

<sb-toggle label="Notifications"></sb-toggle>

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
toggle.bundle.min.js 3.7 kB1.7 kB1.5 kB
As module files: one request, 1.4 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
toggle.js 8.3 kB3.3 kB2.8 kB1.4 kB

API reference

Props

AttributeTypeDefaultDescription
checkedbooleanfalseOn or off. A new state from the server wins (send checked="false" to switch it off); the live state is the checked property.
disabledbooleanfalseDisable interaction.
labelstring""Visible label next to the switch.
size"sm" | "md" | "lg""md"Size of the switch.
confirmbooleanfalseServer-confirmed value: :state(pending) while the local value differs from the server's value attribute (see revert()).
namestring""Name reported in sb-change (e.g. the field of a command).

Events

NameDescription
changeAfter the user flips the switch.
sb-changeSame moment. detail: { name, value, checked } (value is the checked state): ready for a command.