Selection
Listbox
A list of Options to pick one or more from, right on the page instead of in a popup.
- Element
<slab-listbox>- Built on
[role=listbox]- As Markdown
- listbox.md
Example
Here's a styled version, <slab-styled-listbox>. It's not
in the package — it's an example of what you can build with the
headless element. Copy it and make it yours at the
bottom of this page. Switch to "As it ships" to see the same markup
with the headless element and no CSS at all. That's your starting
point.
<slab-styled-listbox label="Crew" multiple>
<slab-styled-option value="ana" selected>Ana, finisher</slab-styled-option>
<slab-styled-option value="ben">Ben, pump operator</slab-styled-option>
<slab-styled-option value="chen">Chen, form setter</slab-styled-option>
<slab-styled-option value="dee">Dee, laborer</slab-styled-option>
<slab-styled-option value="eli" disabled>Eli, on leave</slab-styled-option>
</slab-styled-listbox>
<slab-listbox label="Crew" multiple>
<slab-option value="ana" selected>Ana, finisher</slab-option>
<slab-option value="ben">Ben, pump operator</slab-option>
<slab-option value="chen">Chen, form setter</slab-option>
<slab-option value="dee">Dee, laborer</slab-option>
<slab-option value="eli" disabled>Eli, on leave</slab-option>
</slab-listbox>
Usage
pnpm add slab-elements lit
<script type="module">
import 'slab-elements/listbox.js';
import 'slab-elements/option.js';
</script>
<slab-listbox label="Size" name="size">
<slab-option value="s">Small</slab-option>
<slab-option value="m" selected>Medium</slab-option>
<slab-option value="l">Large</slab-option>
</slab-listbox>
Option, documented here, is also what Select and Combobox use.
Keyboard
| Key | What it does |
|---|---|
| Up, Down | Moves the active Option, stopping at each end and skipping disabled ones. |
| Home, End | Moves to the first or last Option. |
| A character | Moves to the next Option whose label starts with what’s typed. |
| Enter, Space | Selects the active Option, or toggles it when multiple is set. |
With multiple, Shift with Up or Down moves and toggles, Shift with Space
selects from the last selected Option to the active one, Control or Command
with Shift and Home or End selects from the active one to the first or last,
and Control or Command with A selects every Option, or none.
Behavior
<slab-listbox>
A list of Options to select one or more from, with no visual styles.
There's no native listbox: <select> draws its own options, and they can't be anything but text. So this is a <div role="listbox"> and ARIA does the rest. Not a <ul>: a list may only hold list items, which neither a slot nor an Option is, and the role would replace its semantics anyway.
Options are found by class rather than by tag name, so your subclass of Option can be named anything.
Focus stays on the listbox, and the active Option is pointed at with aria-activedescendant rather than focused, the way the APG's own listbox does it. It's what lets a Combobox keep focus in its input while the user moves through the Options, and a Select keep it on its button. Read or set the active Option with activeOption.
The arrow keys, Home and End move the active Option, stopping at each end and skipping disabled Options. Typing moves it to the next Option whose label starts with what was typed. Space, Enter, and a click select it, or toggle it when multiple is set. Then, as in the APG's multi-select listbox, Shift with an arrow key moves and toggles, Shift with Space selects every Option from the one last selected to the active one, Control or Command with Shift and Home or End selects every Option from the active one to the first or last and moves there, and Control or Command with A selects every Option, or none when all of them already are.
Style it through its parts and through :state(disabled), :state(error), and :state(required), and each Option through :state(active), :state(disabled), and :state(selected).
<slab-option>
An Option with no visual styles. The host is the option, so style it directly.
It's never focused. Focus stays on its Listbox, which points at the active Option with aria-activedescendant, so a Select or a Combobox can keep focus where it is while the user moves through the Options.
Its Listbox sets :state(active). It sets :state(disabled), which is also set while its Listbox is disabled, and :state(selected) itself.
API
<slab-listbox>
| Name | Type | Default | Description |
|---|---|---|---|
label
Required
|
string |
||
disabled
|
boolean |
false |
|
hide-label
|
boolean |
false |
|
multiple
|
boolean |
false |
|
name
|
string |
'' |
|
required
|
boolean |
false |
|
value
|
string |
'' |
The value of the first selected and enabled Option. Setting it selects the Option with that value, and only that one. Not reflected, to match a native control. |
version
|
string |
| Name | Type | Description |
|---|---|---|
activeOption
|
Option | null |
The Option the user is on, which Space, Enter and typeahead act from. It's pointed at rather than focused, so focus stays where it is. |
form
Read-only
|
HTMLFormElement | null |
|
options
Read-only
|
Option[] |
|
selectedOptions
Read-only
|
Option[] |
Selected and enabled Options, in the order they appear. |
validity
Read-only
|
ValidityState |
|
values
|
string[] |
The value of every selected and enabled Option, in the order they appear. Setting it selects the Options with those values, and only those. |
| Signature | Description |
|---|---|
checkValidity(): boolean
|
|
focus(options?: FocusOptions): void
|
|
reportValidity(): boolean
|
|
resetValidityFeedback(): void
|
Clears the validity feedback without changing validity. |
setCustomValidity(message: string): void
|
|
setValidity(flags?: ValidityStateFlags, message?: string): void
|
| Name | Type | Description |
|---|---|---|
input
|
Event |
|
change
|
Event |
| Name | Type | Description |
|---|---|---|
(default)
|
Option |
|
description
|
Element | string |
| Name | Description |
|---|---|
description
|
Text about the Listbox, and its validity message when it has one |
label
|
The label |
listbox
|
The Options |
validity-message
|
<slab-option>
| Name | Type | Default | Description |
|---|---|---|---|
label
|
string |
Names the Option in place of its text, and is what typeahead matches against. | |
disabled
|
boolean |
false |
|
selected
|
boolean |
false |
Like a native option, the attribute is the default state and the property is the current one. Style :state(selected) rather than [selected]. |
value
|
string |
'' |
Its label, or its text when it has no label, when not set. The same as a native option. |
version
|
string |
| Name | Type | Description |
|---|---|---|
text
Read-only
|
string |
What the user reads as the Option's name: its label, or its text when it has no label. |
| Name | Type | Description |
|---|---|---|
(default)
|
Element | string |
The label, plus anything else a design puts in an Option |
Copy and own it
<slab-styled-listbox> isn't in the package. It's an example —
copy it into your project and change whatever you want. Under the
hood, it's just the headless element, subclassed, with
styles set. That's it. Keyboard, focus, ARIA, forms, and
events all come from the headless element, and the styles only reach
in through ::part() and :state(), same as
yours would.
To use it, copy these files, swap the tag in
@customElement() for one of your own, and import it.
tokens.styles.ts holds the colors and fonts every styled
example shares, so you only need it once.
listbox.ts
import { customElement } from 'lit/decorators.js';
import Listbox from 'slab-elements/listbox.js';
import styles from './listbox.styles.js';
import tokens from './tokens.styles.js';
declare global {
interface HTMLElementTagNameMap {
'slab-styled-listbox': StyledListbox;
}
}
/**
* Listbox, styled: its Options in an ink-bordered box that casts an ember
* shadow while it's focused.
*/
@customElement('slab-styled-listbox')
export default class StyledListbox extends Listbox {
static override styles = [tokens, styles];
}
listbox.styles.ts
import { css } from 'lit';
export default css`
:host {
color: var(--ink);
display: grid;
font-family: var(--font-body);
}
[part~='label'] {
font-family: var(--font-display);
font-size: 1.0625rem;
font-weight: 600;
margin-block-end: 0.375rem;
}
:host(:state(required)) [part~='label']::after {
color: var(--ember-deep);
content: ' *' / '';
}
/*
Focus stays on the listbox and the active Option shows where the keyboard
is, so the listbox's own focus is the shadow, as Text Field's is.
*/
[part~='listbox'] {
background-color: var(--paper);
border: var(--border);
max-block-size: 16rem;
overflow-y: auto;
padding: 0.25rem 0;
transition: box-shadow 100ms;
&:focus-visible {
box-shadow: 4px 4px 0 var(--ember);
outline: 2px solid transparent;
}
}
:host(:state(error)) [part~='listbox'] {
border-color: var(--ember-deep);
}
:host(:state(disabled)) [part~='listbox'] {
background-color: var(--chalk);
border-style: dashed;
color: var(--ash);
}
/*
Spaced by what's in it rather than by a gap, so a description with
nothing in it takes no room.
*/
[part~='description'] {
color: var(--ash);
display: grid;
font-size: 0.875rem;
}
::slotted([slot='description']) {
margin-block-start: 0.375rem;
}
[part~='validity-message'] {
color: var(--ember-deep);
font-weight: 600;
margin-block-start: 0.375rem;
&::before {
content: '■ ' / '';
}
}
@media (prefers-reduced-motion: reduce) {
[part~='listbox'] {
transition: none;
}
}
`;
option.ts
import { customElement } from 'lit/decorators.js';
import Option from 'slab-elements/option.js';
import styles from './option.styles.js';
import tokens from './tokens.styles.js';
declare global {
interface HTMLElementTagNameMap {
'slab-styled-option': StyledOption;
}
}
/**
* Option, styled: a row that inverts to paper on ink while it's active, with
* an ember bar on its edge, and a check while it's selected.
*/
@customElement('slab-styled-option')
export default class StyledOption extends Option {
static override styles = [tokens, styles];
}
option.styles.ts
import { css } from 'lit';
export default css`
:host {
align-items: center;
border-inline-start: 4px solid transparent;
cursor: pointer;
display: flex;
font-family: var(--font-body);
gap: 0.75rem;
justify-content: space-between;
padding: 0.5rem 0.875rem 0.5rem 0.75rem;
transition:
background-color 100ms,
border-color 100ms,
color 100ms;
}
/*
Before ":state(active)", which wins when both apply. Hovering doesn't make
an Option active, as in the APG's select-only combobox, so the keyboard's
place stays visible beside the pointer's.
*/
:host(:hover) {
background-color: var(--chalk);
}
:host(:state(active)) {
background-color: var(--ink);
border-inline-start-color: var(--ember);
color: var(--paper);
}
:host(:state(selected)) {
font-weight: 600;
}
/* The check: a corner of border, turned. */
:host(:state(selected))::after {
block-size: 0.625rem;
border-block-end: 2px solid currentcolor;
border-inline-end: 2px solid currentcolor;
content: '';
flex-shrink: 0;
inline-size: 0.3125rem;
rotate: 45deg;
translate: 0 -0.125rem;
}
:host(:state(disabled)) {
color: var(--ash);
cursor: not-allowed;
text-decoration: line-through;
}
:host(:state(disabled):state(active)) {
background-color: var(--chalk);
color: var(--ash);
}
@media (prefers-reduced-motion: reduce) {
:host {
transition: none;
}
}
`;
tokens.styles.ts
import { css } from 'lit';
/*
The styled components' palette and type, taken from the logo: ink for the
courses, ember for the base, paper behind them.
Each is a short alias for a "--slab-styled-*" property, with the default as
its fallback. Retheming is "--slab-styled-ember" and the rest on ":root",
which every shadow root inherits. Were the defaults declared under those
names here instead, each host's own declaration would shadow the page's.
Each default is "light-dark()", so the components follow the page's
"color-scheme": "dark" for dark, "light" for light, and "light dark" to
follow the operating system. Ink and paper trade places in the dark, so
ink is always the text and paper always what's behind it. Contrast is given
as light, then dark.
*/
export default css`
:host {
/* Secondary text. 6.8:1 and 6.9:1 on paper. */
--ash: var(--slab-styled-ash, light-dark(#5e5a55, #a8a19a));
--border: 2px solid var(--ink);
/* Behind disabled and read-only controls, and hovered rows. */
--chalk: var(--slab-styled-chalk, light-dark(#f4f1ec, #2b2926));
/*
The logo's orange, brighter in the dark. 4.25:1 and 5.2:1 on paper is
enough for borders, fills and large text, but not always for small text,
which takes "--ember-deep".
*/
--ember: var(--slab-styled-ember, light-dark(#d9492a, #ec5f3b));
/*
Orange for small text on paper, and for paper text on it. 5.7:1 and
7.4:1.
*/
--ember-deep: var(--slab-styled-ember-deep, light-dark(#b3381a, #ff8566));
--font-body: var(
--slab-styled-font-body,
'IBM Plex Sans',
system-ui,
sans-serif
);
--font-display: var(
--slab-styled-font-display,
'Zilla Slab',
rockwell,
'Roboto Slab',
serif
);
--font-mono: var(
--slab-styled-font-mono,
'IBM Plex Mono',
ui-monospace,
monospace
);
--ink: var(--slab-styled-ink, light-dark(#1c1c1c, #f4f1ec));
--paper: var(--slab-styled-paper, light-dark(#fff, #1a1918));
/* The hard, offset shadow a raised surface casts. */
--shadow: 4px 4px 0 var(--ink);
}
`;