Select

A select that can filter, pick several, or autocomplete from the server.

zweiundeins <sb-select> select dropdown combobox autocomplete search form Since 2026-09-22 5.2 kB0Open in playground Edit on GitHub

Playground

A select for one or several values:

  • Plain: pick from a list.
  • searchable: type to filter the options in the browser.
  • remote: type to search on the server. It follows the same pattern as the tree: the select asks with an event, and the server answers with results.

The live value is the value property (a string, or an array with multiple), so data-bind works.

Examples

Autocomplete from the server

Type a star, planet or moon ("or", "sat", "eu"…), from the site's example dataset:

  1. After a short pause, the select emits sb-search with the query.
  2. @get('/demo/data/search?q=…&into=_found') asks the server.
  3. The server patches $_found with the results.
  4. data-attr:results hands them back.

data-indicator shows the spinner while the request is in flight.

Picked:
<div data-signals="{_found: [], _body: '', _searching: false}" style="display: grid; gap: 12px">
  <sb-select remote clearable label="Find a star, planet or moon" placeholder="Type a name…"
    data-attr:results="JSON.stringify($_found)"
    data-attr:loading="$_searching"
    data-preserve-attr="results loading"
    data-indicator:_searching
    data-on:sb-search="@get('/demo/data/search?kind=star,planet,dwarf,moon&into=_found&delay=150&q=' + encodeURIComponent(evt.detail.query))"
    data-bind:_body__prop.value></sb-select>
  <span>Picked: <b data-text="$_body || 'nothing yet'"></b></span>
</div>

The server side is a plain Datastar handler (Go here; any language works):

func search(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query().Get("q")
	datastar.NewSSE(w, r).MarshalAndPatchSignals(map[string]any{
		"_found": find(q), // [{value, label, description?}], at most a few
	})
}

Or the server re-renders the element with a new results attribute: a changed attribute always wins.

Searchable

<sb-select searchable label="Planet" placeholder="Type to filter"
  options='[{"value":"mercury","label":"Mercury","description":"0.39 AU"},{"value":"venus","label":"Venus","description":"0.72 AU"},{"value":"earth","label":"Earth","description":"1 AU"},{"value":"mars","label":"Mars","description":"1.52 AU"},{"value":"jupiter","label":"Jupiter","description":"5.2 AU"},{"value":"saturn","label":"Saturn","description":"9.5 AU"},{"value":"pluto","label":"Pluto","description":"No longer a planet","disabled":true}]'></sb-select>

Several values

With multiple, the value is an array. Keep it in a signal with sb-change: data-bind treats array signals as checkbox groups, which is a different thing.

Crew:
<div data-signals="{_crew: ['Ada', 'Yuri']}" style="display: grid; gap: 12px">
  <sb-select multiple searchable clearable label="Crew" placeholder="Add crew"
    options='["Ada","Buzz","Chris","Mae","Sally","Valentina","Yuri"]' value='["Ada","Yuri"]'
    data-on:sb-change="$_crew = evt.detail.value"></sb-select>
  <span>Crew: <b data-text="$_crew.join(', ') || 'nobody'"></b></span>
</div>

With commands

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

Options

options (and results, for remote searches) is a JSON array of strings, or of {value, label?, description?, disabled?}. The server can change either at any time. A selected value keeps its label even after the options it came from are gone.

Accessibility

It follows the ARIA combobox pattern:

  • Structure: the input is a combobox controlling a listbox, with the highlighted option in aria-activedescendant.
  • Keys:
    • Down and Up open the list and move through it; Home and End jump.
    • Enter picks, and Escape closes.
    • Backspace in an empty input removes the last chip, with multiple.
  • Loading: the input is aria-busy while results are on their way.

The list is a native popover, so it is never clipped by a scrolling container.

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-select label="Destination" placeholder="Pick a planet" value="Mars" options='["Mercury","Venus","Earth","Mars","Jupiter","Saturn"]' style="inline-size: 14rem"></sb-select>

<!-- 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/@0a1ad1bf98a4/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/@0a1ad1bf98a4/autoloader.js" integrity="sha384-IvSCUjEWPS+wwXunU+48x96KmyWDx/Y4LeCwCL8cWFqJ/zHmTsTOsSMrzTOcpW3D"></script>
-->

<!-- Or load just this component, pinned to this version: -->
<!-- <script type="module" src="https://starbase.zweiundeins.gmbh/c/select@b9f1cf4809e3/select.js" integrity="sha384-HVn6ouP86ed76nVZET8iGK+YLjjLCfceaLslH2od/v+LWzbvm49RB19NwzTaRJZB"></script> -->

Size

Each file compressed on its own, the way it is served (gzip -9, brotli -11). Datastar and Rocket are shared by every component and not counted.

FileOriginalgzipbrotli
select.js 17.6 kB6.0 kB5.2 kB

API reference

Props

AttributeTypeDefaultDescription
optionsjson[]Choices: ["A", "B"] or [{value, label, description?, disabled?}].
resultsjson[]Remote: the results of the current search, in the same shape. The server sets it (a signal patch through data-attr, or a morph).
valuestring""The value; for multiple, a JSON array or values separated by commas. A new value from the server replaces it; the live value is the value property.
labelstring""Visible label.
placeholderstring""Placeholder text.
multiplebooleanfalsePick several; they show as chips.
searchablebooleanfalseType to filter the options (in the browser).
remotebooleanfalseType to search on the server: emits sb-search; the server answers with results.
delaynumber250Remote: debounce before sb-search, in ms.
min-charsnumber1Remote: characters needed before searching.
loadingbooleanfalseShow that results are on their way (bind it to data-indicator).
clearablebooleanfalseShow a button that clears the value.
disabledbooleanfalseDisable the control.
namestring""Name reported in sb-change (e.g. the field of a command).
confirmbooleanfalseServer-confirmed value: :state(pending) while the local value differs from the server's value attribute (see revert()).

Events

NameDescription
sb-searchRemote: the query changed (debounced). detail: { query }. Answer by setting results.
changeThe value changed.
sb-changeThe value changed. detail: { name, value } (a string, or an array for multiple): ready for a command.