Skip to content
ShineUI
Install

Select

One choice, found fast.

<sui-select>SelectionStable

About

Single-choice dropdown: one pick from a list the user already knows. It draws the label, help and error line, and can add a search box and a clear control. Form-associated, so it posts under its name. For free text with suggestions use sui-auto-complete; for several picks use sui-multi-select.

Usage

<sui-select label="Deposit account" name="account" value="Operating 4402" placeholder="Choose an account" clearable></sui-select> <script type="module"> import { defineCustomElements } from "@shineui/core/loader"; import "@shineui/core/shineui.css"; defineCustomElements(); const el = document.querySelector("sui-select"); el.options = ["Operating 4402", "Payroll 4410", "Money Market 8871"]; /* SuiSelectEntry[] */ el.addEventListener("suiChange", (e) => console.log(e.detail)); </script>
import { SuiSelect } from "@shineui/react"; import "@shineui/core/shineui.css"; export function Example() { const options = ["Operating 4402", "Payroll 4410", "Money Market 8871"]; /* SuiSelectEntry[] */ return <SuiSelect label="Deposit account" name="account" value="Operating 4402" placeholder="Choose an account" clearable options={options} onSuiChange={(e) => console.log(e.detail)} />; }
<script setup> import { SuiSelect } from "@shineui/vue"; import "@shineui/core/shineui.css"; const options = ["Operating 4402", "Payroll 4410", "Money Market 8871"]; // SuiSelectEntry[] const onChange = (e) => console.log(e.detail); </script> <template> <SuiSelect label="Deposit account" name="account" value="Operating 4402" placeholder="Choose an account" clearable :options="options" @sui-change="onChange" /> </template>
import { Component } from "@angular/core"; import { SuiSelect } from "@shineui/angular"; @Component({ standalone: true, imports: [SuiSelect], template: `<sui-select label="Deposit account" name="account" value="Operating 4402" placeholder="Choose an account" clearable [(open)]="open" [options]="options" (suiChange)="onChange($event)"></sui-select>`, }) export class Example { open = false; options = ["Operating 4402", "Payroll 4410", "Money Market 8871"]; // SuiSelectEntry[] onChange(e: CustomEvent) { console.log(e.detail); } }

Basic

The trigger is a button with aria-haspopup="listbox" and aria-expanded, and it points at the active row through aria-activedescendant. With clearable set it advertises Delete through aria-keyshortcuts.

<div style="max-width:320px"> <sui-select label="Deposit account" name="account" value="Operating 4402" placeholder="Choose an account" clearable></sui-select> </div>
// setup(root): properties and events setup(root) { root.querySelector('sui-select').options = accounts; }

Open with filter

The search box takes focus when the panel opens. A disabled option is announced with aria-disabled and the arrow keys step over it.

<div style="max-width:320px;min-height:320px"> <sui-select id="sel-open" label="Customer" value="riverbend" filter open></sui-select> </div>
// setup(root): properties and events setup(root) { root.querySelector('#sel-open').options = customers; }

Grouped options

A group is { label, items } in the options array and draws an eyebrow header over its rows. The rows stay one list: the arrows walk straight through a header, and a group whose rows all miss the filter loses it.

<div style="max-width:320px;min-height:340px"> <sui-select id="sel-groups" label="Deposit account" filter open></sui-select> </div>
// setup(root): properties and events setup(root) { root.querySelector('#sel-groups').options = groupedAccounts; }

Icons and avatars

An option's icon is drawn before its label, in the row and in the trigger once picked. render draws the row and the chosen value yourself, called with option or value, while the label still names the row and drives the filter and the typeahead.

<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:start;min-height:280px"> <sui-select id="sel-icons" label="Priority" value="high"></sui-select> <sui-select id="sel-avatars" label="Assignee" value="dana-whitfield" filter open></sui-select> </div>
// setup(root): properties and events setup(root) { root.querySelector('#sel-icons').options = priorities; root.querySelector('#sel-avatars').options = people.map(assignee(root.ownerDocument)); }

Option descriptions

description is a second muted line under the label in the list, for what the option means or costs. It joins the row's accessible description; the filter and the typeahead go by the label, and the trigger shows the label alone.

<div style="max-width:320px;min-height:320px"> <sui-select id="sel-desc" label="Role" value="member" filter open></sui-select> </div>
// setup(root): properties and events setup(root) { root.querySelector('#sel-desc').options = roles; }

As a property

variant="quiet" draws the value as text in the surrounding font, its icon before it, with no shell at rest: the fill comes on hover, the focus ring on focus and the shell only while the panel is open. The label is not drawn but still names the control, so the host prints the property name.

Status Priority Assignee Reviewer
<div style="display:grid;grid-template-columns:76px minmax(0,1fr);gap:12px 10px;align-items:center;max-width:320px;font:550 13.5px/1.5 var(--font-sans);color:var(--ink);min-height:300px;align-content:start"> <span style="font:460 12.5px/1.3 var(--font-sans);color:var(--ink-muted)">Status</span><sui-select id="sel-prop-status" variant="quiet" label="Status" size="sm" value="progress" open></sui-select> <span style="font:460 12.5px/1.3 var(--font-sans);color:var(--ink-muted)">Priority</span><sui-select id="sel-prop-priority" variant="quiet" label="Priority" size="sm" value="high"></sui-select> <span style="font:460 12.5px/1.3 var(--font-sans);color:var(--ink-muted)">Assignee</span><sui-select id="sel-prop-assignee" variant="quiet" label="Assignee" size="sm" value="dana-whitfield" clearable placeholder="Unassigned"></sui-select> <span style="font:460 12.5px/1.3 var(--font-sans);color:var(--ink-muted)">Reviewer</span><sui-select id="sel-prop-reviewer" variant="quiet" label="Reviewer" size="sm" clearable placeholder="Unassigned"></sui-select> </div>
// setup(root): properties and events setup(root) { root.querySelector('#sel-prop-status').options = statuses; root.querySelector('#sel-prop-priority').options = priorities; root.querySelector('#sel-prop-assignee').options = people.map(assignee(root.ownerDocument)); root.querySelector('#sel-prop-reviewer').options = people.map(assignee(root.ownerDocument)); }

States

The field draws the label, the required asterisk and one message line. An error replaces the help line, paints the trigger in the danger tone and sets aria-invalid.

<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;align-items:start"> <sui-select id="sel-help" label="Report basis" help="Cash basis hides open invoices." placeholder="Choose a basis"></sui-select> <sui-select id="sel-req" label="Customer" required placeholder="Choose a customer"></sui-select> <sui-select id="sel-err" label="Customer" required error="Customer is required." placeholder="Choose a customer"></sui-select> <sui-select id="sel-off" label="Report basis" value="Accrual" disabled help="Locked by the close checklist."></sui-select> </div>
// setup(root): properties and events setup(root) { root.querySelector('#sel-help').options = bases; root.querySelector('#sel-req').options = customers; root.querySelector('#sel-err').options = customers; root.querySelector('#sel-off').options = bases; }

Loading

While loading is set a spinner stands in for the chevron and the panel shows the loading row instead of options; the listbox carries aria-busy. Set it before the fetch and clear it when the options land.

<div style="min-height:180px"> <div style="display:flex;flex-wrap:wrap;gap:14px;align-items:flex-end"> <div style="max-width:280px;flex:1 1 240px"><sui-select id="sel-loading" label="Deposit account" placeholder="Choose an account" loading open></sui-select></div> <sui-button id="sel-reload" variant="outline" label="Reload accounts"></sui-button> </div> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#sel-loading'); const reload = root.querySelector('#sel-reload'); const fetchAccounts = () => { el.loading = true; setTimeout(() => { el.options = accounts; el.loading = false; }, 1800); }; reload.addEventListener('click', fetchAccounts); fetchAccounts(); }

Sizes

size is the same sm, md, lg vocabulary and the same 28, 34, 42 heights as the rest of the control family.

<div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px;align-items:end"> <sui-select id="sel-sm" label="Small" size="sm" value="Cash"></sui-select> <sui-select id="sel-md" label="Medium" value="Cash"></sui-select> <sui-select id="sel-lg" label="Large" size="lg" value="Cash"></sui-select> </div>
// setup(root): properties and events setup(root) { ['#sel-sm', '#sel-md', '#sel-lg'].forEach((id) => { root.querySelector(id).options = bases; }); }

A long list

From virtualScrollThreshold options, 200 by default, the panel 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 all still count over every option; set the threshold to 0 to keep them all in the page.

Open the panel, then press End.
<div style="display:flex;flex-wrap:wrap;gap:14px;align-items:flex-start;min-height:320px"> <div style="max-width:280px;flex:1 1 240px"><sui-select id="sel-long" label="Deposit account" placeholder="Choose an account" filter></sui-select></div> <span id="sel-long-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Open the panel, then press End.</span> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#sel-long'); const log = root.querySelector('#sel-long-log'); el.options = longLedger(5000); const count = () => { // The open panel renders inside the element. const rows = el.querySelectorAll('[role="option"]').length; log.textContent = `5,000 options, ${rows} rows in the page.`; }; el.addEventListener('suiOpen', () => requestAnimationFrame(count)); el.addEventListener('suiChange', (e) => { log.textContent = e.detail ? `Picked ${e.detail}.` : 'Cleared.'; }); }

Read-only

readonly keeps the trigger named and in the tab order, opens nothing and clears nothing, and the value still posts; a disabled select posts nothing. Neither holds the form on valueMissing.

<div style="display:grid;gap:14px;max-width:320px"> <sui-select id="ro-sui-select" label="Deposit account" name="sui-select" value="Operating 4402" readonly required></sui-select> </div>
// setup(root): properties and events setup(root) { root.querySelector('#ro-sui-select').options = accounts; }

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-select id="sel-empty" label="Deposit account" placeholder="No accounts yet" empty-message="No accounts on this workspace yet." open></sui-select> <sui-select id="sel-nomatch" label="Deposit account" filter placeholder="Search accounts" empty-filter-message="No account matches that." open></sui-select> </div>
// setup(root): properties and events setup(root) { root.querySelector('#sel-empty').options = []; const el = root.querySelector('#sel-nomatch'); el.options = accounts; requestAnimationFrame(() => { const box = el.querySelector('.sui-popup-search-box input'); if (box) { box.value = 'zzz'; box.dispatchEvent(new Event('input')); } }); }

In a form

The value posts under the host name through ElementInternals. Clearing the required field blocks the submit and the browser names the field from its label.

Submit to see what posts.
<form id="sel-form" style="display:grid;gap:14px;max-width:320px"> <sui-select label="Deposit account" name="account" value="Operating 4402" required clearable></sui-select> <sui-select label="Report basis" name="basis" value="Accrual"></sui-select> <sui-button type="submit" variant="brand" label="Save"></sui-button> <output id="sel-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('#sel-form'); const out = root.querySelector('#sel-out'); form.querySelectorAll('sui-select')[0].options = accounts; form.querySelectorAll('sui-select')[1].options = bases; form.addEventListener('submit', (e) => { e.preventDefault(); const entries = [...new FormData(form)].map(([k, v]) => `${k}=${v}`); out.textContent = entries.length ? entries.join(' ') : 'Nothing posted.'; }); }

Numbers as values

An option keeps the type of its value: a number in the options comes back a number in value, in the suiChange detail and through v-model or ngModel, and only the form posts it as text. value="2026" written as an attribute still picks the option 2026.

Pick a year.
<div style="display:flex;flex-wrap:wrap;gap:14px;align-items:center"> <div style="max-width:220px;flex:1 1 200px"><sui-select id="sel-num" label="Fiscal year" name="year" value="2026"></sui-select></div> <span id="sel-num-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Pick a year.</span> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#sel-num'); const log = root.querySelector('#sel-num-log'); el.options = [2024, 2025, 2026, 2027]; el.addEventListener('suiChange', (e) => { log.textContent = `value is the ${typeof e.detail} ${e.detail}.`; }); }

Events

suiOpen fires after the element opens itself, and suiClose fires before it closes with a reason of pick, escape, outside or trigger; neither bubbles past the select, so a dialog around it never mistakes them for its own. suiChange carries the picked value, and an empty string after a clear.

Idle.
<div style="display:flex;flex-wrap:wrap;gap:14px;align-items:center"> <div style="max-width:280px;flex:1 1 240px"><sui-select id="sel-ev" label="Status" placeholder="Choose a status" clearable></sui-select></div> <span id="sel-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('#sel-ev'); const log = root.querySelector('#sel-ev-log'); el.options = ['Open', 'Overdue', 'Paid', 'Draft']; el.addEventListener('suiOpen', () => { log.textContent = 'Panel open.'; }); el.addEventListener('suiClose', (e) => { log.textContent = `Panel closing (${e.detail}).`; }); el.addEventListener('suiChange', (e) => { log.textContent = e.detail ? `Picked ${e.detail}.` : 'Cleared.'; }); }

Keeping the panel open

suiClose is cancelable: calling preventDefault when the reason is pick keeps the panel up so the user can compare rows. Escape and an outside click still close it.

Pick a row and the panel stays up.
<div style="display:flex;flex-wrap:wrap;gap:14px;align-items:flex-start;min-height:320px"> <div style="max-width:280px;flex:1 1 240px"><sui-select id="sel-veto" label="Deposit account" placeholder="Choose an account" filter></sui-select></div> <span id="sel-veto-log" style="font:460 13px/1.6 var(--font-sans);color:var(--ink-muted)">Pick a row and the panel stays up.</span> </div>
// setup(root): properties and events setup(root) { const el = root.querySelector('#sel-veto'); const log = root.querySelector('#sel-veto-log'); el.options = accounts; el.addEventListener('suiClose', (e) => { if (e.detail !== 'pick') return; e.preventDefault(); log.textContent = `Kept open after picking ${el.value}.`; }); }

Keyboard

KeyAction
ArrowDown ArrowUpOpens the panel. Once open, moves the active row, skipping disabled options and wrapping at either end.
EnterOpens the panel, or picks the active row.
SpaceOn the trigger, opens the panel or picks the active row. In the search box it types a space.
Home EndMoves to the first enabled row, or to the last row.
EscapeCloses the panel and returns focus to the trigger.
TabCloses the panel and moves on.
Delete BackspaceOn the trigger, with clearable set, clears the value.
Printable charactersOn the trigger, once the panel is open, jumps the active row to the next label starting with the typed text. In the search box they filter the rows.

API

<sui-select>

Properties

PropertyAttributeTypeDefaultDescription
clearableclearablebooleanfalseX control to clear the value. Delete or Backspace on the trigger clears it from the keyboard.
disableddisabledbooleanfalseDisables the trigger. A disabled select 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 trigger in the danger tone and sets aria-invalid.
filterfilterbooleanfalseSearch box inside the panel.
helphelpstringstring | undefinedHelp text under the trigger. Replaced by error when set.
invalidinvalidbooleanfalseDanger border without a message, for a form that draws its summary elsewhere.
labellabelstringstring | undefinedLabel above the trigger.
loadingloadingbooleanfalseShows the options are still on their way: a spinner stands in for the chevron and the panel shows the loading row instead of options. Set it while fetching, clear it when options land.
loadingMessageloading-messagestringstring | undefinedText of the loading row.
namenamestringstring | undefinedForm control name. Reflected: the form entry is built from the content attribute.
openopenbooleanfalseOpens the panel. Mutable and the single source of truth: suiOpen announces an open the element performed, and the cancelable suiClose asks before it closes itself on pick, outside click, Escape and Tab.
optionsproperty onlySuiSelectEntry[][]Options: strings or numbers, { label, value, disabled, icon, description, render } objects, or { label, items } groups whose items are either. A group draws a header over its rows. icon is a Font Awesome class drawn before the label, in the row and in the trigger; description is a muted second line under the label in the row only; render draws the row and the chosen value yourself, for an avatar say, and takes the place it is drawing, 'option' or 'value'. Property, not attribute.
placeholderplaceholderstringstring | undefinedText shown while nothing is picked.
readOnlyreadonlybooleanfalseDisplay only: the trigger keeps its name and its place in the tab order, opens nothing and clears nothing, and the value still posts. Neither a read-only nor a disabled select holds the form on valueMissing.
requiredrequiredbooleanfalseA pick is required for the surrounding form to be valid.
sizesize"lg" | "md" | "sm"'md'Trigger height: sm 28, md 34 (default), lg 42, from the --control-* tokens. In the quiet variant it is the height of the box that paints on hover and while open; the text keeps the surrounding font.
tonetone"inverse" | undefinedInk for the quiet variant on the brand chrome. inverse takes the inverse inks, the inverse hover wash and the inverse focus colour, as sui-button's tone of the same name does, so the value reads on a tone="brand" toolbar. Opening the field shows its shell in the card's own inks. The default variant ignores it.
valuevalueSuiKeynumber | string | undefinedThe picked option's value, as the option gives it (a number stays a number); '' after a clear. Written as an attribute it is text, and it still picks the option whose value reads the same, so value="2" picks the option 2. Mutable; written before suiChange fires.
variantvariant"default" | "quiet"'default'default draws the field shell. quiet draws the value as text in the surrounding font, with its icon before it and no border or fill at rest, for a record's properties column: the fill comes on hover, the focus ring on focus, and the shell only while the panel is open. The label is not drawn but still names the control, so the host prints the property name. Keyboard, events, validation and the form post are the same in both. Anything else falls back to default.
virtualScrollThresholdvirtual-scroll-thresholdnumber200Option count at which the panel 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
suiChangeSuiKeynumber | stringonSuiChange@sui-changeFires when a value commits; detail is the new value: the option's value as given, or '' after a clear.
suiCloseSuiCloseReason"cancel" | "close-button" | "escape" | "outside" | "pick" | "scrim" | "trigger"onSuiClose@sui-closeFires before the element closes the panel itself; detail is 'pick' | 'escape' | 'outside' | 'trigger'. Cancelable: preventDefault() keeps it open, so detail === 'pick' is how a consumer keeps the panel up after a choice. Tab reports 'outside', because focus left the control and SuiCloseReason has no member for that. Does not bubble: a preventDefault() in a dialog's own suiClose listener cannot veto this one.
suiOpenvoidonSuiOpen@sui-openFires after the element opens the panel itself (trigger click, ArrowDown, Enter, Space). Does not bubble, so a dialog around the select never takes it for its own.

Methods

MethodSignatureDescription
setFocus()setFocus() => Promise<void>Moves keyboard focus to the trigger, as el.focus() does.

Types

SuiCloseReason typeWhy a dismissible surface is closing. Detail of the cancelable suiClose. A consumer expresses "a stray backdrop click must not close this" by calling preventDefault() on the reason it does not accept, which is why none of these needs a prop of its own.
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.
SuiRenderResult type
No members.
SuiSelectEntry typeOne entry of options: a string or a number, an option object, or a group.
No members.
SuiSelectGroup interfaceA group: its header label and the options under it. Groups never nest.
labelstring
itemsSuiSelectOptionInput[]
SuiSelectOption interfaceOne option.
labelstringText of the row: what the filter and the typeahead match, what a screen reader hears, and what the trigger shows once picked.
valueSuiKeyWhat value holds, as given: a number stays a number through value, suiChange and a framework binding. The form posts its text.
disabled?booleanGreys the row out; the arrows step over it.
icon?stringFont Awesome class drawn before the label, in the row and in the trigger.
description?stringA second line under the label in the row, in the muted ink, for what the option means or costs. It joins the row's accessible description; the filter, the typeahead and the trigger keep to the label. A row with one is taller, so a list long enough to be windowed should give every option one, or none.
render?(place: SuiSelectRenderPlace) => SuiRenderResultDraws the row, and the chosen value in the trigger, yourself: return a string or an element for the place asked for. It is called on every draw, so return a new element each time or keep one per place, since one node cannot sit in two places at once. The label still names the row for a screen reader and still drives the filter and the typeahead. A render that throws, as one that builds nodes with the page's document does on a server, draws the label instead.
SuiSelectOptionInput typeAn option: a string or a number stands for itself as label and value.
No members.
SuiSelectRenderPlace typeWhere an option's render output is drawn: its row in the list, or the trigger once it is the 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