Skip to content
UniAria
Color scheme
Contrast
Color scheme
Contrast

Listbox

A listbox shows a list of options and lets the user select one, or several in multiple mode. There is no native element underneath: <select size> can’t hold styled options, groups with headings or descriptions. The list gets role="listbox" and each option role="option"; UniAria moves focus between the options and handles the arrows, Home and End, the page keys, typeahead, and ranges in multiple mode.

Options are data: each item has a key, and you render one element per item. Select and Combobox use the same Listbox for their popup, so everything on this page about items, keys and option markup applies to them too. Collections covers keys, selection, typeahead and virtualization across all three.

It follows the WAI-ARIA Listbox pattern.

Listboxes
View source
<!doctype html><meta charset="utf-8"><style>/* The page's layout. The listboxes' look is @uniaria/styles' default. */
:root {
  font:
    15px/1.5 system-ui,
    sans-serif;
}
body {
  display: grid;
  gap: 1rem;
  margin: 1.5rem;
  background: var(--uni-surface-color);
  color: var(--uni-text-color);
}
.row {
  display: flex;
  flex-wrap: wrap;
  gap: 2rem;
  align-items: start;
}
uni-listbox {
  inline-size: 13rem;
}
[data-uni='listbox'] [data-element='List'] {
  max-block-size: 15rem;
}
</style><div class="row">
  <uni-listbox id="fruit" disabled-keys='["cherry"]'>
    <div data-uni="listbox">
      <span data-element="Label">Fruit</span>
      <div data-element="List">
        <div data-element="Option" data-key="apple">Apple</div>
        <div data-element="Option" data-key="banana">Banana</div>
        <div data-element="Option" data-key="cherry">Cherry</div>
        <div data-element="Group" data-key="citrus">
          <span data-element="GroupLabel" data-key="citrus">Citrus</span>
          <div data-element="Option" data-key="lemon">Lemon</div>
          <div data-element="Option" data-key="lime">Lime</div>
          <div data-element="Option" data-key="orange">Orange</div>
        </div>
        <div data-element="Option" data-key="kiwi">Kiwi</div>
        <div data-element="Option" data-key="mango">Mango</div>
      </div>
    </div>
  </uni-listbox>

  <uni-listbox id="toppings" selection-mode="multiple">
    <div data-uni="listbox">
      <span data-element="Label">Toppings</span>
      <div data-element="List">
        <div data-element="Option" data-key="cheese">Cheese</div>
        <div data-element="Option" data-key="mushrooms">Mushrooms</div>
        <div data-element="Option" data-key="olives">Olives</div>
        <div data-element="Option" data-key="onions">Onions</div>
        <div data-element="Option" data-key="peppers">Peppers</div>
        <div data-element="Option" data-key="basil">Basil</div>
      </div>
      <p data-element="Empty">No toppings</p>
      <p data-element="Description">Pick as many as you like.</p>
    </div>
  </uni-listbox>
</div>
<output id="log">Click an option, or Tab in and use the keys.</output>
<script src="/demo/listbox.js"></script><script>const log = document.getElementById('log');

// `defaultValue` is a property: markup has no attribute for it.
document.getElementById('toppings').defaultValue = ['cheese', 'basil'];

document.addEventListener('valuechange', (event) => {
  const label = event.target.querySelector('[data-element="Label"]');
  const keys = event.detail.length ? event.detail.join(', ') : 'nothing';
  log.textContent = `${label.textContent}: ${keys}`;
});
</script>

Usage

React

import { listbox } from '@uniaria/core/listbox';
import { useBehavior } from '@uniaria/react';

const fruits = [
  { key: 'apple', textValue: 'Apple' },
  { key: 'banana', textValue: 'Banana' },
  { key: 'cherry', textValue: 'Cherry', isDisabled: true },
];

export function FruitList(props: { onChange(keys: readonly string[]): void }) {
  const fruit = useBehavior(listbox, {
    items: fruits,
    defaultValue: ['banana'],
    onValueChange: props.onChange,
  });
  return (
    <div {...fruit.parts.root}>
      <span {...fruit.parts.label}>Fruit</span>
      <ul {...fruit.parts.list}>
        {fruits.map((item) => (
          <li key={item.key} {...fruit.getItemProps('option', item.key)}>
            {item.textValue}
          </li>
        ))}
      </ul>
      <p {...fruit.parts.empty}>No fruit</p>
    </div>
  );
}

getItemProps(part, key) returns the props of one item part. Moving focus or changing the selection later doesn’t render the list again: UniAria writes the change to the option elements.

Vue

<script setup lang="ts">
import { listbox } from '@uniaria/core/listbox';
import { useBehavior } from '@uniaria/vue';

const fruits = [
  { key: 'apple', textValue: 'Apple' },
  { key: 'banana', textValue: 'Banana' },
  { key: 'cherry', textValue: 'Cherry', isDisabled: true },
];

const emit = defineEmits<{ change: [keys: readonly string[]] }>();

const fruit = useBehavior(listbox, {
  items: fruits,
  defaultValue: ['banana'],
  onValueChange: (keys) => emit('change', keys),
});
</script>

<template>
  <div v-bind="fruit.parts.root">
    <span v-bind="fruit.parts.label">Fruit</span>
    <ul v-bind="fruit.parts.list">
      <li
        v-for="item in fruits"
        :key="item.key"
        v-bind="fruit.getItemProps('option', item.key)"
      >
        {{ item.textValue }}
      </li>
    </ul>
    <p v-bind="fruit.parts.empty">No fruit</p>
  </div>
</template>

getItemProps(part, key) returns the props of one item part. Moving focus or changing the selection later doesn’t render the list again: UniAria writes the change to the option elements.

HTML

<uni-listbox id="fruit" disabled-keys='["cherry"]'>
  <div data-uni="listbox">
    <span data-element="Label">Fruit</span>
    <div data-element="List">
      <div data-element="Option" data-key="apple">Apple</div>
      <div data-element="Option" data-key="banana">Banana</div>
      <div data-element="Option" data-key="cherry">Cherry</div>
    </div>
    <p data-element="Empty">No fruit</p>
  </div>
</uni-listbox>

<script type="module">
  import '@uniaria/elements/listbox';

  const fruit = document.querySelector('#fruit');
  fruit.defaultValue = ['banana'];
  fruit.addEventListener('valuechange', (event) => {
    console.log('Selected', event.detail);
  });
</script>

The element reads its items from the markup (see below). value and defaultValue are arrays, so they are properties with no attribute.

Items and keys

items is an array in display order. Every item needs a key, a string that is unique in the list; value is a list of those keys, in the order they were selected. Without accessors, an item is read as { key, textValue, isDisabled?, items? }. For other data, pass accessors instead of copying it:

const people = useBehavior(listbox, {
  items: users,
  getKey: (user: User) => user.id,
  getTextValue: (user: User) => user.name,
  isItemDisabled: (user: User) => !user.isActive,
});

getTextValue is what typeahead matches, so give every option one. disabledKeys disables options by key, besides isItemDisabled. A key in value with no item stays selected, so a value can arrive before its items.

Items from markup

Without an items property, <uni-listbox> reads its items from the markup:

  • Every option part, in document order, is an item. Its key is its data-key, and its text is its data-text-value, else its own text with whitespace collapsed.
  • Every group part is a section holding the options inside it.
  • disabled-keys (a JSON array) disables options.

The element reads them again when you add or remove options or change a data-key, so the markup can come from a server template or a framework’s loop. Setting the items property takes over from the markup; each item still needs an option element with its data-key.

Every item part carries a data-key: the option’s key on option, optionLabel, optionDescription and optionExtras, the group’s key on group and groupLabel. A part without one, or with a key used twice, is left alone.

Groups

An item with children is a section, rendered as a group whose first child is its groupLabel. Its options are the section’s children. Groups don’t nest.

React

const produce = [
  { key: 'apple', textValue: 'Apple' },
  {
    key: 'citrus',
    textValue: 'Citrus',
    items: [
      { key: 'lemon', textValue: 'Lemon' },
      { key: 'lime', textValue: 'Lime' },
    ],
  },
];

const fruit = useBehavior(listbox, { items: produce });
return (
  <div {...fruit.parts.list}>
    {produce.map((item) =>
      item.items ? (
        <div key={item.key} {...fruit.getItemProps('group', item.key)}>
          <span {...fruit.getItemProps('groupLabel', item.key)}>
            {item.textValue}
          </span>
          {item.items.map((child) => (
            <div key={child.key} {...fruit.getItemProps('option', child.key)}>
              {child.textValue}
            </div>
          ))}
        </div>
      ) : (
        <div key={item.key} {...fruit.getItemProps('option', item.key)}>
          {item.textValue}
        </div>
      ),
    )}
  </div>
);

Vue

<script setup lang="ts">
const produce = [
  { key: 'apple', textValue: 'Apple' },
  {
    key: 'citrus',
    textValue: 'Citrus',
    items: [
      { key: 'lemon', textValue: 'Lemon' },
      { key: 'lime', textValue: 'Lime' },
    ],
  },
];

const fruit = useBehavior(listbox, { items: produce });
</script>

<template>
  <div v-bind="fruit.parts.list">
    <template v-for="item in produce" :key="item.key">
      <div v-if="item.items" v-bind="fruit.getItemProps('group', item.key)">
        <span v-bind="fruit.getItemProps('groupLabel', item.key)">
          {{ item.textValue }}
        </span>
        <div
          v-for="child in item.items"
          :key="child.key"
          v-bind="fruit.getItemProps('option', child.key)"
        >
          {{ child.textValue }}
        </div>
      </div>
      <div v-else v-bind="fruit.getItemProps('option', item.key)">
        {{ item.textValue }}
      </div>
    </template>
  </div>
</template>

HTML

<div data-element="List">
  <div data-element="Option" data-key="apple">Apple</div>
  <div data-element="Group" data-key="citrus">
    <span data-element="GroupLabel" data-key="citrus">Citrus</span>
    <div data-element="Option" data-key="lemon">Lemon</div>
    <div data-element="Option" data-key="lime">Lime</div>
  </div>
</div>

Use divs for groups: a ul inside a ul needs role="presentation" on the inner list. A listbox may only contain options and groups, so a separator between groups is a border in your CSS.

Labels and descriptions in options

An option is named by its text. Render an optionLabel part inside it to name it by that part alone, and an optionDescription part to describe it, so badges and secondary text stay out of the name:

<div data-element="Option" data-key="lemon" data-text-value="Lemon">
  <span data-element="OptionLabel" data-key="lemon">Lemon</span>
  <span data-element="OptionDescription" data-key="lemon">Sour, yellow</span>
</div>

In markup, give such an option a data-text-value; its own text would include the description. Options can’t hold buttons, links or other controls: that is a grid, not a listbox.

Extras in options

An optionExtras part holds what sits at an option’s end: badges, counts, icons. While rendered, it describes the option after its description, so a screen reader reads “option, Apple, Red and sweet New, not selected”.

<div data-element="Option" data-key="apple" data-text-value="Apple">
  <span data-element="OptionLabel" data-key="apple">Apple</span>
  <span data-element="OptionDescription" data-key="apple">Red and sweet</span>
  <span data-element="OptionExtras" data-key="apple">New</span>
</div>
  • Nothing in them acts. The extras are inside the option, so nothing in them may take focus: no buttons, links or other controls.
  • Give the option an optionLabel, or the extras’ text is part of its name as well as its description.
  • Decorative extras, such as an icon or a count that says nothing new, take aria-hidden="true". UniAria then leaves them out of the description, since a reference reads hidden content all the same.

Option tooltips

Long option text is often cut off with an ellipsis, as in the default styles. One Tooltip can show the whole text of whichever option is cut off: create it with shouldOpenOnlyWhenTruncated and relation: 'none', and pass its instance as the list’s optionTooltip. On the element, name it by id with the option-tooltip attribute.

React

import { listbox } from '@uniaria/core/listbox';
import { tooltip } from '@uniaria/core/tooltip';
import { useBehavior } from '@uniaria/react';

export function FruitList() {
  const full = useBehavior(tooltip, {
    shouldOpenOnlyWhenTruncated: true,
    relation: 'none',
    positionArea: 'right',
  });
  const fruit = useBehavior(listbox, {
    items: fruits,
    optionTooltip: full.instance,
  });
  return (
    <>
      <div {...fruit.parts.root}>
        <span {...fruit.parts.label}>Fruit</span>
        <ul {...fruit.parts.list}>
          {fruits.map((item) => (
            <li key={item.key} {...fruit.getItemProps('option', item.key)}>
              {item.textValue}
            </li>
          ))}
        </ul>
      </div>
      <div {...full.parts.root}>
        <span {...full.parts.text} />
      </div>
    </>
  );
}

Vue

<script setup lang="ts">
import { listbox } from '@uniaria/core/listbox';
import { tooltip } from '@uniaria/core/tooltip';
import { useBehavior } from '@uniaria/vue';

const full = useBehavior(tooltip, {
  shouldOpenOnlyWhenTruncated: true,
  relation: 'none',
  positionArea: 'right',
});
const fruit = useBehavior(listbox, {
  items: fruits,
  optionTooltip: full.instance,
});
</script>

<template>
  <div v-bind="fruit.parts.root">
    <span v-bind="fruit.parts.label">Fruit</span>
    <ul v-bind="fruit.parts.list">
      <li
        v-for="item in fruits"
        :key="item.key"
        v-bind="fruit.getItemProps('option', item.key)"
      >
        {{ item.textValue }}
      </li>
    </ul>
  </div>
  <div v-bind="full.parts.root">
    <span v-bind="full.parts.text" />
  </div>
</template>

HTML

<uni-listbox option-tooltip="fruit-full">
  <div data-uni="listbox">
    <span data-element="Label">Fruit</span>
    <div data-element="List">
      <div data-element="Option" data-key="apple">Apple</div>
      <div data-element="Option" data-key="banana">
        Banana, the long yellow one from the market
      </div>
    </div>
  </div>
</uni-listbox>

<uni-tooltip
  id="fruit-full"
  open-only-when-truncated
  relation="none"
  position-area="right"
>
  <div data-uni="tooltip"><span data-element="Text"></span></div>
</uni-tooltip>

<script type="module">
  import '@uniaria/elements/listbox';
  import '@uniaria/elements/tooltip';
</script>

Only the attribute links it: a <uni-listbox> inside a <uni-tooltip> doesn’t take that tooltip for its options.

Without the default styles, cut the options yourself, or their optionLabel and optionDescription when they have them:

[data-element='Option'] {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}
  • Hover and focus. It opens on an option under the pointer after its delay, and at once on an option with keyboard focus, as a tooltip does on its trigger. It shows the option’s optionLabel and optionDescription, one per line, else the option’s whole text.
  • Moving on. While it is open, moving to another option moves it there at once if that option is cut off too, and closes it otherwise.
  • Presses and typing. A press on an option closes it until the pointer leaves that option. Typeahead closes it until an arrow key, Home, End or a page key moves focus.
  • Where. Render it beside the list, never inside an option, whose name comes from its content. It is placed against the option, and positionArea: 'right' puts it beside the list rather than over the next options.
  • No relation. No option references it. Give it relation: 'none'; development builds warn otherwise.
  • Virtualized, an option that leaves the rendered set closes it.

Multiple selection

With selectionMode: 'multiple' (selection-mode="multiple"), a press, Enter or Space toggles an option, and no key needs a modifier. Shift with a press, Space, an arrow or a page key selects a range from the option selected last, and Ctrl+A (Cmd+A on Apple platforms) selects every enabled option. value keeps the order of selection; a range adds its options in list order.

Controlled value

React

const [value, setValue] = useState<readonly string[]>([]);
const fruit = useBehavior(listbox, {
  items: fruits,
  selectionMode: 'multiple',
  value,
  onValueChange: setValue,
});

Vue

<script setup lang="ts">
const value = ref<readonly string[]>([]);
const fruit = useBehavior(listbox, () => ({
  items: fruits,
  selectionMode: 'multiple',
  value: value.value,
  onValueChange: (keys) => (value.value = keys),
}));
</script>

HTML

const fruit = document.querySelector('#fruit');
fruit.value = [];
fruit.addEventListener('valuechange', (event) => {
  // Accept the change, or set something else.
  fruit.value = event.detail;
});

Setting the value property makes the element controlled: it reports valuechange and waits for you to set the new value. Set defaultValue instead to let it keep its own. Reading value gives the current value either way.

Virtualization

A list of thousands of options can render only the ones in and near the view. Pass virtualizer from @uniaria/core/virtual, wrap the list in a scroller part that has a height and overflow: auto, and render the keys in the renderedKeys state instead of every item. UniAria positions the rows, keeps the focused option rendered, and scrolls to options outside the view when the keyboard or typeahead moves there. Lists that don’t virtualize never load @uniaria/core/virtual.

React

import { listbox } from '@uniaria/core/listbox';
import { virtualizer } from '@uniaria/core/virtual';
import type { BehaviorHandle } from '@uniaria/react';
import { useBehavior, useBehaviorState } from '@uniaria/react';

export function UserList({ users }: { users: readonly User[] }) {
  const list = useBehavior(listbox, {
    items: users,
    getKey: (user: User) => user.id,
    getTextValue: (user: User) => user.name,
    virtualizer,
    itemSize: 32,
  });
  return (
    <div {...list.parts.root}>
      <span {...list.parts.label}>Users</span>
      <div {...list.parts.scroller} className="scroller">
        <div {...list.parts.list}>
          <UserRows list={list} users={users} />
        </div>
      </div>
    </div>
  );
}

// The rows render again when the rendered set changes; the list doesn't.
function UserRows(props: {
  list: BehaviorHandle<typeof listbox>;
  users: readonly User[];
}) {
  const keys = useBehaviorState(props.list, (state) => state.renderedKeys);
  const byId = new Map(props.users.map((user) => [user.id, user]));
  return keys.map((key) => (
    <div key={key} {...props.list.getItemProps('option', key)}>
      {byId.get(key)!.name}
    </div>
  ));
}

Vue

<!-- UserList.vue -->
<script setup lang="ts">
import { listbox } from '@uniaria/core/listbox';
import { virtualizer } from '@uniaria/core/virtual';
import { useBehavior } from '@uniaria/vue';

import UserRows from './UserRows.vue';

const props = defineProps<{ users: readonly User[] }>();

const list = useBehavior(listbox, () => ({
  items: props.users,
  getKey: (user: User) => user.id,
  getTextValue: (user: User) => user.name,
  virtualizer,
  itemSize: 32,
}));
</script>

<template>
  <div v-bind="list.parts.root">
    <span v-bind="list.parts.label">Users</span>
    <div v-bind="list.parts.scroller" class="scroller">
      <div v-bind="list.parts.list">
        <UserRows :list="list" :users="users" />
      </div>
    </div>
  </div>
</template>
<!-- UserRows.vue -->
<script setup lang="ts">
import type { listbox } from '@uniaria/core/listbox';
import type { BehaviorHandle } from '@uniaria/vue';
import { useBehaviorState } from '@uniaria/vue';
import { computed } from 'vue';

const props = defineProps<{
  list: BehaviorHandle<typeof listbox>;
  users: readonly User[];
}>();

// The rows render again when the rendered set changes; the list doesn't.
const keys = useBehaviorState(props.list, (state) => state.renderedKeys);
const byId = computed(
  () => new Map(props.users.map((user) => [user.id, user])),
);
</script>

<template>
  <div v-for="key in keys" :key="key" v-bind="list.getItemProps('option', key)">
    {{ byId.get(key)!.name }}
  </div>
</template>

HTML

<uni-listbox id="users" item-size="32">
  <span data-element="Label">Users</span>
  <div data-element="Scroller" class="scroller">
    <div data-element="List"></div>
  </div>
</uni-listbox>

<script type="module">
  import '@uniaria/elements/listbox';
  import { virtualizer } from '@uniaria/core/virtual';
  import { users } from './users.js'; // [{ id, name }, …]

  const byId = new Map(users.map((user) => [user.id, user]));
  const element = document.querySelector('#users');
  const list = element.querySelector('[data-element="List"]');
  const rows = new Map();

  // Rows that stay aren't moved, so the focused one keeps focus.
  function render(keys) {
    const wanted = new Set(keys);
    for (const [key, row] of rows) {
      if (wanted.has(key)) continue;
      row.remove();
      rows.delete(key);
    }
    let next = list.firstElementChild;
    for (const key of keys) {
      let row = rows.get(key);
      if (!row) {
        row = document.createElement('div');
        row.dataset.element = 'Option';
        row.dataset.key = key;
        row.textContent = byId.get(key).name;
        rows.set(key, row);
      }
      if (row === next) next = next.nextElementSibling;
      else list.insertBefore(row, next);
    }
  }

  element.addEventListener('renderedkeyschange', (event) => {
    render(event.detail.keys);
  });
  element.virtualizer = virtualizer;
  element.items = users.map((user) => ({ key: user.id, textValue: user.name }));
</script>

The markup can’t hold every item, so a virtualized element needs the items property. Render the rows synchronously in the renderedkeyschange listener, so an option the keyboard moves to exists before the next paint.

  • Sizes. itemSize is every row’s exact size in pixels, or a function of the key. Without it, or with shouldMeasureItems, rows are measured as they render. Gaps between rows belong in the size: rows are absolutely positioned, so margins and the list’s gap don’t apply.
  • Width. Positioned rows have no width of their own: give options inset-inline-end: 0 or inline-size: 100%, and the list a width from its container.
  • Groups. renderedSections lists the groups to render. Render each with its groupLabel and the options of renderedKeys that belong to it. A section’s label row is itemSize long too.
  • Before layout. Until the scroller has a size (on the server, or in a closed popover), the first initialItemCount options render.

Anatomy

Part Element Notes
root any, usually div Optional wrapper around the label, list and messages.
label span by default; any element through labelElementType Optional. Names the list through aria-labelledby. Not a <label>: a listbox isn’t labelable.
scroller any, usually div Only when virtualized: the box that scrolls, around the list. Give it a height or a max height and overflow: auto.
list any, usually ul or div Holds the options and groups. Always rendered, even when empty.
description any Optional. Describes the list.
error any Optional. Shown and announced while isInvalid; hidden otherwise.
empty any, outside the list Optional. Your “No items” text. UniAria hides it while there are options, and while there are none it describes the list. A listbox can’t contain text.

A part keeps an id you give it, and everything that refers to the part follows it; UniAria writes an id only on a part that has none (see Ids). Option ids are always made from the list’s id and the option’s key.

Item parts repeat once per item and are keyed by the item’s key: in React and Vue you pass it to getItemProps(part, key), on the element each part carries it as data-key.

Part Element Notes
option any, usually li or div One per item that isn’t a section. Named by its text, or by its optionLabel.
optionLabel any, inside the option Optional. Names the option instead of its whole text.
optionDescription any, inside the option Optional. Describes the option.
optionExtras any, inside the option; usually span Optional. Badges, counts and icons, with nothing that takes focus. While rendered and not aria-hidden="true", it describes the option after its optionDescription.
group any, usually div One per section. Holds its label and its options.
groupLabel any, first in the group Names the group. Gets role="presentation", since a listbox can’t contain headings.

You own the content, your classes and other attributes. UniAria writes roles, ids, tabindex, the ARIA states and the state attributes, and option ids are made from the list’s id and the key (<list id>-option-<key>).

Props

Prop Attribute Type Default Description
items — (property, or markup) readonly Item[] [] The options and sections, in order. The element reads them from its markup while the property is unset.
getKey — (function) (item) => string item.key The item’s key, unique in the list.
getTextValue — (function) (item) => string item.textValue The text typeahead matches.
isItemDisabled — (function) (item) => boolean item.isDisabled === true Disables an option: it can’t be focused or selected, and the keys skip it.
getChildren — (function) (item) => readonly Item[] | undefined item.items A section’s options. An item with children is a section.
disabledKeys disabled-keys (JSON) readonly string[] [] More disabled options, by key.
value / defaultValue — (properties) readonly string[] [] The selected keys, in the order they were selected; at most one in single mode. Changes are reported by valuechange.
selectionMode selection-mode 'single' | 'multiple' 'single' Multiple mode toggles options and adds ranges.
isDisabled disabled boolean false Disables the list and every option. Nothing takes focus.
isReadOnly readonly boolean false Focus moves, but nothing is selected and no action is reported.
isRequired required boolean false aria-required on the list. A listbox has no native form value; read value in your form.
isInvalid invalid boolean false aria-invalid on the list, and shows the error part.
orientation orientation 'vertical' | 'horizontal' 'vertical' Which arrow keys move focus.
shouldFocusWrap focus-wrap boolean false The arrows wrap from the last option to the first and back. Home, End and the page keys never wrap.
shouldFocusOnHover focus-on-hover boolean false A hovering mouse focuses the option, unless the keyboard is in use. Select turns it on.
shouldUseVirtualFocus — (property) boolean false For a component that keeps focus in its own input, as Combobox does: options take no focus, and data-focused follows a highlight.
listId — string generated The list’s id while it has none of its own. Option ids are made from the list’s id.
labelElementType — (the label’s tag) string 'span' The tag you render as the label.
labelledBy labelled-by string — Ids of more elements that name the list.
describedBy described-by string — Ids of more elements that describe the list.
virtualizer — (property) Virtualizer — virtualizer from @uniaria/core/virtual. Set, the list virtualizes.
itemSize item-size (a number) number | ((key: string) => number) — A row’s size along the list in pixels: an option’s, or a section’s label row. Unset, rows are measured, estimated at 32.
shouldMeasureItems measure-items boolean false Treats itemSize as an estimate and measures each rendered row.
overscan overscan number 10 Options rendered beyond each edge of the view.
initialItemCount initial-item-count number 20 Options rendered before the scroller has a size.
optionTooltip option-tooltip (the tooltip’s id) a Tooltip’s instance — One Tooltip for every option (see Option tooltips). On the element, only the attribute links it.

Development builds warn when the list has no name: no label part, no labelledBy and no aria-label on the list.

Events

Event Callback prop Detail When
valuechange onValueChange readonly string[] The user changed the selection: a press, Enter, Space, a range or Ctrl+A. Never for a value you set.
action onAction string The user chose an option with a press, Enter or Space, after any valuechange, also when the selection didn’t change. Not while read-only.
focuschange onFocusChange boolean Focus entered or left the list, for validating on blur.
renderedkeyschange onRenderedKeysChange { keys: readonly string[]; sections: readonly string[] } The set of options to render changed. Only virtualized lists need it.

The element dispatches each event on itself, bubbling, with the detail in event.detail.

State

Field Type Meaning
value readonly string[] The selected keys.
selectionMode 'single' | 'multiple' The selectionMode prop.
focusedKey string | null The option with focus, or the one the list focuses next on Tab.
isEmpty boolean The list has no options. Groups don’t count.
isDisabled boolean The isDisabled prop.
isReadOnly boolean The isReadOnly prop.
isRequired boolean The isRequired prop.
isInvalid boolean The isInvalid prop.
isErrorShown boolean The error part is shown: invalid and not disabled.
isFocusWithin boolean Focus is on the list or an option.
isFocusVisible boolean That focus came from the keyboard.
isVirtualized boolean A virtualizer is set.
renderedKeys readonly string[] The options to render, in list order: every option unless virtualized.
renderedSections readonly string[] The sections whose groups to render: those holding a rendered option.

To show something from state in React or Vue, select it. Per option, select what that option needs, and the component renders again only when that value changes:

const isBananaSelected = useBehaviorState(fruit, (state) =>
  state.value.includes('banana'),
);

On the custom element, read element.instance.getState(). See State.

Styling

Attribute On When
data-uni="listbox" root Always.
data-element every other part Always.
data-selected option The option is selected.
data-focused option The option has focus.
data-focus-visible option, named parts The option, or the list, has focus from the keyboard.
data-focus-within named parts Focus is on the list or an option.
data-hovered option A mouse or pen is over an enabled option.
data-pressed option The option is being pressed.
data-disabled named and item parts On an option: the option is disabled. Everywhere: the list is.
data-readonly named and item parts The list is read-only.
data-required, data-invalid named parts The list is required, or invalid.
data-multiple named parts Multiple mode, for checkbox-like options.
data-orientation named parts vertical or horizontal. Always present.
data-empty named parts The list has no options.
data-virtualized root, list, scroller A virtualizer is set.

“Named parts” are root, label, list, description, error and empty; the scroller gets only data-virtualized. State attributes are present or absent, never "false". Listbox writes no CSS custom properties.

UniAria sets hidden on the empty part while there are options. While virtualized it also writes inline styles: position: relative and the total length on the list, position: absolute and a translate on each option and group, and overflow-anchor: none on the scroller. translate leaves transform free for your own effects.

Default styles

The optional @uniaria/styles package styles every part, as in the demo above, whose CSS tab holds only the page’s layout and the list’s height. The styles key on the root, so wrap the parts in one (data-uni="listbox" inside <uni-listbox>). The list is a box like a field’s, and options are 32-pixel rows of soft text: lighter under the pointer and with focus, in the accent theme with a check at the end once selected, and with a ring inside them on keyboard focus. An option’s text takes one line and ends in an ellipsis, as do its optionLabel and its optionDescription below it, so pair the list with an option tooltip. Its optionExtras sit at the end, before the check. The list shows its own ring only while it has focus itself, empty or with no option to focus. An empty list shows the empty part inside its box, in an option’s place. Virtualized, the scroller is the box and options take the list’s width; the height stays yours.

Keyboard

Keys act on the focused option. In a horizontal list, ArrowLeft and ArrowRight take the place of ArrowUp and ArrowDown, swapped in right-to-left text.

Key Behavior
Tab, Shift+Tab The list is one tab stop. It enters at the option focused last; before that, at the first selected option, else the first enabled one.
ArrowDown, ArrowUp Focuses the next or previous enabled option, across groups. Stops at the ends unless shouldFocusWrap.
Home, End Focuses the first or last enabled option.
PageDown, PageUp Focuses the option one visible height further down or up; without scrolling, the last or first option.
Space, Enter Selects the focused option (single mode) or toggles it (multiple mode). Space joins a typeahead query typed in the last second instead.
Printable characters Typeahead: focuses the next enabled option whose text starts with what was typed in the last second. Selects nothing.
Shift+ArrowDown, Shift+ArrowUp Multiple mode: moves focus and selects from the option selected last to the new one.
Shift+Space, Shift+PageDown, Shift+PageUp Multiple mode: selects a range from the option selected last, as above.
Ctrl+Shift+Home, Ctrl+Shift+End Multiple mode: selects a range to the first or last option and focuses it. Cmd instead of Ctrl on Apple platforms.
Ctrl+A Multiple mode: selects every enabled option. Cmd+A on Apple platforms. Never deselects.
Escape Not handled, so it reaches a popover or dialog around the list.

Moving focus never changes the selection. Keys the list doesn’t use, and keys with Alt, Ctrl or Meta that it doesn’t name, stay with the browser.

Accessibility

  • Focus. DOM focus moves to the option itself (a roving tabindex), so :focus-visible works and mobile screen readers follow it. After a key the option scrolls into view; after a pointer press nothing scrolls. When the focused option is removed or disabled, focus moves to the next enabled option, else the previous one, else the list.
  • A name. Render a label part, pass labelledBy, or put an aria-label on the list. The label names the list with aria-labelledby; clicking it doesn’t focus the list.
  • Spoken output. The list reads as “listbox, Fruit, orientated vertically”, with “multi-selectable” in multiple mode. An option reads as “option, Apple, not selected”: UniAria writes aria-selected="false" on every unselected option, as the APG asks. With a description and extras, it reads “option, Apple, Red and sweet New, not selected”. A group reads as “group, Citrus”.
  • Option tooltips add nothing. With relation: 'none' the tooltip is hidden from assistive technology, and screen readers read the option’s whole text from the option already, cut off or not.
  • Empty lists. While the list has no options it takes the tab stop and the empty part describes it: “listbox, Fruit, No fruit”.
  • Selection follows the user, not focus. Arrowing through a list never selects, so a selection with side effects only happens when the user asks.
  • Multiple mode uses aria-selected, as React Aria and Zag do. The APG suggests aria-checked, but browsers still expose a selected state with it, so options would read “not selected, checked”.
  • Virtualized lists write aria-posinset and aria-setsize on each option, so screen readers count the whole list rather than the rendered rows. Options outside the rendered set can’t be reached in browse mode or found with the browser’s find in page.

Compared with other libraries

  • The value is an array of keys in both modes, as in Zag. React Aria uses a Set or 'all'; UniAria has no 'all', and Ctrl+A selects every enabled key.
  • Choosing the selected option again keeps it selected in single mode, as <select> does, and still reports action. React Aria deselects it.
  • Escape is left alone. React Aria clears the selection on the first Escape; here it closes the popover or dialog around the list at once.
  • A press selects on release, for every pointer, as Cube UI Kit does. React Aria selects a mouse press on mousedown.
  • The empty state is an empty part that describes the list, not an option standing in for one.
  • Typeahead keeps its query for a second, even after a key that matches nothing, as <select> does in Chromium and Firefox. React Aria starts over after a miss.
  • Shift+Arrow selects a range, as the APG’s own example, React Aria and <select multiple> do. The APG’s text describes toggling the next option.