Skip to content
UniAria
Color scheme
Contrast
Color scheme
Contrast

Select

A select lets the user pick one option, or several in multiple mode, from a list that opens under a trigger button. Use it instead of a native <select> when the options need markup, groups with headings, descriptions or a styled popup. Select is a Listbox inside a Popover, plus the trigger: the options, keys, typeahead and selection are Listbox’s, and the popover holds the list in the top layer, places it and closes it. A hidden native <select> carries the value into forms and takes the browser’s autofill.

The trigger follows the WAI-ARIA select-only combobox and the popup the Listbox pattern, except that focus moves into the list while it is open (see Accessibility).

Select in a form
View source
<!doctype html><meta charset="utf-8"><style>/* The page's layout. The select's 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: 0.75rem;
  align-items: end;
}
.row > uni-select {
  inline-size: 13rem;
}
output {
  padding-block: 0.5rem;
}
</style><form id="order" class="row">
  <uni-select placeholder="Pick a fruit" required>
    <div data-uni="select">
      <span data-element="Label">Fruit</span>
      <button data-element="Trigger">
        <span data-element="Value"></span>
      </button>
      <div data-element="Popover">
        <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="blueberry">Blueberry</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 data-element="Option" data-key="peach">Peach</div>
        </div>
      </div>
      <select data-element="HiddenSelect" name="fruit"></select>
    </div>
  </uni-select>
  <uni-button type="submit">
    <button data-uni="button" data-variant="primary">Order</button>
  </uni-button>
  <output id="log">Pick a fruit, then order.</output>
</form>
<script src="/demo/select.js"></script><script>const log = document.getElementById('log');
const form = document.getElementById('order');

document.addEventListener('valuechange', (event) => {
  log.textContent = `valuechange: ${event.detail.join(', ')}`;
});

// The hidden <select> submits the value, and `required` blocks an empty one.
form.addEventListener('submit', (event) => {
  event.preventDefault();
  log.textContent = `Submitted fruit=${new FormData(form).get('fruit')}`;
});
</script>

Usage

React

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

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

export function FruitSelect() {
  const fruit = useBehavior(select, {
    items: fruits,
    placeholder: 'Pick a fruit',
    isRequired: true,
  });
  return (
    <div {...fruit.parts.root}>
      <span {...fruit.parts.label}>Fruit</span>
      <button {...fruit.parts.trigger}>
        <span {...fruit.parts.value} />
        <ChevronIcon aria-hidden />
      </button>
      <div {...fruit.parts.popover}>
        <div {...fruit.parts.list}>
          {fruits.map((item) => (
            <div key={item.key} {...fruit.getItemProps('option', item.key)}>
              {item.textValue}
            </div>
          ))}
        </div>
        <p {...fruit.parts.empty}>No fruit</p>
      </div>
      <select {...fruit.parts.hiddenSelect} name="fruit" />
    </div>
  );
}

Vue

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

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

const fruit = useBehavior(select, {
  items: fruits,
  placeholder: 'Pick a fruit',
  isRequired: true,
});
</script>

<template>
  <div v-bind="fruit.parts.root">
    <span v-bind="fruit.parts.label">Fruit</span>
    <button v-bind="fruit.parts.trigger">
      <span v-bind="fruit.parts.value" />
      <ChevronIcon aria-hidden="true" />
    </button>
    <div v-bind="fruit.parts.popover">
      <div v-bind="fruit.parts.list">
        <div
          v-for="item in fruits"
          :key="item.key"
          v-bind="fruit.getItemProps('option', item.key)"
        >
          {{ item.textValue }}
        </div>
      </div>
      <p v-bind="fruit.parts.empty">No fruit</p>
    </div>
    <select v-bind="fruit.parts.hiddenSelect" name="fruit" />
  </div>
</template>

HTML

<uni-select placeholder="Pick a fruit" required>
  <div data-uni="select">
    <span data-element="Label">Fruit</span>
    <button data-element="Trigger">
      <span data-element="Value"></span>
      <svg aria-hidden="true">…</svg>
    </button>
    <div data-element="Popover">
      <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>
    <select data-element="HiddenSelect" name="fruit"></select>
  </div>
</uni-select>

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

The options are Listbox’s item parts, so keys, groups, option labels, descriptions and extras, and items read from the markup work as on Listbox. Render the options while the list is closed, inside the hidden popover: the element reads its items from them, and the trigger’s aria-controls points at a list that holds the selected option.

Render the root part around everything, the popover included. Select tracks focus within it: without a root, focuschange never fires and the data-focus-within and data-focus-visible attributes never appear.

The displayed value

UniAria writes the value part’s text: the selected option’s getTextValue, the selected options’ texts joined with , in multiple mode, or the placeholder while nothing is selected, when it also sets data-placeholder. Leave the part empty. In React and Vue the text appears once the component mounts.

For an icon or other markup in the trigger, leave the value part out and render the value yourself:

React

const selected = useBehaviorState(fruit, (state) => state.value[0]);
return (
  <button {...fruit.parts.trigger}>
    {selected ? <FruitBadge fruit={selected} /> : 'Pick a fruit'}
  </button>
);

Vue

<script setup lang="ts">
const fruit = useBehavior(select, { items: fruits });
const selected = useBehaviorState(fruit, (state) => state.value[0]);
</script>

<template>
  <button v-bind="fruit.parts.trigger">
    <FruitBadge v-if="selected" :fruit="selected" />
    <template v-else>Pick a fruit</template>
  </button>
</template>

HTML

const fruit = document.querySelector('uni-select');
const badge = fruit.querySelector('.badge');
fruit.addEventListener('valuechange', () => {
  const { valueText, isPlaceholder } = fruit.instance.getState();
  badge.textContent = isPlaceholder ? 'Pick a fruit' : valueText;
});

Trigger extras

For buttons, badges and counts that go with the trigger, such as a quiet Button that clears the value, render a triggerFrame inside the root, around the trigger, and a triggerExtras part in it, beside the trigger. The extras are never inside the trigger, so each is a control of its own and a Tab stop after the trigger. The frame and the extras carry the trigger’s state attributes, data-open and data-invalid included, and the pointer anywhere over the frame hovers the trigger.

React

import { useState } from 'react';
import { button } from '@uniaria/core/button';
import { select } from '@uniaria/core/select';
import { useBehavior } from '@uniaria/react';

export function FruitSelect() {
  const [value, setValue] = useState<readonly string[]>([]);
  const fruit = useBehavior(select, {
    items: fruits,
    placeholder: 'Pick a fruit',
    value,
    onValueChange: setValue,
  });
  const clear = useBehavior(button, { onPress: () => setValue([]) });
  return (
    <div {...fruit.parts.root}>
      <span {...fruit.parts.label}>Fruit</span>
      <div {...fruit.parts.triggerFrame}>
        <button {...fruit.parts.trigger}>
          <span {...fruit.parts.value} />
        </button>
        <span {...fruit.parts.triggerExtras}>
          <button
            {...clear.parts.root}
            data-variant="quiet"
            aria-label="Clear Fruit"
          >
            <ClearIcon aria-hidden />
          </button>
        </span>
      </div>
      <div {...fruit.parts.popover}>…</div>
    </div>
  );
}

Vue

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

const value = ref<readonly string[]>([]);
const fruit = useBehavior(select, () => ({
  items: fruits,
  placeholder: 'Pick a fruit',
  value: value.value,
  onValueChange: (keys) => (value.value = keys),
}));
const clear = useBehavior(button, { onPress: () => (value.value = []) });
</script>

<template>
  <div v-bind="fruit.parts.root">
    <span v-bind="fruit.parts.label">Fruit</span>
    <div v-bind="fruit.parts.triggerFrame">
      <button v-bind="fruit.parts.trigger">
        <span v-bind="fruit.parts.value" />
      </button>
      <span v-bind="fruit.parts.triggerExtras">
        <button
          v-bind="clear.parts.root"
          data-variant="quiet"
          aria-label="Clear Fruit"
        >
          <ClearIcon aria-hidden="true" />
        </button>
      </span>
    </div>
    <div v-bind="fruit.parts.popover">…</div>
  </div>
</template>

HTML

<uni-select id="fruit" placeholder="Pick a fruit">
  <div data-uni="select">
    <span data-element="Label">Fruit</span>
    <div data-element="TriggerFrame">
      <button data-element="Trigger"><span data-element="Value"></span></button>
      <span data-element="TriggerExtras">
        <uni-button id="clear-fruit">
          <button
            data-uni="button"
            data-variant="quiet"
            aria-label="Clear Fruit"
          >
            <svg aria-hidden="true">…</svg>
          </button>
        </uni-button>
      </span>
    </div>
    <div data-element="Popover">…</div>
  </div>
</uni-select>

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

  const fruit = document.querySelector('#fruit');
  fruit.addEventListener('valuechange', (event) => {
    fruit.value = event.detail;
  });
  document.querySelector('#clear-fruit').addEventListener('press', () => {
    fruit.value = [];
  });
</script>

Without an anchor part, the popover is placed against the frame, so min-width: var(--anchor-width) makes it as wide as the trigger with its extras. A press on an extra while the list is open closes the list without selecting, and the extra acts, in one press; focus stays on the extra. Name each extra in its context: “Clear Fruit”, not “Clear”.

Tooltips

Select takes two Tooltip links:

  • tooltip is the trigger’s, as on a Button. It describes the trigger after its description and error, unless it has relation: 'none'.
  • optionTooltip is one tooltip for every option. It opens on the option under the pointer or with keyboard focus, moves with them, and closes on a press or while you type. It never describes an option: give it relation: 'none', or development builds warn. Render it inside the popover, beside the list, never inside an option, whose name comes from its content.

With shouldOpenOnlyWhenTruncated, a tooltip opens only while the text is cut off, and writes the whole text into its text part (see Overflow tooltips). The trigger’s measures the value part, so a cut-off value gets an overflow tooltip; the option tooltip measures each option’s optionLabel and optionDescription, else the option.

React

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

export function FruitSelect() {
  const valueTip = useBehavior(tooltip, {
    shouldOpenOnlyWhenTruncated: true,
    relation: 'none',
  });
  const optionTip = useBehavior(tooltip, {
    shouldOpenOnlyWhenTruncated: true,
    relation: 'none',
    positionArea: 'right',
  });
  const fruit = useBehavior(select, {
    items: fruits,
    tooltip: valueTip.instance,
    optionTooltip: optionTip.instance,
  });
  return (
    <div {...fruit.parts.root}>
      <span {...fruit.parts.label}>Fruit</span>
      <button {...fruit.parts.trigger}>
        <span {...fruit.parts.value} />
      </button>
      <div {...valueTip.parts.root}>
        <span {...valueTip.parts.text} />
      </div>
      <div {...fruit.parts.popover}>
        <div {...fruit.parts.list}>…</div>
        <div {...optionTip.parts.root}>
          <span {...optionTip.parts.text} />
        </div>
      </div>
    </div>
  );
}

Vue

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

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

<template>
  <div v-bind="fruit.parts.root">
    <span v-bind="fruit.parts.label">Fruit</span>
    <button v-bind="fruit.parts.trigger">
      <span v-bind="fruit.parts.value" />
    </button>
    <div v-bind="valueTip.parts.root">
      <span v-bind="valueTip.parts.text" />
    </div>
    <div v-bind="fruit.parts.popover">
      <div v-bind="fruit.parts.list">…</div>
      <div v-bind="optionTip.parts.root">
        <span v-bind="optionTip.parts.text" />
      </div>
    </div>
  </div>
</template>

HTML

<uni-tooltip open-only-when-truncated relation="none">
  <uni-select option-tooltip="fruit-option-tip">
    <div data-uni="select">
      <span data-element="Label">Fruit</span>
      <button data-element="Trigger"><span data-element="Value"></span></button>
      <div data-element="Popover">
        <div data-element="List">…</div>
        <uni-tooltip
          id="fruit-option-tip"
          open-only-when-truncated
          relation="none"
          position-area="right"
        >
          <div data-uni="tooltip"><span data-element="Text"></span></div>
        </uni-tooltip>
      </div>
    </div>
  </uni-select>
  <div data-uni="tooltip"><span data-element="Text"></span></div>
</uni-tooltip>

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

The <uni-tooltip> around the select is the trigger’s tooltip. The option tooltip is linked only by its id, with option-tooltip, never through a <uni-tooltip> around the select. Import the select’s entry before the tooltip’s.

Forms

Put an empty <select> in the hiddenSelect part, with your name, and form or autocomplete when you need them. UniAria fills it with an empty first <option> and one <option> per item, keeps its selection, multiple, required and disabled in step, and hides it visually, but not with display: none, since Safari won’t autofill that. Then:

  • The form submits the selected key under the <select>’s name, once per key in multiple mode.
  • With isRequired, the browser blocks submitting nothing. When the select is the form’s first invalid control, focus moves to its trigger and shows. form.checkValidity() moves no focus.
  • Resetting the form goes back to defaultValue, even one set after the select rendered, and valueChange reports it. A controlled select gets a valueChange asking for defaultValue, or without one the value it mounted with.
  • Browser autofill sets the value.

Multiple selection

With selectionMode: 'multiple' (selection-mode="multiple"), a press, Enter or Space toggles an option and the list stays open. Listbox’s ranges and Ctrl+A (Cmd+A on Apple platforms) work in the list, and Escape, Tab or a press outside closes it. The value text joins the selected options with , . Typing and ArrowLeft and ArrowRight on the closed trigger change nothing in multiple mode.

Controlled value and open state

React

const [value, setValue] = useState<readonly string[]>([]);
const [isOpen, setOpen] = useState(false);
const fruit = useBehavior(select, {
  items: fruits,
  value,
  onValueChange: setValue,
  isOpen,
  onOpenChange: setOpen,
});

Vue

<script setup lang="ts">
const value = ref<readonly string[]>([]);
const isOpen = ref(false);
const fruit = useBehavior(select, () => ({
  items: fruits,
  value: value.value,
  onValueChange: (keys) => (value.value = keys),
  isOpen: isOpen.value,
  onOpenChange: (open) => (isOpen.value = open),
}));
</script>

HTML

const fruit = document.querySelector('uni-select');
fruit.value = ['banana'];
fruit.addEventListener('valuechange', (event) => {
  fruit.value = event.detail;
});

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

A controlled select that keeps isOpen true stays open after Escape, Tab or a choice.

Virtualization

Select takes Listbox’s virtualization props and passes them to its list. Put the scroller part inside the popover, around the list, and render the renderedKeys state as on Listbox. Rows are positioned absolutely, so give the popover a width, such as width: var(--anchor-width). Opening scrolls the selected option into view.

Anatomy

Part Element Notes
root any, usually div Around every other part. Select tracks focus within it.
label any, span by default Names the trigger and the list. A press on it focuses the trigger without opening the list. UniAria writes no for, even on a <label>.
triggerFrame any, usually div Optional, inside the root, around the trigger and its extras. It carries the trigger’s state attributes, and the pointer over it hovers the trigger. Without an anchor part, the popover is placed against it.
trigger button Opens and closes the list. Gets role="combobox"; its text is the value. Add an icon hidden with aria-hidden.
value any, usually span Optional, inside the trigger, left empty: UniAria writes the value text or the placeholder.
triggerExtras any, usually span Optional, inside the frame, beside the trigger and never inside it: your buttons, badges and counts. It carries the trigger’s state attributes.
anchor any Optional. The popover is placed against it instead of the frame or the trigger.
popover any, usually div Always rendered, after the trigger. Hidden while closed; shown in the top layer while open.
arrow any, inside the popover Optional. Points at the trigger, or the anchor.
scroller any, usually div Only when virtualized: the box that scrolls, around the list.
list any, usually div or ul Inside the popover. Holds the options and groups.
empty any Optional, inside the popover and outside the list: your “No items” text, hidden while there are options.
close button Optional, inside the popover. A visually hidden one lets touch screen-reader users close the list.
description any Optional. Describes the trigger.
error any Optional. Shown and announced while isInvalid.
errorText any, inside error Optional. In a Form, UniAria writes the first error’s text here; leave it out to render errors yourself.
hiddenSelect select, left empty Optional. Submits the value and takes autofill; UniAria writes its options.

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).

The item parts are Listbox’s: option, optionLabel, optionDescription, optionExtras, group and groupLabel, inside the list and keyed by the item’s key (see Listbox). optionExtras holds badges, counts or icons inside an option, nothing focusable, and describes the option after its description; extras with aria-hidden="true" stay out of the description (see Extras in options).

Props

Prop Attribute Type Default Description
items, getKey, getTextValue, isItemDisabled, getChildren — (properties, or markup) Listbox’s: the options and how to read them. getTextValue is also the displayed value and the hidden <option>s’ text.
disabledKeys disabled-keys (JSON) readonly string[] [] Disabled options, by key.
value / defaultValue — (properties) readonly string[] [] The selected keys; at most one in single mode. Keys with no item stay in it and show nothing.
selectionMode selection-mode 'single' | 'multiple' 'single' Multiple mode keeps the list open while options toggle.
isOpen / defaultOpen — / default-open boolean false Whether the list is open. Changes are reported by openchange.
placeholder placeholder string — The value part’s text while nothing is selected. There is no default text.
isDisabled disabled boolean false The trigger is natively disabled and nothing opens.
isReadOnly readonly boolean false aria-readonly on the trigger. The list opens and focus moves, but nothing is selected, from the list or the closed trigger.
isRequired required boolean false aria-required on the trigger and required on the hidden <select>.
isInvalid invalid boolean — aria-invalid on the trigger, and shows the error part. Unset in a Form, the form’s errors decide it.
shouldFocusWrap focus-wrap boolean false The arrows in the list wrap from the last option to the first.
positionArea position-area PositionArea 'block-end span-inline-end' Where the popover goes: below the trigger, aligned to its start edge. See Popover.
positionTryFallbacks position-try-fallbacks string 'flip-block, flip-inline' Where it goes when that doesn’t fit.
triggerId — string generated The trigger’s id while it has none of its own.
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 trigger and the list.
describedBy described-by string — Ids of more elements that describe the trigger.
tooltip tooltip (the <uni-tooltip>’s id) a tooltip’s instance — A Tooltip for the trigger. On the element, a <uni-tooltip> that <uni-select> sits in directly links too. With shouldOpenOnlyWhenTruncated, it measures the value part, else the trigger.
optionTooltip option-tooltip (the <uni-tooltip>’s id) a tooltip’s instance — One Tooltip for every option (see Tooltips). On the element, only the attribute links it.
virtualizer, itemSize, shouldMeasureItems, overscan, initialItemCount as on Listbox Listbox’s virtualization, passed to the list.

In a Form, Select also takes name, form, validate, validationTrigger, validationDelay, validationMessages, dependsOn, isEqual and shouldPreserve: see Field props for the form.

The list always focuses options on hover and is vertical; the popover is always modal and dismissable. Development builds warn when the select has no name: no label part, no labelledBy and no aria-label on the trigger.

Events

Event Callback prop Detail When
valuechange onValueChange readonly string[] The user changed the selection, in the list or by typing or ArrowLeft and ArrowRight on the closed trigger. Never for a value you set.
openchange onOpenChange boolean The user opened or closed the list. Once per change, never for an isOpen you set.
focuschange onFocusChange boolean Focus entered or left the select. The trigger and the popup count as one, so choosing an option reports nothing.
renderedkeyschange onRenderedKeysChange { keys: readonly string[]; sections: readonly string[] } The set of options to render changed. Only virtualized lists need it.

Actions

Action What it does
open() Opens the list, as Enter on the trigger does: focus goes to the selected option, else the first one. Does nothing while disabled.
close() Closes the list and returns focus to the trigger if it was in the list.

In React and Vue they are on the handle (fruit.actions.open()); on the custom element they are methods (document.querySelector('uni-select').open()). While isOpen is controlled, each only reports openChange and leaves the change to you.

State

Field Type Meaning
value readonly string[] The selected keys.
valueText string The selected options’ text, joined with , ; '' when none has an item.
isPlaceholder boolean No selected key has an item: the value part shows the placeholder.
selectionMode 'single' | 'multiple' The selectionMode prop.
isOpen boolean The list is open.
focusedKey string | null The option with focus, or the one the list focuses when it opens.
isEmpty boolean The list has no options.
isDisabled, isReadOnly, isRequired, isInvalid boolean The props of the same names.
isErrorShown boolean The error part is shown: invalid and not disabled.
isFocusWithin boolean Focus is on the trigger or in the popup.
isFocusVisible boolean That focus came from the keyboard.
isVirtualized, renderedKeys, renderedSections Listbox’s virtualization state.

Select what you render, and the component renders again only when it changes:

const isOpen = useBehaviorState(fruit, (state) => state.isOpen);

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

Styling

Attribute On When
data-uni="select" root Always.
data-element every other part Always.
data-open named parts The list is open.
data-entering, data-exiting named parts The first frames after opening; closing, until your exit animations on the popover end.
data-placeholder named parts Nothing is selected.
data-multiple named parts Multiple mode.
data-disabled, data-readonly, data-required, data-invalid named parts The select is disabled, read-only, required or invalid.
data-focus-within, data-focus-visible named parts Focus is on the trigger or in the popup, and from the keyboard.
data-pressed, data-hovered, data-focused trigger, triggerFrame, triggerExtras As on Button. The frame and the extras carry the trigger’s, and data-hovered is set while the pointer is anywhere over the frame.
data-placement popover, arrow The side the popover opened on: top, bottom, left or right.
data-empty, data-orientation list, empty Listbox’s: no options, and vertical.
data-selected, data-focused, data-focus-visible, data-hovered, data-pressed, data-disabled option Listbox’s. Hovering focuses an option, so data-focused follows the mouse too.

“Named parts” are every part except anchor, scroller and hiddenSelect. State attributes are present or absent, never "false".

Custom property On Value
--anchor-width popover The width of the anchor part, else the trigger frame, else the trigger, for min-width.
--available-width popover The most room the popover has across its positions, in pixels.
--available-height popover The same, vertically, for a max-height that leaves room to flip.

They resolve on the popover, so use them there:

[data-uni='select'] [data-element='Popover'] {
  min-width: var(--anchor-width);
  max-height: min(16rem, var(--available-height));
  margin: 4px 0; /* the gap from the trigger */
  overflow: auto;
}

By default UniAria places the open popover with inline position: fixed, top and left, and the gap from the trigger is the popover’s margin (see Popover for CSS anchor positioning instead). It also hides the hidden <select> with inline styles. Everything else is your CSS.

Default styles

The optional @uniaria/styles package styles every part, as in the demo above, whose CSS tab holds only the page’s layout. The trigger is a field’s box holding the value, on one line that ends in an ellipsis, with a soft placeholder and a chevron the styles draw, so leave your icon out. In a triggerFrame, the extras sit over the trigger’s end, the value ends before them, and the chevron comes after them (see Labels, frames and extras). The popover is a surface a gap away from the trigger, at least as wide as it and no larger than the room UniAria measures, and the list scrolls inside it; options are Listbox’s. They cut their text to one line with an ellipsis, so pair them with an option tooltip (see Tooltips), and their extras come before the check. Opening, the popover fades in and grows from 90% as it moves 8 pixels away from the trigger, in 80 milliseconds, and closing reverses it; under reduced motion it shows and hides at once. The close button stays visually hidden until it has focus.

Keyboard

On the closed trigger:

Key Behavior
Enter, Space Opens the list and focuses the selected option, else the first.
ArrowDown, Alt+ArrowDown The same.
ArrowUp, Alt+ArrowUp Opens the list and focuses the selected option, else the last.
ArrowLeft, ArrowRight Single mode: selects the previous or next enabled option without opening; with nothing selected, the first. Doesn’t wrap.
Printable characters Single mode: typeahead selects the next matching option without opening. Space while a query is in progress joins it.
Home, End, PageUp, PageDown Not handled.
Escape Not handled, so it reaches a dialog around the select.

In the open list, focus is on an option and Listbox’s keys apply: the arrows, Home, End, the page keys, typeahead and, in multiple mode, the range keys. Besides them:

Key Behavior
Enter, Space Single mode: selects the option and closes the list. Multiple mode: toggles it.
Escape Closes the list without changing the value. Focus returns to the trigger.
Tab, Shift+Tab Closes the list without selecting, and moves focus on from the trigger: to what follows it, or with Shift to what comes before it.

Each of the trigger’s extras is a Tab stop of its own after the trigger, in DOM order, so Tab in the open list closes it and moves focus to the first extra.

Accessibility

  • The trigger is a <button> with role="combobox", aria-haspopup="listbox", aria-expanded and aria-controls, named by the label alone; its text is the value. It reads “combobox, Fruit, has popup listbox, not expanded”, then “Apple”. Because it is a combobox, it can carry aria-required, aria-invalid and aria-readonly, which a plain button can’t. aria-controls stays while the list is closed: Chromium computes the value from the selected option of the list it points at.
  • Focus moves into the list when it opens, to the selected option, else the first one (the last for ArrowUp), and the list reads “listbox, Fruit”. The APG’s select-only combobox keeps focus on the trigger and moves aria-activedescendant, which VoiceOver on iOS and TalkBack ignore.
  • While open, the rest of the page is hidden from assistive technology with aria-hidden, but it isn’t inert: one press outside both closes the list and acts on what it hit. Render a visually hidden close button for touch screen-reader users, who can’t reach the page to dismiss it.
  • On close, focus returns to the trigger as the closing starts, so a key pressed during an exit animation doesn’t reach an option. After a press outside, focus stays where the press put it.
  • The label isn’t a <label for>: activating it would open the list. A press on it focuses the trigger and shows its focus ring.
  • The hidden <select> has aria-hidden="true" and tabindex="-1", so screen readers and Tab skip it.
  • The root part is needed for focuschange and the focus attributes.
  • Extras beside the trigger aren’t part of its name or value: each reads on its own, such as “button, Clear Fruit”.
  • Option extras describe their option after its description: “option, Apple, New, not selected”. Extras with aria-hidden="true" stay out of it.
  • Tooltips. An overflow tooltip with relation: 'none' adds nothing: a cut-off value still reads “combobox, Fruit, has popup listbox, not expanded”, then the whole value, which is in the trigger already. The option tooltip never describes an option.

Compared with other libraries

  • The trigger is a combobox, named by the label, as in Radix and the APG. React Aria’s is a plain button named “Apple Fruit”, which can’t say required, invalid or read-only.
  • Focus moves into the list, as in React Aria, Radix and the browser’s customizable select. The APG keeps focus on the trigger and moves aria-activedescendant.
  • Tab closes the list without selecting and moves on. The APG selects the focused option; React Aria and Radix keep Tab in the popup.
  • Typing on the closed trigger selects without opening, as native <select>, React Aria and Radix do. The APG opens the list.
  • A mouse opens the list on press, and releasing over an option after moving 10 px selects it, as native <select>, React Aria and Radix do. The APG opens on click. Touch opens on release.
  • The value is an array of keys in both modes, as Listbox’s.
  • Clearing and other actions are your own controls in triggerExtras, instead of Cube UI Kit’s isClearable and actions. A press on one while the list is open closes it and acts; Cube UI Kit keeps it open.
  • Compared with a native <select>, options can hold markup and groups can have headings, multiple mode opens a popup, and the hidden <select> keeps form submission, validation and autofill.