Slab

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.

Styled example As it ships
Ana, finisher Ben, pump operator Chen, form setter Dee, laborer Eli, on leave
HTML
<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>
Ana, finisher Ben, pump operator Chen, form setter Dee, laborer Eli, on leave
HTML
<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

Install
pnpm add slab-elements lit
HTML
<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>

Attributes
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
Properties
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.
Methods
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
Events
Name Type Description
input Event
change Event
Slots
Name Type Description
(default) Option
description Element | string
CSS parts
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>

Attributes
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
Properties
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.
Slots
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);
  }
`;