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 MIT licence 5.4 kB0 Open 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. Without loading, the spinner only covers the pause before sb-search.

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 handler behind this demo is Go with the Datastar SDK; any SDK works the same way. demoAnswer also serves plain JSON and the demo's delay:

// demoSearch answers with the bodies whose name matches ?q=, at most ?limit=.
func (s *Server) demoSearch(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	limit, _ := strconv.Atoi(q.Get("limit"))
	limit = min(max(limit, 1), 50)
	if q.Get("limit") == "" {
		limit = 8
	}
	var kinds []string
	for _, k := range strings.Split(q.Get("kind"), ",") {
		if k = strings.TrimSpace(k); k != "" {
			kinds = append(kinds, k)
		}
	}
	bodies, err := s.q.ViewValue(r.Context(), func(rd *queries.Reader) ([]queries.DemoBody, error) {
		return rd.DemoSearch(r.Context(), q.Get("q"), kinds, limit)
	})
	if err != nil {
		s.fail(w, r, err)
		return
	}
	items := demoItems(bodies)
	s.demoAnswer(w, r, "_found", items, items)
}

// demoAnswer writes the list as JSON, or patches it into the requested signal.
func (s *Server) demoAnswer(w http.ResponseWriter, r *http.Request, defaultSignal string, list, patch any) {
	w.Header().Set("Access-Control-Allow-Origin", "*") // public; used from the playground sandbox
	if !demoWait(r) {
		return
	}
	// Datastar's own requests accept JSON too: they always get the patch.
	if r.Header.Get("Datastar-Request") == "" && strings.Contains(r.Header.Get("Accept"), "application/json") {
		w.Header().Set("Content-Type", "application/json")
		w.Header().Set("Cache-Control", "public, max-age=300")
		json.NewEncoder(w).Encode(list)
		return
	}
	into := r.URL.Query().Get("into")
	if into == "" {
		into = defaultSignal
	}
	if !signalNameRe.MatchString(into) {
		http.Error(w, "into must be a signal name", http.StatusBadRequest)
		return
	}
	js, err := json.Marshal(map[string]any{into: patch})
	if err != nil {
		s.fail(w, r, err)
		return
	}
	// Through lith's sse, which compresses: the edge leaves text/event-stream to it.
	if stream, err := sse.Open(w, r, sse.Options{}); err == nil {
		stream.CloseWith(sse.PatchSignals(js, false))
	}
}

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

Until the query has min-chars characters, the list shows no results. With min-chars="0", opening the list searches too, with an empty query (e.g. for recent or popular picks).

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>

A compact picker for a toolbar

With many options, chips don't fit a toolbar. summary shows a text in their place, and actions puts "Select all" and "Clear" at the top of the list ("Select the N matches" while a search filters it). Nothing picked shows the placeholder, which here means all. The 84 sources are the first stars of the example dataset's catalog (GET /demo/data/rows?count=84):

Flows from
<style>
  .sources { inline-size: 15rem; }
  .sources::part(control) { min-block-size: 2.25rem; padding-block: 0; }
  .sources::part(input) { block-size: 1.75rem; }
</style>
<div data-signals="{_stars: {rows: []}, _sources: []}" data-init="@get('/demo/data/rows?count=84&into=_stars')"
  style="display: flex; flex-wrap: wrap; align-items: center; gap: 8px">
  <span>Flows from</span>
  <sb-select class="sources" multiple searchable clearable actions
    summary="{count} of {total} sources" placeholder="All sources"
    data-attr:options="JSON.stringify($_stars.rows.map((s) => ({value: String(s.id), label: s.name, description: s.class + ', ' + s.constellation})))"
    data-preserve-attr="options"
    data-on:sb-change="$_sources = evt.detail.value"></sb-select>
  <span data-text="$_sources.length ? $_sources.length + ' picked' : 'all of them'"></span>
</div>

max-chips shows a few chips first. With a summary too, the summary takes over from the chips beyond that many:

<div style="display: grid; gap: 12px; max-inline-size: 22rem">
  <sb-select multiple searchable max-chips="2" label="Up to two chips, then +K"
    options='["Ada","Buzz","Chris","Mae","Sally","Valentina","Yuri"]' value='["Ada","Mae","Sally","Yuri"]'></sb-select>
  <sb-select multiple searchable max-chips="2" summary="+{more} more" label="Up to two chips, then a summary"
    options='["Ada","Buzz","Chris","Mae","Sally","Valentina","Yuri"]' value='["Ada","Mae","Sally","Yuri"]'></sb-select>
</div>

A chevron to match a toolbar button

Beside an sb-button with a caret, the select can take the button's look: its box through ::part(control) (height, edge, hover), its text through ::part(input) (weight), and the arrow through ::part(arrow). Here the arrow is a chevron stroked in the text's colour, as thick as the caret, and it turns over while the list is open (:state(open)):

Export
<style>
  .tb-select { inline-size: 11rem; }
  .tb-select::part(control) { min-block-size: 2rem; padding-block: 0; border-color: var(--sb-brand-light); background: transparent; cursor: pointer; }
  .tb-select::part(control):hover { background: var(--sb-brand-subtle); }
  .tb-select::part(input) { block-size: 1.875rem; font-size: 0.8125rem; font-weight: 600; }
  .tb-select::part(arrow) {
    box-sizing: border-box;
    inline-size: 7px;
    block-size: 7px;
    color: inherit;
    background: none;
    clip-path: none;
    border: solid currentColor;
    border-width: 0 2px 2px 0;
    translate: 0 -75%;
    rotate: 45deg;
    transition: rotate 120ms, translate 120ms;
  }
  .tb-select:state(open)::part(arrow) { translate: 0 -25%; rotate: 225deg; }
</style>
<div style="display: flex; flex-wrap: wrap; align-items: center; gap: 8px">
  <sb-button variant="outline" size="sm" caret>Export</sb-button>
  <sb-select class="tb-select" value="7d"
    options='[{"value":"24h","label":"Last 24 hours"},{"value":"7d","label":"Last 7 days"},{"value":"30d","label":"Last 30 days"}]'></sb-select>
</div>

The page's ::part(arrow) rules win over the component's own whatever their specificity, so a rotate set there replaces the default flip in both states: give the open one too, with :state(open). Rules that leave rotate alone keep the flip.

Compact closed state and list actions

These work with multiple.

  • summary: a text in place of the chips. {count} is the number picked, {more} the picks not shown as chips, and {total} the number of options: those in options, or, with remote, every option the server has offered so far, which only the server can know in full. Set total when it does, e.g. total="84". Nothing picked shows the placeholder.
  • max-chips="N": at most N chips, then a "+K" chip for the rest (its tooltip names them). max-chips="0" shows only that count.
  • Both: up to N picks show as chips. Beyond N, the first N chips stay and the summary takes the place of the "+K" chip, so summary="+{more} more" reads like it, in your words. With max-chips="0", only the summary shows; with no max-chips, a summary alone is the same as max-chips="0".
  • Either keeps the control one line high: chips' labels are cut short to fit. Without them, chips wrap as before.
  • actions: "Select all" and "Clear" rows at the top of the list. While a search filters it, "Select all" becomes "Select the N matches", which adds every enabled option the search shows (the search stays). Each is one change: one change and one sb-change with the whole new value, and the list stays open. They follow confirm, revert(), a new value from the server and forms like any other pick. An action that would change nothing is disabled.
  • Labels: select-all-label, clear-label (also the clear button's accessible name) and matches-label ({count}; the text before a | is for one match: "Select the match|Select the {count} matches").

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.

A new value from the server always wins. A removed value attribute changes nothing, by design: morphs also strip attributes that were only reflected, so the select can't take a missing one for "cleared". To clear, the server sends value="". Markup re-sent with the same value leaves the user's pick alone. Typing a search fires no input event on the element: change and sb-change come when the value changes.

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.

Forms

Inside a <form>, sb-select submits its value under its name (name=value; with multiple, one entry per picked value and none when nothing is picked, like <select multiple>), new FormData(form) and Datastar's contentType: 'form' include it, and a form reset brings back the server's value and clears a typed search. A disabled select submits nothing. 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: it fills the width it is given, up to 26rem; set max-inline-size on the element to change that. The control is at least 2.75rem tall and grows as chips wrap; the list is at most 18rem tall (max-block-size on ::part(listbox)).
  • Fonts: the label, the text you type, the chips and the options use your page's font.
  • Colours: the control is --sb-control-bg with a --sb-control-border edge (--sb-control-border-hover on hover) and --sb-control-text; the placeholder and the arrow are --sb-control-placeholder, the label --sb-text-2. Focus draws a --sb-brand-light edge with a --sb-brand-subtle glow, and chips are --sb-brand-subtle. The list is --sb-surface-raised; the active option is --sb-surface-hover with a --sb-brand edge, a selected one --sb-brand-light, descriptions --sb-text-muted. Corners are --sb-control-radius.
  • Shadow: --sb-shadow-overlay sets the list's drop shadow: one shadow without spread, such as 0 8px 16px rgb(0 0 0 / 0.3), or none.
  • Parts: label, control (the box), input, chip (each chip, with multiple), more (the "+K" chip), summary, clear, arrow and listbox (the list). Your page's ::part() rules win over the component's own, without !important.
  • Arrow: a block filled with its color (--sb-control-placeholder) and cut by a clip-path: a pixel staircase, or a plain triangle at --sb-notch: 0. It turns 180° while the list is open. Replace the look with background, clip-path, mask or border on ::part(arrow), as in A chevron to match a toolbar button.
  • States: :state(open) while the list is open, and :state(pending) with confirm (see With commands). Both work from the page: sb-select:state(open)::part(control).
<style>
  .my-select { max-inline-size: 16rem; --sb-control-radius: 0; }
  .my-select::part(control) { border-width: 2px; }
  .my-select::part(listbox) { max-block-size: 10rem; }
</style>
<sb-select class="my-select" label="Destination" placeholder="Pick a planet" options='["Mercury","Venus","Earth","Mars"]'></sb-select>

Accessibility

It follows the ARIA combobox pattern:

  • Structure: the input is a combobox controlling a listbox, with the highlighted option in aria-activedescendant. It is aria-autocomplete="list" only when you can type (searchable or remote).
  • Keys:
    • Down and Up open the list and move through it; Home and End jump.
    • Enter picks, and Escape closes.
    • Without searchable or remote, Space opens and picks like Enter, and typing jumps to the next option that starts with the letters (the same letter again cycles).
    • Backspace in an empty input removes the last chip shown, with multiple. A pick behind "+K" or the summary stays: remove it in the list.
    • The list actions are rows like the options, before them: Home reaches "Select all", and Enter runs it.
  • Selection: with multiple, the combobox's description (aria-describedby) lists every pick, also those behind "+K" or a summary. The "+K" chip and the summary are text, not buttons.
  • Loading: the input is aria-busy while results are on their way, and "Searching…", "No results" and "Type to search" are announced (a status region).
  • Disabled: disabled takes it out of the tab order, and neither keys nor the pointer can change it.
  • Forced colours: the focus ring, the highlighted option and the arrow use system colours.

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 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-select label="Destination" placeholder="Pick a planet" options='["Mercury","Venus","Earth","Mars"]'></sb-select>

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/select@50c6b602cf1a/select.bundle.min.js" integrity="sha384-2FUZRPglZxkTgMWOJ+9y8ma3kLp249+8HxmpImuw4mD/o4fAKQ3i8LvaRnAhjz1R"></script>

<sb-select label="Destination" placeholder="Pick a planet" options='["Mercury","Venus","Earth","Mars"]'></sb-select>

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

<sb-select label="Destination" placeholder="Pick a planet" options='["Mercury","Venus","Earth","Mars"]'></sb-select>

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/select@50c6b602cf1a/select.bundle.min.js": "sha384-2FUZRPglZxkTgMWOJ+9y8ma3kLp249+8HxmpImuw4mD/o4fAKQ3i8LvaRnAhjz1R"
    }
  }
</script>
<script type="module" src="https://starbase.zweiundeins.gmbh/c/@339249aca48f/autoloader.js" integrity="sha384-ZNpMayI1XShztIEX0iDHyCDDbhT8H+t+zEESUq4BCrN4b+7m931LQEl5x0vbY+Vo"></script>

<sb-select label="Destination" placeholder="Pick a planet" options='["Mercury","Venus","Earth","Mars"]'></sb-select>

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

<sb-select label="Destination" placeholder="Pick a planet" options='["Mercury","Venus","Earth","Mars"]'></sb-select>

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

<sb-select label="Destination" placeholder="Pick a planet" options='["Mercury","Venus","Earth","Mars"]'></sb-select>

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
select.bundle.min.js 16.3 kB6.0 kB5.4 kB
As module files: one request, 5.3 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
select.js 33.2 kB10.1 kB8.9 kB5.3 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) and submitted with its form.
summarystring""Multiple: a text shown instead of chips, e.g. "{count} of {total} sources". {count} is the number picked, {more} those not shown as chips, {total} the options (or the total prop). With max-chips, it shows only beyond that many picks, after the chips.
max-chipsnumber-1Multiple: show at most this many chips on one line, then a "+K" chip (or the summary). 0 shows only the count; -1 (the default) shows every chip and wraps.
totalnumber-1Summary: what {total} stands for, e.g. the number of results a remote search can reach. -1 (the default): the options it knows (remote: every option it has been offered).
actionsbooleanfalseMultiple: "Select all" (while a search filters: "Select the N matches") and "Clear" rows at the top of the list.
select-all-labelstring"Select all"Actions: the text of "Select all".
matches-labelstring"Select the match|Select the {count} matches"Actions: the text of "Select the N matches" while a search filters the list. Text before a | is for one match.
clear-labelstring"Clear"Actions: the text of "Clear", also the clear button's accessible name.
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.