Navigation

v0.0.4
Forms

Listbox

A list of options, the surface a combobox and a command palette open.

On this page

A listbox is a list of options to choose from. It is the surface a combobox and a command palette open, and it can stand in the page on its own. The stylesheet draws every state from ARIA, and a script moves the attributes: which option is active, what a key does, what is chosen. For a listbox that stands in the page that script is the listbox behavior; a combobox and a command palette drive their list from their own field.

Listbox

<div class="sheet sheet-flat max-w(320) mx-auto">  <div class="listbox" role="listbox" aria-label="Framework" tabindex="0" aria-activedescendant="fw-astro">    <div class="listbox-option" role="option" id="fw-astro" aria-selected="true" aria-checked="true">Astro</div>    <div class="listbox-option" role="option" id="fw-next" aria-checked="false">Next.js</div>    <div class="listbox-option" role="option" id="fw-svelte" aria-checked="false">SvelteKit</div>    <div class="listbox-option" role="option" id="fw-nuxt" aria-checked="false" aria-disabled="true">Nuxt</div>  </div></div><script type="module">  import { attach } from '@master/ui/behaviors'  attach()</script>
Click an option, or focus the list, move with the arrow keys and choose with Enter or Space.

#Two states, two attributes

A listbox tells apart where the keys are and what has been chosen.

  • Active is aria-selected="true": the option the arrow keys are on, the one Enter would take. It takes a chip tone. The pointer gives the same tone to the row under it, at once and without a fade.
  • Chosen is aria-checked="true": a value that is set. It carries a check at its end. An option with aria-checked in either state keeps room for the check, so labels do not move.

Focus usually stays on the list or on a text field, and aria-activedescendant names the active option. When an option takes real focus instead, it also draws an ink outline.

#Rich options

Put icon on a leading icon. listbox-body stacks a title over a listbox-description, and listbox-end sets a shortcut or a hint at the end of the row. listbox-group wraps a set of options with its listbox-label, and listbox-separator rules between groups.

Rich options

<div class="sheet sheet-flat max-w(360) mx-auto">  <div class="listbox" role="listbox" aria-label="Commands">    <div class="listbox-group" role="group" aria-labelledby="rich-suggestions">      <div class="listbox-label" id="rich-suggestions">Suggestions</div>      <div class="listbox-option" role="option" aria-selected="true">        <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">          <path d="M12 5v14M5 12h14" />        </svg>New project        <span class="listbox-end">          <kbd class="kbd">⌘</kbd>          <kbd class="kbd">N</kbd>        </span>      </div>      <div class="listbox-option" role="option">        <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">          <circle cx="11" cy="11" r="6" />          <path d="m20 20-4.35-4.35" />        </svg>        <span class="listbox-body">          <span>Search deployments</span>          <span class="listbox-description">By branch, commit or author</span>        </span>      </div>    </div>    <hr class="listbox-separator">    <div class="listbox-group" role="group" aria-labelledby="rich-team">      <div class="listbox-label" id="rich-team">Team</div>      <div class="listbox-option" role="option">        <span class="avatar avatar-xs">          <img class="avatar-image" src="/demo/portrait-1.svg" alt>        </span>Ada Lovelace</div>      <div class="listbox-option" role="option">        <span class="avatar avatar-xs">          <img class="avatar-image" src="/demo/portrait-2.svg" alt>        </span>Alan Turing</div>    </div>  </div></div>

#Filtering

A script filters by setting the hidden attribute on the options that do not match. Two things then happen without more script, through :has():

  • listbox-empty shows once every option is hidden, and hides again when one returns.
  • A listbox-group, a role="group" element around a caption and its options, hides while all of its options are hidden, so a caption never stands over nothing.

Nothing matches

<div class="sheet sheet-flat max-w(320) mx-auto">  <div class="listbox" role="listbox" aria-label="Regions">    <div class="listbox-option" role="option" hidden>Tokyo</div>    <div class="listbox-option" role="option" hidden>Frankfurt</div>    <div class="listbox-empty">No regions match "zz".</div>  </div></div>

#The behavior

listbox drives a listbox that stands in the page and holds focus itself, so the listbox has tabindex="0". It takes the element with role="listbox", has no options, and returns the function that detaches it.

app.js
import { listbox } from '@master/ui/behaviors/listbox.js'const list = document.querySelector('.listbox')listbox(list)list.addEventListener('change', () => {  const chosen = list.querySelectorAll('[aria-checked="true"]')})
  • The active option. The keys and a click move aria-selected="true" to one option, name it in aria-activedescendant on the listbox and scroll it into view. Focus never leaves the listbox. An option without an id is given one. When focus arrives and no option is active, the chosen option becomes active, or the first one.
  • Choosing. Enter, Space or a click sets aria-checked="true" on the option and false on the others.
  • Events. The listbox dispatches input, then change, each time what is chosen changes. Read the options with aria-checked="true".
  • Skipped. Hidden options and those with aria-disabled="true" are never active and cannot be chosen.

With aria-multiselectable="true" on the listbox, an option is checked or unchecked by itself and the others keep their state.

Several values

<div class="sheet sheet-flat max-w(320) mx-auto">  <div class="listbox" role="listbox" aria-label="Notify me about" aria-multiselectable="true" tabindex="0">    <div class="listbox-option" role="option" id="notify-deploys" aria-checked="true">Deployments</div>    <div class="listbox-option" role="option" id="notify-comments" aria-checked="false">Comments</div>    <div class="listbox-option" role="option" id="notify-billing" aria-checked="true">Billing</div>    <div class="listbox-option" role="option" id="notify-digest" aria-checked="false" aria-disabled="true">Weekly digest</div>  </div></div><script type="module">  import { attach } from '@master/ui/behaviors'  attach()</script>

A listbox that a text field controls belongs to that field's behavior, which drives the list from the field. listbox leaves alone a listbox named by the aria-controls of a role="combobox" field, and attach() skips one inside a combobox or a command palette. Without a script a listbox is drawn as its attributes say and does not change.

#Floating

With the popover attribute a listbox is a floating sheet: a 12px corner around 8px rows, placed against its anchor and flipped at the viewport edge, with the same motion in and out as a menu. Use popover="manual" when a text field owns it, so typing does not dismiss it. The combobox shows this.

#Listbox, menu or select

  • A select is the native control for one value from a list. Use it when the options are plain text; it needs no script.
  • A menu holds actions, as native buttons and links.
  • A listbox holds values or results that a script filters, groups or loads: a combobox, a command palette, a multi-select.

#Keyboard

These are the keys of the listbox behavior, on a listbox that holds focus. The arrow keys stop at the first and the last option. In a combobox and a command palette the field has the keys of those patterns.

KeysAction
ArrowDown, ArrowUpMove the active option, skipping disabled ones, and scroll it into view
Home, EndMove to the first or the last option
Enter, SpaceChoose the active option; in a list with aria-multiselectable="true", check or uncheck it
TypingMove to the next option that starts with the typed letters

#Accessibility

  • Give the container role="listbox" and a label, and each option role="option". A listbox-group is role="group", labelled by its caption with aria-labelledby.
  • The behavior moves aria-selected and aria-activedescendant together and scrolls the active option into view; options keep a scroll margin so it clears the list's edge.
  • A disabled option has aria-disabled="true" and is skipped by the keys.

#React, Svelte and Vue

The listbox component renders the options from data, attaches the behavior and passes the chosen value in and out. Copy it into the project with npx master-ui add listbox; React, Svelte and Vue says how the components are built and used.

ui/listbox.tsx
'use client'import { useState, type ComponentProps } from 'react'import { listbox } from '@master/ui/behaviors/listbox.js'import { useBehavior } from './use-behavior'export interface ListboxOption {  /** What choosing the option gives `onValueChange`. */  value: string  label: string  description?: string  disabled?: boolean}export interface ListboxProps extends Omit<ComponentProps<'div'>, 'defaultValue' | 'role'> {  options: ListboxOption[]  /** The value of the chosen option, null for none; a list of them when `multiple`. */  value?: string | string[] | null  defaultValue?: string | string[] | null  onValueChange?: (value: string | string[] | null) => void  /** Any number of options may be chosen. */  multiple?: boolean}/** A listbox that stands in the page. Name it with `aria-label` or `aria-labelledby`. */export function Listbox({ options, value, defaultValue, onValueChange, multiple, className, ...root }: ListboxProps) {  const ref = useBehavior(listbox, undefined, { value, defaultValue, onValueChange })  const [first] = useState(() => [value ?? defaultValue ?? []].flat())  return (    <div className={className ? `listbox ${className}` : 'listbox'} role="listbox" tabIndex={0} aria-multiselectable={multiple || undefined} ref={ref} {...root}>      {options.map(option => (        <div className="listbox-option" role="option" key={option.value} data-value={option.value} aria-checked={first.includes(option.value)} aria-disabled={option.disabled || undefined}>          {option.description            ? <span className="listbox-body"><span>{option.label}</span><span className="listbox-description">{option.description}</span></span>            : option.label}        </div>      ))}    </div>  )}
ui/Listbox.svelte
<script lang="ts" module>  import type { HTMLAttributes } from 'svelte/elements'  export interface ListboxOption {    /** What choosing the option gives `onValueChange`. */    value: string    label: string    description?: string    disabled?: boolean  }  export interface ListboxProps extends Omit<HTMLAttributes<HTMLDivElement>, 'role' | 'children'> {    options: ListboxOption[]    /** The value of the chosen option, null for none; a list of them when `multiple`. Bind it, or leave it out. */    value?: string | string[] | null    onValueChange?: (value: string | string[] | null) => void    /** Any number of options may be chosen. */    multiple?: boolean  }</script><script lang="ts">  import { untrack } from 'svelte'  import { listbox } from '@master/ui/behaviors/listbox.js'  import { behavior } from './behavior.svelte.js'  let { options, value = $bindable(), onValueChange, multiple, class: className, ...root }: ListboxProps = $props()  const first = untrack(() => [value ?? []].flat())  const attach = behavior(listbox, undefined, {    value: () => value,    change: (next) => { value = next; onValueChange?.(next) },  })</script><!--  @component  A listbox that stands in the page. Name it with `aria-label` or `aria-labelledby`.--><div class={['listbox', className]} role="listbox" tabindex={0} aria-multiselectable={multiple || undefined} {@attach attach} {...root}>  {#each options as option (option.value)}    <!-- The behavior writes `aria-selected` on the option the keys are on. -->    <!-- svelte-ignore a11y_role_has_required_aria_props -->    <div class="listbox-option" role="option" data-value={option.value} aria-checked={first.includes(option.value)} aria-disabled={option.disabled || undefined}>      {#if option.description}        <span class="listbox-body"><span>{option.label}</span><span class="listbox-description">{option.description}</span></span>      {:else}        {option.label}      {/if}    </div>  {/each}</div>
ui/Listbox.vue
<script lang="ts">export interface ListboxOption {  /** What choosing the option gives the model. */  value: string  label: string  description?: string  disabled?: boolean}</script><script setup lang="ts">import { useTemplateRef } from 'vue'import { listbox } from '@master/ui/behaviors/listbox.js'import { useBehavior } from './use-behavior'defineOptions({ inheritAttrs: false })defineProps<{  options: ListboxOption[]  /** Any number of options may be chosen. */  multiple?: boolean}>()/** The value of the chosen option, null for none; a list of them when `multiple`: `v-model`. */const model = defineModel<string | string[] | null>()/* A listbox that stands in the page. Name it with `aria-label` or `aria-labelledby`. */const first = [model.value ?? []].flat()const root = useTemplateRef<HTMLDivElement>('root')useBehavior(root, listbox, undefined, model)</script><template>  <div class="listbox" role="listbox" tabindex="0" :aria-multiselectable="multiple || undefined" ref="root" v-bind="$attrs">    <div v-for="option in options" :key="option.value" class="listbox-option" role="option" :data-value="option.value" :aria-checked="first.includes(option.value)" :aria-disabled="option.disabled || undefined">      <span v-if="option.description" class="listbox-body"><span>{{ option.label }}</span><span class="listbox-description">{{ option.description }}</span></span>      <template v-else>{{ option.label }}</template>    </div>  </div></template>

#Classes

ClassDescription
listboxThe list; a floating sheet when it has the popover attribute
listbox-optionAn option, on an element with role="option". It stands for its data-value, or for its text when it has none
listbox-bodyInside an option: a title stacked over a description
listbox-descriptionThe muted line under an option's title, inside listbox-body
listbox-endA shortcut or a hint at the end of an option
listbox-groupA set of options with its caption, on an element with role="group" labelled by the caption; it hides while all of its options are hidden
listbox-labelA quiet caption over a group of options
listbox-separatorA hairline between groups, running to the list's edges
listbox-emptyWhat the list says when nothing matches; it shows once every option is hidden

#Custom properties

PropertyDefaultDescription
--listbox-inset4pxPadding around the options
--listbox-option-radiusvar(--radius-control-sm)Row corner; a floating list's corner is this plus the inset

#Source

@master/ui/components/listbox.css
/* * Listbox: a list of options to choose from, the surface a combobox and a command palette open. * The stylesheet draws every state from ARIA and leaves the behavior to the script that drives it: * aria-selected marks the active option, the one the arrow keys are on; aria-checked marks a chosen value * with a check. In the flow of the page it is a plain list; with the popover attribute it is a floating * sheet placed against its anchor, whose corner is a row's corner plus its 4px inset. */@layer components {  .listbox {    --listbox-inset: 4px;    --listbox-option-radius: var(--radius-control-sm);    min-width: 0;    margin: 0;    padding: var(--listbox-inset);    overflow-y: auto;    overscroll-behavior: contain;    color: var(--color-text-strong);    font: 400 .84375rem/1.2 var(--font-family-sans);    font-feature-settings: inherit;    list-style: none;    scrollbar-width: thin;    scrollbar-color: var(--color-line-strong) transparent;    /* The list holds focus while aria-activedescendant moves through it: the active row shows where. */    &:focus-visible {      outline: none;    }    /* Floating: opened with showPopover() by the script that owns the field. */    &[popover] {      --surface-color: var(--color-surface-floating);      min-width: 11rem;      max-width: min(24rem, calc(100vw - 1rem));      max-height: min(20rem, 60vh);      border: 0;      border-radius: calc(var(--listbox-option-radius) * var(--corner-scale) + var(--listbox-inset));      corner-shape: var(--corner-shape);      background: var(--color-surface-floating);      box-shadow: var(--shadow-floating);      transform-origin: top;      @apply --anchored;      @apply --pop;    }    @media (forced-colors: active) {      &[popover] {        outline: 1px solid CanvasText;        outline-offset: -1px;      }    }  }  /* An option: role="option" on any element. */  .listbox-option {    --icon-size: 1rem;    --icon-offset: -.0625rem;    --icon-color: var(--color-text-muted);    display: flex;    align-items: center;    gap: .625rem;    min-height: 2rem;    padding: .25rem .5rem;    border-radius: calc(var(--listbox-option-radius) * var(--corner-scale));    corner-shape: var(--corner-shape);    outline: none;    color: var(--color-text-strong);    scroll-margin: var(--listbox-inset);    user-select: none;    -webkit-user-select: none;    -webkit-tap-highlight-color: transparent;    cursor: pointer;    touch-action: manipulation;    /* A script filters by setting hidden; a display on this rule would otherwise bring the option back. */    &[hidden] {      display: none;    }    /* A row answers at once: no fade between rows as the pointer or the keys travel. */    @media (hover: hover) {      &:hover:not([aria-disabled="true"]) {        --icon-color: currentColor;        background: var(--color-surface-chip);      }    }    /* Pressed: a step deeper than the pointer's tone. */    &:active:not([aria-disabled="true"]) {      background: var(--color-surface-inset-hover);    }    &[aria-selected="true"] {      --icon-color: currentColor;      background: var(--color-surface-chip);    }    /* With real focus on the option, the ink outline says so. */    &:focus-visible {      --icon-color: currentColor;      background: var(--color-surface-chip);      outline: 2px solid var(--color-focus);      outline-offset: -2px;    }    &[aria-disabled="true"] {      --icon-color: currentColor;      color: var(--color-text-disabled);      cursor: not-allowed;    }    /* A checkable option keeps room for its mark at the end, so nothing moves when it is chosen. */    &[aria-checked]::after {      content: '';      flex: none;      width: 14px;      height: 14px;      margin-inline-start: auto;      opacity: 0;      background: currentColor;      mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m3.5 8.5 3 3 6-7'/%3E%3C/svg%3E") center / contain no-repeat;    }    /* Beside something at the end of the option, the mark follows it: only one of the two is pushed to the end. */    &[aria-checked]:has(.listbox-end)::after {      margin-inline-start: 0;    }    &[aria-checked="true"]::after {      opacity: 1;    }    @media (pointer: coarse) {      min-height: 2.75rem;    }    @media (forced-colors: active) {      &:is(:hover, :focus-visible, [aria-selected="true"]):not([aria-disabled="true"]) {        background: Highlight;        color: HighlightText;        forced-color-adjust: none;      }      &::after {        background: currentColor;      }    }  }  /* A title with a line under it, for an option that needs a sentence. */  .listbox-body {    display: grid;    flex: 1 1 0;    gap: .125rem;    min-width: 0;    padding-block: .125rem;  }  .listbox-description {    color: var(--color-text-muted);    font-size: .75rem;    line-height: 1.4;    text-wrap: pretty;  }  /* A shortcut or a hint at the end of an option. */  .listbox-end {    display: inline-flex;    flex: none;    align-items: center;    gap: .25rem;    margin-inline-start: auto;    padding-inline-start: 1rem;    color: var(--color-text-muted);    font-size: .71875rem;    font-variant-numeric: tabular-nums;  }  /* Secondary ink must follow the system-selected foreground, including on highlighted options. */  @media (forced-colors: active) {    :is(.listbox-description, .listbox-end) {      color: inherit;    }  }  /* A group: role="group" around a caption and its options. With every option filtered out, it steps aside. */  .listbox-group:not(:has(.listbox-option:not([hidden]))) {    display: none;  }  /* A quiet caption over a group of options. */  .listbox-label {    padding: .4375rem .5rem .3125rem;    color: var(--color-text-muted);    font-size: .71875rem;    user-select: none;    -webkit-user-select: none;  }  /* A hairline between groups, running to the list's edges. */  .listbox-separator {    height: 1px;    margin: var(--listbox-inset) calc(-1 * var(--listbox-inset));    border: 0;    background: var(--color-line-hair);  }  /* What the list says when nothing matches. It shows by itself once every option is hidden. */  .listbox-empty {    padding: 1.5rem .75rem;    color: var(--color-text-muted);    text-align: center;    text-wrap: balance;  }  .listbox:has(.listbox-option:not([hidden])) .listbox-empty {    display: none;  }  /* A caption or a rule with no visible option left under its list has nothing to introduce. */  .listbox:not(:has(.listbox-option:not([hidden]))) :is(.listbox-label, .listbox-separator) {    display: none;  }}