Playground
One choice out of a handful, the form primitive a <select> is too small for. Write the choices as sb-radio children, or let the server fill options. The group keeps a single value, emits sb-change when the user commits a choice, and follows the command contract, so it can drive a command as it is.
sb-radio is not a component of its own. Like <option> inside a native <select>, it is markup the group reads: value, label, description and disabled. The group renders every item in its own shadow root, so the repeated rows contain no custom elements, the roving focus never has to cross a shadow boundary, and a Datastar morph over the group can't trip over a nested upgrade.
Examples
Basic
<sb-radio-group label="Drive mode" value="warp">
<sb-radio value="impulse">Impulse</sb-radio>
<sb-radio value="warp">Warp</sb-radio>
<sb-radio value="tow" disabled>Tow (needs a tug)</sb-radio>
</sb-radio-group>
Descriptions and a row
description adds a second line, and orientation="horizontal" lays the choices out in a row that wraps.
<div style="display: grid; gap: 24px">
<sb-radio-group label="Shield profile" value="balanced">
<sb-radio value="balanced" description="Even coverage, no surprises">Balanced</sb-radio>
<sb-radio value="forward" description="Everything to the bow">Forward</sb-radio>
<sb-radio value="off" description="For when the sensors need silence">Off</sb-radio>
</sb-radio-group>
<sb-radio-group label="Rations" orientation="horizontal" value="double">
<sb-radio value="single">Single</sb-radio>
<sb-radio value="double">Double</sb-radio>
<sb-radio value="feast">Feast</sb-radio>
</sb-radio-group>
</div>
Choices from the server
options takes the same shape as sb-select: strings, or {value, label, description?, disabled?}. It is server data, so it only flows in. Children win over it when both are there.
<sb-radio-group label="Dock" value="b"
options='[{"value":"a","label":"Bay A"},{"value":"b","label":"Bay B","description":"Closest to the lift"},{"value":"c","label":"Bay C","disabled":true}]'></sb-radio-group>
Two-way binding
The live value is the value property, so data-bind works with the __prop and __event modifiers. Declare the signal first.
Engaged:
<div data-signals:_drive="'impulse'">
<sb-radio-group label="Drive mode" data-bind:_drive__prop.value__event.change>
<sb-radio value="impulse">Impulse</sb-radio>
<sb-radio value="warp">Warp</sb-radio>
</sb-radio-group>
<p>Engaged: <strong data-text="$_drive"></strong></p>
</div>
With commands
Give it a name, and it emits sb-change with { name, value } when the user commits a choice: ready to post as a command. With confirm, it sets :state(pending) until the server's re-rendered value attribute matches, and revert() goes back to the server's value when a command is rejected.
<sb-radio-group name="drive" confirm value="impulse" label="Drive mode"
data-on:sb-change="@post('/cmd/flight', {payload: {tabid: $tabid, ...evt.detail}})"
data-on:datastar-fetch="evt.detail.el === el && evt.detail.type === 'error' && el.revert()">
<sb-radio value="impulse">Impulse</sb-radio>
<sb-radio value="warp">Warp</sb-radio>
</sb-radio-group>
Style the wait from the page:
sb-radio-group:state(pending) { opacity: 0.7; }
The value attribute is the server's value, and it wins whenever it changes: a morph with a new value replaces the local choice, re-sent identical markup leaves the user's choice alone, and a removed attribute is ignored (to clear, the server sends value=""). Nothing else is server state: the live value before the echo, the roving focus, the hover and the pending state all live in local signals and are never reflected to attributes. See Commands and components and the Showcase.
Styling
Parts: base, label, items, item, dot and description. Colours come from the control and brand tokens, the corners from --sb-notch, so [data-sb-style="smooth"] rounds them instead.
<sb-radio-group label="Hull paint" value="rust" style="--sb-brand: #F5C451; --sb-brand-subtle: rgb(245 196 81 / 0.16)">
<sb-radio value="rust">Rust</sb-radio>
<sb-radio value="chrome">Chrome</sb-radio>
</sb-radio-group>
Accessibility
The items sit in a role="radiogroup" with aria-orientation, named by the label (without one it is announced as "Choice"). Each item is a role="radio" with aria-checked, and its description is part of its accessible name.
Only one item is in the tab order: the checked one, or the first enabled one when nothing is checked. Arrow keys move and select, as native radios do, wrapping around and skipping disabled items; Home and End jump to the first and last enabled choice; Space (and Enter) selects the focused one. The focus survives a re-render: the items are driven by signals, and the roving focus is restored after the rows change.
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-radio-group value="warp" orientation="horizontal">
<sb-radio value="impulse">Impulse</sb-radio>
<sb-radio value="warp">Warp</sb-radio>
</sb-radio-group>
<!-- 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/radio-group@2b41ab7d9411/radio-group.min.js" integrity="sha384-vrMzR4DAsUJtz+l68JA45MlLjqVPo5BpPWjrKWdSraUi4DIKgiUsryYVOmbYau5R"></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.
| File | Original | gzip | brotli | minified |
|---|---|---|---|---|
radio-group.js | 14.5 kB | 5.4 kB | 4.7 kB | 3.3 kB |
API reference
Props
| Attribute | Type | Default | Description |
|---|---|---|---|
value | string | "" | The selected value. A new value from the server replaces it; the live value is the value property. |
options | json | [] | Choices from the server: ["A", "B"] or [{value, label, description?, disabled?}]. <sb-radio> children win over it. |
label | string | "" | Visible label, and the accessible name of the group. |
orientation | "vertical" | "horizontal" | "vertical" | Stack the choices or lay them out in a row. |
disabled | boolean | false | Disable the whole group. |
confirm | boolean | false | Server-confirmed value: :state(pending) while the local value differs from the server's value attribute (see revert()). |
name | string | "" | Name reported in sb-change (e.g. the field of a command). |
Slots
| Name | Description |
|---|---|
| <sb-radio value="…" [description] [disabled]>Label</sb-radio> items: markup the group reads as its choices, like <option> in a native <select>. Nothing is slotted; the group renders the items itself. |
Events
| Name | Description |
|---|---|
change | When the user picks a choice. |
sb-change | Same moment. detail: { name, value }: ready for a command. |