Skip to content
ShineUI
Install

Listbox

The list that stays open.

<sui-listbox>SelectionStable

About

Always-visible option list in a bordered box: the list that stays open. Single or multiple selection, with an optional search box. Use it on a settings page where the user should see every option at once; for a list that should collapse use sui-select. Form-associated: posts the value, one entry per pick when multiple.

Usage

<sui-listbox label="Customer" name="customer" value="Riverbend Dental Group"></sui-listbox> <script type="module"> import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); const el = document.querySelector("sui-listbox"); el.options = ["Clark County Schools", "Derby City Events", "Riverbend Dental Group", "Silver Hills Church"]; /* SuiListboxEntry[] */ el.addEventListener("suiChange", (e) => console.log(e.detail)); </script>
import { SuiListbox } from "@shineui/react"; import "@shineui/core/shineui.css"; export function Example() { const options = ["Clark County Schools", "Derby City Events", "Riverbend Dental Group", "Silver Hills Church"]; /* SuiListboxEntry[] */ return <SuiListbox label="Customer" name="customer" value="Riverbend Dental Group" options={options} onSuiChange={(e) => console.log(e.detail)} />; }
<script setup> import { SuiListbox } from "@shineui/vue"; import "@shineui/core/shineui.css"; const options = ["Clark County Schools", "Derby City Events", "Riverbend Dental Group", "Silver Hills Church"]; // SuiListboxEntry[] const onChange = (e) => console.log(e.detail); </script> <template> <SuiListbox label="Customer" name="customer" value="Riverbend Dental Group" :options="options" @sui-change="onChange" /> </template>
import { Component } from "@angular/core"; import { SuiListbox } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiListbox], template: `<sui-listbox label="Customer" name="customer" value="Riverbend Dental Group" [options]="options" (suiChange)="onChange($event)"></sui-listbox>`, }) export class Example { options = ["Clark County Schools", "Derby City Events", "Riverbend Dental Group", "Silver Hills Church"]; // SuiListboxEntry[] onChange(e: CustomEvent) { console.log(e.detail); } }

Basic

The list itself is the tab stop: a role="listbox" with aria-activedescendant, so the active row moves without moving focus. Disabled options carry aria-disabled and the arrows step over them.

<div style="max-width:340px"> <sui-listbox id="lb-basic" label="Customer" name="customer" value="Riverbend Dental Group"></sui-listbox> </div>
// setup(root): properties and events setup(root) { root.querySelector('#lb-basic').options = customers; }

Multiple with filter

multiple turns value into an array and every row keeps its own aria-selected. The search box narrows the rows without changing the selection.

<div style="max-width:340px"> <sui-listbox id="lb-multi" label="Customers to email" multiple filter height="190" empty-message="No customer matches that name."></sui-listbox> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#lb-multi'); el.options = customers; el.value = ['Derby City Events', 'Silver Hills Church']; }

Grouped options

A group is { label, items } in the options array and draws an eyebrow header over its rows. The rows stay one list, so the arrows walk straight through a header.

<div style="max-width:340px"> <sui-listbox id="lb-groups" label="Report" value="bs"></sui-listbox> </div>
// setup(root): properties and events setup(root) { root.querySelector('#lb-groups').options = groupedReports; }

Loading

While loading is set the box shows the loading row instead of options and carries aria-busy. Set it before the fetch and clear it when the options land.

<div style="display:flex;flex-wrap:wrap;gap:14px;align-items:flex-end"> <div style="max-width:300px;flex:1 1 240px"><sui-listbox id="lb-loading" label="Report" height="150" loading></sui-listbox></div> <sui-button id="lb-reload" variant="outline" label="Reload reports"></sui-button> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#lb-loading'); const fetchReports = () => { el.loading = true; setTimeout(() => { el.options = reports; el.loading = false; }, 1800); }; root.querySelector('#lb-reload').addEventListener('click', fetchReports); fetchReports(); }

States

An error replaces the help line, paints the box in the danger tone and sets aria-invalid. invalid is the same tone with no message, for a form that draws its summary elsewhere.

<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:start"> <sui-listbox id="lb-help" label="Report" help="The report runs against the selected period." height="150"></sui-listbox> <sui-listbox id="lb-err" label="Report" required error="Choose a report to run." height="150"></sui-listbox> <sui-listbox id="lb-inv" label="Report" required invalid help="The report runs against the selected period." height="150"></sui-listbox> <sui-listbox id="lb-off" label="Report" disabled height="150"></sui-listbox> <sui-listbox id="lb-empty" label="Report" height="150" empty-message="No report is available for this period."></sui-listbox> </div>
// setup(root): properties and events setup(root) { root.querySelector('#lb-help').options = reports; root.querySelector('#lb-err').options = reports; root.querySelector('#lb-inv').options = reports; const off = root.querySelector('#lb-off'); off.options = reports; off.value = 'bs'; root.querySelector('#lb-empty').options = []; }

Height

height is the maximum list height in px. The box scrolls past it and the active row is kept in view.

<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:start"> <sui-listbox id="lb-short" label="Short list" height="110"></sui-listbox> <sui-listbox id="lb-tall" label="Tall list" height="220"></sui-listbox> </div>
// setup(root): properties and events setup(root) { root.querySelector('#lb-short').options = customers; root.querySelector('#lb-tall').options = customers; }

A long list

From virtualScrollThreshold options, 200 by default, the box draws only the rows near the view and stands the rest in a blank the height of the list. The arrows, Home, End, typeahead and the position a screen reader reads still count over every option.

Click a row, then press End.
<div style="display:flex;flex-wrap:wrap;gap:14px;align-items:flex-start"> <div style="max-width:340px;flex:1 1 280px"><sui-listbox id="lb-long" label="Customer" filter height="220"></sui-listbox></div> <span id="lb-long-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Click a row, then press End.</span> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#lb-long'); const log = root.querySelector('#lb-long-log'); el.options = longCustomers(5000); const count = () => { log.textContent = `5,000 options, ${el.querySelectorAll('[role="option"]').length} rows in the page.`; }; requestAnimationFrame(count); el.addEventListener('suiChange', (e) => { log.textContent = `Picked ${e.detail}.`; }); }

Read-only

readonly lets the rows be walked and searched but not picked, and the pick still posts.

<div style="display:grid;gap:14px;max-width:320px"> <sui-listbox id="ro-sui-listbox" label="Customer" name="sui-listbox" value="Derby City Events" readonly required filter></sui-listbox> </div>
// setup(root): properties and events setup(root) { root.querySelector('#ro-sui-listbox').options = customers; }

Empty and no matches

emptyMessage is the row for a list that has nothing in it and emptyFilterMessage the row for a search that found nothing; they are two states and two sentences, so "Nothing here yet." is never shown over a long list the reader has just narrowed to none.

<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:start"> <sui-listbox id="lb-empty" label="Customer" empty-message="No customers yet."></sui-listbox> <sui-listbox id="lb-nomatch" label="Customer" filter empty-filter-message="No customer matches that."></sui-listbox> </div>
// setup(root): properties and events setup(root) { root.querySelector('#lb-empty').options = []; const el = root.querySelector('#lb-nomatch'); el.options = customers; requestAnimationFrame(() => { const box = el.querySelector('.sui-popup-search-box input'); if (box) { box.value = 'zzz'; box.dispatchEvent(new Event('input')); } }); }

In a form

Single selection posts one entry under the host name. Multiple posts one entry per selected value, the way a native select multiple does.

Submit to see what posts.
<form id="lb-form" style="display:grid;gap:14px;max-width:340px"> <sui-listbox id="lb-form-report" label="Report" name="report" required height="150"></sui-listbox> <sui-listbox id="lb-form-customers" label="Customers" name="customer" multiple height="150"></sui-listbox> <sui-button type="submit" variant="brand" label="Run report"></sui-button> <output id="lb-out" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Submit to see what posts.</output> </form>
// setup(root): properties and events setup(root) { const form = root.querySelector('#lb-form'); const out = root.querySelector('#lb-out'); const report = root.querySelector('#lb-form-report'); report.options = reports; report.value = 'ar'; const picked = root.querySelector('#lb-form-customers'); picked.options = customers; picked.value = ['Silver Hills Church']; form.addEventListener('submit', (e) => { e.preventDefault(); const entries = [...new FormData(form)].map(([k, v]) => `${k}=${v}`); out.textContent = entries.length ? entries.join(' ') : 'Nothing posted.'; }); }

Events

suiChange fires on every pick, with the value in single mode and the full array when multiple. The list has no open state, so it emits no suiOpen or suiClose.

Idle.
<div style="display:flex;flex-wrap:wrap;gap:14px;align-items:flex-start"> <div style="max-width:300px;flex:1 1 260px"><sui-listbox id="lb-ev" label="Customers to email" multiple height="150"></sui-listbox></div> <span id="lb-ev-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Idle.</span> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#lb-ev'); const log = root.querySelector('#lb-ev-log'); el.options = customers; el.addEventListener('suiChange', (e) => { log.textContent = e.detail.length ? `Selected ${e.detail.join(', ')}.` : 'Nothing selected.'; }); }

Keyboard

KeyAction
ArrowDown ArrowUpMoves the active row, skipping disabled options and wrapping at either end.
Home EndMoves to the first or last enabled row. In the search box the caret keeps them.
EnterPicks the active row; with multiple, toggles it.
SpacePicks the active row; with multiple, toggles it. In the search box it types a space.
Printable charactersJumps the active row to the next label starting with the typed text. In the search box they filter the rows.

API

<sui-listbox>

Properties

PropertyAttributeTypeDefaultDescription
disableddisabledbooleanfalseDisables the list. A disabled list posts nothing and never holds the form.
emptyFilterMessageempty-filter-messagestringstring | undefinedRow shown when the filter matched nothing. An empty list and a search that found nothing are different states and one sentence cannot be right about both: "Nothing here yet." is wrong over a list of two hundred the reader has just narrowed to none.
emptyMessageempty-messagestringstring | undefinedRow shown when the option list is empty.
errorerrorstringstring | undefinedError message; paints the box in the danger tone and sets aria-invalid.
filterfilterbooleanfalseSearch box at the top of the list. It narrows the rows without changing the selection.
heightheightnumber220Maximum list height in px; the box scrolls beyond it and keeps the active row in view.
helphelpstringstring | undefinedHelp text under the list. Replaced by error when set.
invalidinvalidbooleanfalseDanger border without a message, for a form that lists every problem in one summary block. It is the tone only: it reports no validity error of its own, and the caller clears it.
labellabelstringstring | undefinedLabel above the list.
loadingloadingbooleanfalseShows the options are still on their way: the box shows the loading row instead of options. Set it while fetching, clear it when options land.
loadingMessageloading-messagestringstring | undefinedText of the loading row.
multiplemultiplebooleanfalseAllows more than one selection; value and the suiChange detail become an array.
namenamestringstring | undefinedForm control name. Reflected: the form entry is built from the content attribute.
optionsproperty onlySuiListboxEntry[][]Options: strings or numbers, { label, value, disabled } objects, or { label, items } groups whose items are either. A group draws a header over its rows. Property, not attribute.
readOnlyreadonlybooleanfalseDisplay only: the rows can be read, walked and searched but not picked, and the picks still post. Neither a read-only nor a disabled list holds the form on valueMissing.
requiredrequiredbooleanfalseMarks the control required for form validation (at least one selection).
valuevalueSuiChoiceValueSuiKey[] | number | string | undefinedThe picked option's value, or the array of picked values when multiple, each as its option gives it (a number stays a number). Written as an attribute it is text, and it still picks the option whose value reads the same. Mutable; written before suiChange fires.
virtualScrollThresholdvirtual-scroll-thresholdnumber200Option count at which the box starts mounting only the rows near the viewport instead of all of them, so a list of exactly this many is windowed. The keyboard, the ids and the counts a screen reader reads still cover every option. Set it to 0 to keep every row in the DOM whatever the list costs, or to 1 to window a short list under test.

Events

EventDetailReactVueDescription
suiChangeSuiChoiceValueSuiKey[] | number | stringonSuiChange@sui-changeFires when the selection changes; detail is the new value: the picked option's value, or the array of picked values when multiple.

Methods

MethodSignatureDescription
setFocus()setFocus() => Promise<void>Moves keyboard focus to the list (or the filter box when present), as el.focus() does.

Types

SuiChoiceValue typeWhat a choice element holds: one option's value, or, where it takes several (multiple), the array of the picked values. A value keeps the type its option gave it, so a numeric id comes back a number through value, the events and a framework binding; only the form posts text.
No members.
SuiKey typeA record's or an item's key wherever the library identifies one: a row, a tree node, a list item, a selected value. Numbers stay numbers, so a numeric id round-trips through selection, the events and a framework binding without turning into a string.
No members.
SuiListboxEntry typeOne entry of options: a string or a number, an option object, or a group.
No members.
SuiListboxGroup interfaceA group: its header label and the options under it. Groups never nest.
labelstring
itemsSuiListboxOptionInput[]
SuiListboxOption interfaceOne option. value is what value holds, as given: a number stays a number through value, suiChange and a framework binding. The form posts its text.
labelstring
valueSuiKey
disabled?boolean
SuiListboxOptionInput typeAn option: a string or a number stands for itself as label and value.
No members.

Theming

Every design token this element can render with, read from its own stylesheets and the ones it imports. Set a token on :root or on any ancestor and every use below follows it. The values shown are the ones in the theme you are viewing. The list is read per stylesheet, so an element that imports a shared drawing module lists that module's tokens too, whether or not it paints with all of them.

See also