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 withresults.
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:
- After a short pause, the select emits
sb-searchwith the query. @get('/demo/data/search?q=…&into=_found')asks the server.- The server patches
$_foundwith the results. data-attr:resultshands them back.
data-indicator shows the spinner while the request is in flight. Without loading, the spinner only covers the pause before sb-search.
<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.
<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):
<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)):
<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 inoptions, or, withremote, every option the server has offered so far, which only the server can know in full. Settotalwhen 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. Withmax-chips="0", only the summary shows; with nomax-chips, a summary alone is the same asmax-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: onechangeand onesb-changewith the whole new value, and the list stays open. They followconfirm,revert(), a newvaluefrom 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) andmatches-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; setmax-inline-sizeon the element to change that. The control is at least2.75remtall and grows as chips wrap; the list is at most18remtall (max-block-sizeon::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-bgwith a--sb-control-borderedge (--sb-control-border-hoveron hover) and--sb-control-text; the placeholder and the arrow are--sb-control-placeholder, the label--sb-text-2. Focus draws a--sb-brand-lightedge with a--sb-brand-subtleglow, and chips are--sb-brand-subtle. The list is--sb-surface-raised; the active option is--sb-surface-hoverwith a--sb-brandedge, a selected one--sb-brand-light, descriptions--sb-text-muted. Corners are--sb-control-radius. - Shadow:
--sb-shadow-overlaysets the list's drop shadow: one shadow without spread, such as0 8px 16px rgb(0 0 0 / 0.3), ornone. - Parts:
label,control(the box),input,chip(each chip, withmultiple),more(the "+K" chip),summary,clear,arrowandlistbox(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 aclip-path: a pixel staircase, or a plain triangle at--sb-notch: 0. It turns 180° while the list is open. Replace the look withbackground,clip-path,maskorborderon::part(arrow), as in A chevron to match a toolbar button. - States:
:state(open)while the list is open, and:state(pending)withconfirm(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
comboboxcontrolling alistbox, with the highlighted option inaria-activedescendant. It isaria-autocomplete="list"only when you can type (searchableorremote). - Keys:
- Down and Up open the list and move through it; Home and End jump.
- Enter picks, and Escape closes.
- Without
searchableorremote, 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-busywhile results are on their way, and "Searching…", "No results" and "Type to search" are announced (a status region). - Disabled:
disabledtakes 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.jsone 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.
| File | Original | gzip | brotli |
|---|---|---|---|
select.bundle.min.js | 16.3 kB | 6.0 kB | 5.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.
| File | Original | gzip | brotli | minified |
|---|---|---|---|---|
select.js | 33.2 kB | 10.1 kB | 8.9 kB | 5.3 kB |
API reference
Props
| Attribute | Type | Default | Description |
|---|---|---|---|
options | json | [] | Choices: ["A", "B"] or [{value, label, description?, disabled?}]. |
results | json | [] | Remote: the results of the current search, in the same shape. The server sets it (a signal patch through data-attr, or a morph). |
value | string | "" | 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. |
label | string | "" | Visible label. |
placeholder | string | "" | Placeholder text. |
multiple | boolean | false | Pick several; they show as chips. |
searchable | boolean | false | Type to filter the options (in the browser). |
remote | boolean | false | Type to search on the server: emits sb-search; the server answers with results. |
delay | number | 250 | Remote: debounce before sb-search, in ms. |
min-chars | number | 1 | Remote: characters needed before searching. |
loading | boolean | false | Show that results are on their way (bind it to data-indicator). |
clearable | boolean | false | Show a button that clears the value. |
disabled | boolean | false | Disable the control. |
name | string | "" | Name reported in sb-change (e.g. the field of a command) and submitted with its form. |
summary | string | "" | 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-chips | number | -1 | Multiple: 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. |
total | number | -1 | Summary: 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). |
actions | boolean | false | Multiple: "Select all" (while a search filters: "Select the N matches") and "Clear" rows at the top of the list. |
select-all-label | string | "Select all" | Actions: the text of "Select all". |
matches-label | string | "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-label | string | "Clear" | Actions: the text of "Clear", also the clear button's accessible name. |
confirm | boolean | false | Server-confirmed value: :state(pending) while the local value differs from the server's value attribute (see revert()). |
Events
| Name | Description |
|---|---|
sb-search | Remote: the query changed (debounced). detail: { query }. Answer by setting results. |
change | The value changed. |
sb-change | The value changed. detail: { name, value } (a string, or an array for multiple): ready for a command. |