Skip to content

Select

Picks one value, or several, from a fixed list the user cannot type into.

View source View as Markdown

Select is a listbox behind a trigger, for picking one value or several from a fixed set. Use it where you would reach for a native <select>; once the list is long enough to scroll, use Combobox.

The example switches the portal off, so the popup renders in place and scrolls with the frame around it rather than with the page.

App.vue
<template>
  <Select.Root v-model="framework" :options="options">
    <Select.Trigger
      class="group border-surface rounded-component-lg focus:focus-ring data-[popup-open=true]:focus-ring data-[disabled=true]:border-disabled-subtle flex h-12 w-64 cursor-pointer items-center justify-between gap-1.5 border-2 px-3.5 text-left outline-4 outline-transparent transition-all duration-100 ease-linear select-none data-[disabled=true]:cursor-not-allowed"
    >
      <span
        class="pointer-events-none flex max-h-full w-full flex-col-reverse justify-center gap-0 px-1 transition-all duration-100 ease-linear group-data-[filled=true]:gap-1"
      >
        <Select.Value
          class="text-primary-solid type-component-lg group-data-[disabled=true]:text-disabled-on-subtle flex h-0 w-full items-center overflow-hidden [line-height:normal]! opacity-0 transition-all duration-100 ease-linear group-data-[filled=true]:h-[0.9375rem] group-data-[filled=true]:opacity-100"
        />
        <span
          class="text-primary-muted type-component-lg group-data-[filled=true]:type-component-2xs group-data-[disabled=true]:text-disabled-muted flex w-full items-center [line-height:normal]! transition-all duration-100 ease-linear"
        >
          Framework
        </span>
      </span>

      <Select.Icon
        class="text-primary-muted group-data-[filled=true]:text-primary-solid group-data-[disabled=true]:text-disabled-muted flex shrink-0 items-center transition-all duration-100 ease-linear"
      >
        <svg class="size-4.5" viewBox="0 0 16 16" aria-hidden="true">
          <path
            d="m4 6.5 4 4 4-4"
            fill="none"
            stroke="currentColor"
            stroke-width="1.5"
            stroke-linecap="round"
            stroke-linejoin="round"
          />
        </svg>
      </Select.Icon>
    </Select.Trigger>

    <Select.Content
      class="rounded-component-2xl bg-primary-inverted shadow-component-high overflow-y-auto p-1.5 backdrop-blur-[8rem] [&_[data-scope=list]]:outline-none"
    >
      <Select.Item
        v-for="name in frameworks"
        :key="name"
        class="group/item rounded-component-md type-component-lg text-primary-on-subtle hover:bg-primary-subtle active:bg-primary-subtle-hover data-[highlighted=true]:bg-primary-subtle data-[disabled=true]:text-disabled-on-subtle flex h-12 w-full items-center justify-start gap-4 px-1 transition-colors duration-100 ease-linear select-none [--mirror-select-item-cursor:pointer]"
        :value="name"
      >
        <Select.ItemText
          class="inline-flex h-full min-w-0 flex-1 items-center overflow-hidden pr-1.5 pl-5.5"
        >
          <span class="truncate">{{ name }}</span>
        </Select.ItemText>

        <Select.ItemIndicator
          class="text-primary-muted group-hover/item:text-primary-solid group-data-[highlighted=true]/item:text-primary-solid group-data-[disabled=true]/item:text-disabled-muted flex shrink-0 items-center pr-4 leading-none transition-colors duration-100 ease-linear"
        >
          <svg class="size-4.5" viewBox="0 0 16 16" aria-hidden="true">
            <path
              d="m3.5 8.5 3 3 6-6.5"
              fill="none"
              stroke="currentColor"
              stroke-width="1.5"
              stroke-linecap="round"
              stroke-linejoin="round"
            />
          </svg>
        </Select.ItemIndicator>
      </Select.Item>
    </Select.Content>
  </Select.Root>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Select } from '@maas/mirror/vue'

const options = {
  modal: false,
  portal: { disabled: true },
}

const frameworks = ['Nuxt', 'Astro', 'Remix', 'SvelteKit']
const framework = ref<string | null>('Nuxt')
</script>

Usage guidelines

  • Make sure the select has an accessible name, either through a Label inside a Field or an aria-label on the trigger.
  • Select.Content teleports to the end of <body> unless you turn that off with options.portal.disabled. Leave it on where the trigger sits inside anything with overflow: hidden.
  • The content resolves everything but side and align once, during setup, so change the rest before the popup first renders or key the root to remount it.
  • Once the list is long enough to scroll, reach for Combobox instead, so the user can narrow it by typing.
  • Where the OS picker is the better control, on a phone or a tablet, set options.native and keep the same markup.

Anatomy

Assemble the parts. Select.Content is one part over four boxes: the teleport, the positioned element, the popup and the listbox. Everything you put inside it renders in the listbox.

NameRequiredDescription
Select.Root true Renders its children plus one hidden input per selected value.
Select.Trigger true Renders a <button>. Opens the popup and owns the collapsed state.
Select.ValueRenders a <span> with the selected item’s text.
Select.IconRenders a <span aria-hidden="true"> for the disclosure marker.
Select.Content true Renders the teleport, the floating box, the popup and the <div role="listbox">.
Select.ArrowRenders a <div aria-hidden="true"> pointing at the trigger. Goes in the header or footer slot.
Select.ScrollUpArrowRenders a <div> that scrolls the list up while a pointer rests on it. Goes in the header slot.
Select.ScrollDownArrowRenders a <div> that scrolls the list down. Goes in the footer slot.
Select.GroupRenders a <div role="group">.
Select.GroupLabelRenders a <div>, wired to its group.
Select.Item true Renders a <div role="option">.
Select.ItemTextRenders a <span> with the item’s label.
Select.ItemIndicatorRenders a <span> while the item is selected.
Select.SeparatorRenders a <div role="separator">.
<script setup lang="ts">
import { Field, Label, Select } from '@maas/mirror/vue'
</script>

<template>
  <Field.Root :options="{ name: 'framework' }">
    <Label>Framework</Label>
    <Select.Root v-model="framework">
      <Select.Trigger>
        <Select.Value placeholder="Pick one" />
        <Select.Icon />
      </Select.Trigger>
      <Select.Content>
        <Select.Group>
          <Select.GroupLabel>Meta</Select.GroupLabel>
          <Select.Item value="react">
            <Select.ItemText>React</Select.ItemText>
            <Select.ItemIndicator />
          </Select.Item>
        </Select.Group>
        <Select.Separator />
        <Select.Item value="vue">
          <Select.ItemText>Vue</Select.ItemText>
          <Select.ItemIndicator />
        </Select.Item>
      </Select.Content>
    </Select.Root>
  </Field.Root>
</template>

Examples

Multiple selection

Set options.multiple and the value becomes an array, the popup stays open as you pick, and every part gains data-multiple.

<script setup lang="ts">
const frameworks = ref<Array<string>>([])
</script>

<template>
  <Select.Root v-model="frameworks" :options="{ multiple: true }">
    <Select.Trigger>
      <Select.Value placeholder="Pick a few" />
    </Select.Trigger>
  </Select.Root>
</template>

The value and options.multiple have to agree, or the root throws invalid_multiple_value.

Select.Value joins the labels with a comma. Take its slot to render them some other way.

<template>
  <Select.Value v-slot="{ value }" placeholder="Pick a few">
    {{ value.length }} selected
  </Select.Value>
</template>

Object values

An item value does not have to be a string. Hand Select.Item the object you already hold and tell the select how to read it: isItemEqualToValue decides which item the current selection is, itemToStringLabel supplies the text Select.Value shows, and itemToStringValue supplies the string the hidden inputs submit.

<script setup lang="ts">
interface Framework {
  id: string
  name: string
}

const frameworks: Array<Framework> = [
  { id: 'vue', name: 'Vue' },
  { id: 'react', name: 'React' },
]

const framework = ref<Framework | null>(null)
</script>

<template>
  <Select.Root
    v-model="framework"
    name="framework"
    :options="{
      isItemEqualToValue: (item, value) => item.id === value.id,
      itemToStringLabel: (item) => item.name,
      itemToStringValue: (item) => item.id,
    }"
  >
    <Select.Trigger>
      <Select.Value placeholder="Pick one" />
    </Select.Trigger>
    <Select.Content>
      <Select.Item v-for="entry in frameworks" :key="entry.id" :value="entry">
        <Select.ItemText>{{ entry.name }}</Select.ItemText>
      </Select.Item>
    </Select.Content>
  </Select.Root>
</template>

Without isItemEqualToValue two objects only match when they are the same object, which is usually what you want when the items and the model come from the same array. Set it as soon as the model is rebuilt from a server response.

An object shaped { value, label } needs neither converter: the defaults read label for the text and value for the form.

Native

Set options.native and the select runs on the browser’s own <select>, which brings the native popup, the OS picker on a phone or a tablet, and the keyboard handling that comes with it. Your markup does not change. The trigger keeps drawing the frame you styled and hosts a transparent <select> over it, and Select.Content stops rendering a popup and only mounts the items, which is where the options come from.

App.vue
<template>
  <Select.Root v-model="framework" :options="options">
    <Select.Trigger
      class="group border-surface rounded-component-lg data-[focused=true]:focus-ring flex h-12 w-64 items-center justify-between gap-1.5 border-2 px-3.5 text-left outline-4 outline-transparent transition-all duration-100 ease-linear select-none"
    >
      <Select.Value
        class="text-primary-solid type-component-lg data-[placeholder=true]:text-primary-muted flex min-w-0 items-center truncate"
        placeholder="Pick a framework"
      />

      <Select.Icon
        class="text-primary-muted flex shrink-0 items-center transition-all duration-100 ease-linear"
      >
        <svg class="size-4.5" viewBox="0 0 16 16" aria-hidden="true">
          <path
            d="m4 6.5 4 4 4-4"
            fill="none"
            stroke="currentColor"
            stroke-width="1.5"
            stroke-linecap="round"
            stroke-linejoin="round"
          />
        </svg>
      </Select.Icon>
    </Select.Trigger>

    <Select.Content>
      <Select.Item v-for="name in frameworks" :key="name" :value="name">
        {{ name }}
      </Select.Item>
    </Select.Content>
  </Select.Root>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Select } from '@maas/mirror/vue'

const options = { native: true }

const frameworks = ['Nuxt', 'Astro', 'Remix', 'SvelteKit']
const framework = ref<string | null>(null)
</script>

The <select> is the control now. It takes the focus, the tab stop, the name, the form and the required flag, so Select.Root renders no hidden inputs and a <label for> points at it. Focus still reaches the trigger’s data-focused, which is what the ring in the example is drawn from, since the element that holds focus is the one inside the frame rather than the frame itself.

Every part that resolves the select context gains data-native, so a native select is styled as its own mode.

.trigger[data-native='true'] {
  padding-right: 2rem;
}

Select.Group becomes an <optgroup>, labelled with the text Select.GroupLabel rendered. A group without a label stays flat, and so does everything outside a group.

<template>
  <Select.Content>
    <Select.Group>
      <Select.GroupLabel>Meta</Select.GroupLabel>
      <Select.Item value="react">React</Select.Item>
    </Select.Group>
    <Select.Item value="vue">Vue</Select.Item>
  </Select.Content>
</template>

Two things are left behind. The popup never opens, so open, defaultOpen, update:open and data-popup-open are all inert, the header and footer slots are not rendered at all, and Select.ItemIndicator and Select.Separator mount inside the registry where nobody sees them. And Select.Trigger renders its own element: asChild is ignored and the default <button> becomes a <div>, because a <button> may not contain a <select>.

Object values keep working. An <option> carries a string, and that string comes from itemToStringValue, the same one the hidden inputs would have submitted.

Positioning

Select.Content positions itself against the trigger, steered by the floating keys on the root’s options; the options table below lists every one of them. The resolved placement comes back as data-side and data-align; read those rather than the options you passed, since flipping can change either one.

<template>
  <Select.Root
    :options="{
      floating: { side: 'top', align: 'center', sideOffset: 8, flip: false },
    }"
  />
</template>

Pass anchor to position against something else, and pass any of the ten floating keys straight to the content to override the options for one instance.

<template>
  <Select.Content :anchor="() => frame" side="top" :side-offset="12" />
</template>

The content writes --rack-floating-anchor-width, --rack-floating-anchor-height, --rack-floating-available-width, --rack-floating-available-height and --rack-floating-transform-origin onto the floating box, which is why an unstyled popup is already as wide as its trigger and never taller than the space around it.

.popup {
  min-width: var(--mirror-select-popup-min-width);
  max-height: var(--mirror-select-popup-max-height);
}

An arrow

Select.Arrow draws a marker pointing at the trigger. Render it in the content’s header or footer slot, so it sits inside the popup and outside the listbox. It ships position: absolute and takes its offset from the same pass that placed the popup, so give it a shape and leave the placement alone.

<template>
  <Select.Content>
    <template #header>
      <Select.Arrow class="arrow" />
    </template>
    <Select.Item value="react">React</Select.Item>
  </Select.Content>
</template>

options.floating.arrowPadding says how close the arrow may come to the popup’s corners. Once collision handling has pushed it off the trigger’s centre it carries data-uncentered, and it carries the resolved data-side and data-align throughout.

Aligning a value with the trigger

Set options.floating.alignItemWithTrigger to lay the selected item over the trigger. The popup covers the trigger rather than sitting beside it, and a list too tall for the screen scrolls until the selected value meets the button that opened it.

<template>
  <Select.Root :options="{ floating: { alignItemWithTrigger: true } }" />
</template>

The measurement is taken against the viewport, so while it holds the floating box takes fixed coordinates and carries data-item-aligned, and side, sideOffset, align, alignOffset and strategy have no effect. Style the covering popup from the attribute rather than from the option.

.mirror-select-floating[data-item-aligned='true'] .mirror-select-popup {
  box-shadow: 0 0 0 1px var(--color-border-muted);
}

Three things send the popup back to the standard placement, and each of them leaves data-item-aligned off rather than reporting anything: nothing is selected, the pointer is a coarse one, and the popup has not been laid out yet. Leave an arrow off a select that aligns its items, since the popup covers the trigger it would point at.

Scrolling a long list

Select.ScrollUpArrow and Select.ScrollDownArrow move a list that is taller than the popup. Each one renders only while the list can still travel that way, and each scrolls for as long as a pointer rests on it or holds it down. Put the up arrow in the content’s header slot and the down arrow in its footer slot, so both sit inside the popup and outside the listbox.

<template>
  <Select.Content>
    <template #header>
      <Select.ScrollUpArrow class="scroll-arrow" />
    </template>
    <Select.Item value="react">React</Select.Item>
    <template #footer>
      <Select.ScrollDownArrow class="scroll-arrow" />
    </template>
  </Select.Content>
</template>

Give the listbox an overflow of its own, or put it on the popup, and cap the height. The arrows measure whichever of the two boxes scrolls.

.mirror-select-list {
  overflow-y: auto;
}

Each arrow publishes data-direction, so one rule paints both and a second turns the glyph around.

.scroll-arrow[data-direction='down'] {
  rotate: 180deg;
}

Where the popup renders

The content teleports to the end of <body>, which is what keeps a popup out of an ancestor’s overflow: hidden and above the rest of the page. Turn it off to render in place, or name a target of your own.

<template>
  <Select.Root :options="{ portal: { to: '#overlays' } }" />
  <Select.Root :options="{ portal: { disabled: true } }" />
</template>

options.backdrop adds a fixed <div role="presentation"> behind the popup. It has no dismiss behaviour of its own: the popup’s layer already closes on a pointer down outside.

<template>
  <Select.Root :options="{ backdrop: true }" />
</template>

Under options.native none of this runs. There is no popup to place, teleport or transition, so the floating and portal options are ignored, the open state never leaves false, update:open never fires and data-popup-open never appears. The browser owns the list.

Inside a form

Select.Root renders one visually hidden input per selected value once it has a name, so the form sees it, and a surrounding Field supplies the name and the flags.

<template>
  <Field.Root :options="{ name: 'framework', required: true }">
    <Label>Framework</Label>
    <Select.Root v-model="framework">
      <Select.Trigger>
        <Select.Value placeholder="Pick one" />
      </Select.Trigger>
    </Select.Root>
  </Field.Root>
</template>

Reaching the select from anywhere

Give the root an id and useMirrorSelect(id) opens, closes and reads that select from anywhere in the app.

<script setup lang="ts">
const { isOpen, open, value, setValue } = useMirrorSelect(SelectId.Framework)
</script>

<template>
  <Button :aria-expanded="isOpen" @click="open()">Choose a framework</Button>
</template>

Styling from state

Every part writes its state to data-*, so style it with attribute selectors.

.item[data-highlighted='true'] {
  background: var(--app-color-primary-bg-subtle);
}

.item[data-disabled='true'] {
  opacity: 0.5;
}

.icon[data-popup-open='true'] {
  transform: rotate(180deg);
}

.value[data-placeholder='true'] {
  color: var(--app-color-surface-fg-subtle);
}

The content’s four boxes are not parts, so they are reached by their data-scope or by the class each one ships. A class on Select.Content is applied to the popup, the box you paint.

<template>
  <Select.Content class="popup" />
</template>
.mirror-select-floating {
  --mirror-select-floating-z-index: 80;
}

[data-scope='list'] {
  outline: none;
}

API reference

Module. A bundled options object on the root, and useMirrorSelect(id) as the programmatic API. Every part that reads the select takes id to resolve one it is not nested inside.

Select.Root

Renders its children plus one visually hidden <input> per selected value.

Props

PropTypeDefault
id
stringgenerated
modelValue
string | number | object | Array | undefinedundefined
defaultValue
string | number | object | Array | nullnull
open
boolean | undefinedundefined
defaultOpen
booleanfalse
options
SelectOptionssee below

disabled, readOnly, required and name resolve in one order everywhere: the options object first, then the surrounding Field, then false. Leave the option unset and the field answers; set it, false included, and it wins.

Control is decided once, on the first render. A select that mounts with modelValue bound stays controlled even if the binding later turns undefined, and one that mounts without it stays uncontrolled. The same holds for open, so swapping a binding in or out after mount changes nothing.

Options

OptionTypeDefault
multiple
booleanfalse
modal
booleantrue
loop
booleantrue
disabled
booleanundefined
readOnly
booleanundefined
required
booleanundefined
name
stringundefined
form
stringundefined
isItemEqualToValue
(item, value) => booleanObject.is
itemToStringLabel
(item) => stringsee below
itemToStringValue
(item) => stringsee below
typeahead
boolean | { resetAfter?: number }{ resetAfter: 1000 }
highlightOnHover
booleantrue
dismiss.escape
booleantrue
dismiss.pointerDownOutside
booleantrue
dismiss.focusOutside
booleantrue
focus.trapped
booleantrue
focus.restore
booleantrue
backdrop
booleanfalse
native
booleanfalse
portal.to
string | HTMLElementbody
portal.disabled
booleanfalse
portal.defer
booleanfalse
floating.side
'top' | 'right' | 'bottom' | 'left''bottom'
floating.sideOffset
number4
floating.align
'start' | 'center' | 'end''start'
floating.alignOffset
number0
floating.strategy
'absolute' | 'fixed''absolute'
floating.flip
booleantrue
floating.shift
booleantrue
floating.collisionPadding
number8
floating.alignItemWithTrigger
booleanfalse
floating.arrowPadding
number5
forceMount.popup
booleanfalse
transition.popup
string'mirror-select-popup'
transition.backdrop
string'mirror-select-backdrop'

Both string converters read an item the same way. A primitive is its own string, an object shaped { value, label } supplies the key the converter needs, and anything else falls back to String(item).

Emits

EmitPayload
update:modelValue
string | number | object | Array | null
update:open
boolean
highlightChange
string | number | object | null

Select.Trigger

Renders a <button> with aria-haspopup="listbox". Under options.native it renders a <div> around a transparent <select> instead, drops the listbox wiring, and ignores asChild.

Props

PropTypeDefault
disabled
booleanoptions.disabled

Slot props

The field state set, plus the four below.

PropType
open
boolean
value
SelectModelValue | null
side
SelectSide | undefined
align
SelectAlign | undefined
pressed
boolean

Select.Value

Renders a <span> with the selected item’s text, joining a multiple selection with a comma.

Props

PropTypeDefault
placeholder
string''

Slots

SlotSlot props
default
value, label
placeholder
none

Slot props

PropType
value
SelectModelValue | null
label
string

Select.Icon

Renders a <span aria-hidden="true"> with data-popup-open, for the disclosure marker. No props beyond id and the primitive ones.

Select.Content

Renders everything that floats: the teleport, the floating box, the popup and the listbox. Everything in its default slot renders inside the listbox. It anchors on Select.Trigger. Under options.native it renders one hidden, inert box instead, and the items inside it feed the browser’s own list.

Layers

None of them is a part, so each is styled through its data-scope or the class it ships. A class on the content itself is applied to the popup, or to the registry under options.native.

LayerElementPublishes
portal
nonenothing
backdrop
div[role=presentation]data-select, data-state
floating
divdata-side, data-align, data-anchor-hidden, data-state
popup
divdata-select, data-side, data-align, data-state, data-multiple
list
div[role=listbox]data-multiple, aria-activedescendant, aria-multiselectable
registry
div[aria-hidden][inert]data-select, data-native

Props

Every prop below but anchor is an escape hatch over an option, forceMount over options.forceMount.popup and the rest over the matching options.floating key, and only side and align stay reactive after mount.

PropTypeDefault
forceMount
booleanoptions.forceMount.popup
anchor
HTMLElement | (() => HTMLElement | null)the trigger
side
'top' | 'right' | 'bottom' | 'left''bottom'
sideOffset
number4
align
'start' | 'center' | 'end''start'
alignOffset
number0
strategy
'absolute' | 'fixed''absolute'
flip
booleantrue
shift
booleantrue
collisionPadding
number8
alignItemWithTrigger
booleanfalse
arrowPadding
number5

Slots

SlotRenders
defaultInside the listbox. Items, groups and separators.
headerInside the popup, above the listbox. Anything that is not an option.
footerInside the popup, below the listbox.

Slot props

All three slots take the same props.

PropType
open
boolean
side
SelectSide
align
SelectAlign

Select.Arrow

Renders a <div aria-hidden="true"> pointing at the trigger. It ships position: absolute and takes its offset from the same positioning pass as the popup; the shape it draws is yours. Render it in the content’s header or footer slot, so it sits inside the popup and outside the listbox, and expect missing_select_content anywhere else. No props beyond id and the primitive ones.

Slot props

PropType
side
SelectSide
align
SelectAlign
uncentered
boolean

Select.ScrollUpArrow

Renders a <div aria-hidden="true"> that scrolls the list up for as long as a pointer rests on it or holds it down. It renders only while the list has somewhere left to travel upwards, and nothing at all under options.native. Render it in the content’s header slot, and expect missing_select_content outside a Select.Content. No props beyond id and the primitive ones.

Slot props

PropType
direction
SelectScrollDirection
side
SelectSide

Select.ScrollDownArrow

Renders a <div aria-hidden="true"> that scrolls the list down on the same terms, rendering only while the list has somewhere left to travel downwards. Render it in the content’s footer slot. No props beyond id and the primitive ones.

Slot props

PropType
direction
SelectScrollDirection
side
SelectSide

Select.Item

Renders a <div role="option">. Items register in document order, so grouping and portals cannot scramble the arrow-key sequence. Naming a <button> in as gets the button semantics with it, including the native disabled attribute.

Props

PropTypeDefault
value
string | number | objectnone
label
stringthe Select.ItemText content
disabled
booleanfalse

Slot props

PropType
selected
boolean
highlighted
boolean
disabled
boolean
index
number

Select.ItemText

Renders a <span> and registers its text as the item’s label when no label prop was given.

Select.ItemIndicator

Renders a <span> while the item is selected.

Props

One indicator per item, so forceMount is configured on the part and nowhere else. Set it on the indicator you want kept in the tree while its item is unselected. Every other indicator is left where it was.

PropTypeDefault
forceMount
booleanfalse

Slot props

PropType
selected
boolean

Select.Group

Renders a <div role="group">.

Select.GroupLabel

Renders a <div>, wired to its group through aria-labelledby. It must be nested inside a Select.Group.

Select.Separator

Renders a <div role="separator"> with aria-orientation.

Props

PropTypeDefault
orientation
'horizontal' | 'vertical''horizontal'

Composable

useMirrorSelect(id) reaches a select from anywhere in the app.

KeyType
isOpen
ComputedRef<boolean>
open
() => void
close
(restoreFocus?: boolean) => void
toggle
() => void
value
ComputedRef<SelectModelValue | null>
setValue
(next: SelectModelValue | null) => void
highlightedValue
ComputedRef<SelectValueType | null>

Data attributes

PartAttributeValue
Select.Trigger, Select.Item
data-disabled
true
Select.Trigger
data-readonly
true
Select.Trigger
data-required
true
Select.Trigger
data-valid
true
Select.Trigger
data-invalid
true
Select.Trigger
data-dirty
true
Select.Trigger
data-touched
true
Select.Trigger
data-filled
true
Select.Trigger
data-focused
true
Select.Trigger, Select.Content, Select.Arrow, Select.ScrollUpArrow, Select.ScrollDownArrow
data-select
the instance ID
Select.Content
data-scope
floating | popup | list | backdrop | registry
Select.Content, Select.Arrow, Select.ItemIndicator
data-state
open | closed
Select.Trigger, Select.Icon
data-popup-open
true
Select.Trigger, Select.Content, Select.Arrow, Select.ScrollUpArrow, Select.ScrollDownArrow
data-side
top | right | bottom | left
Select.Trigger, Select.Content, Select.Arrow
data-align
start | center | end
Select.Content
data-anchor-hidden
true
Select.Content
data-item-aligned
true
Select.Arrow
data-uncentered
true
Select.ScrollUpArrow, Select.ScrollDownArrow
data-direction
up | down
Select.Item, Select.ItemIndicator
data-selected
true
Select.Item
data-highlighted
true
Select.Item
data-index
the index
Select.Value, Select.Trigger
data-placeholder
true
Select.Trigger, Select.Value, Select.Content, Select.Item
data-multiple
true
Select.Trigger
data-pressed
true
Select.Trigger, Select.Value, Select.Icon, Select.Content, Select.Item, Select.Group, Select.GroupLabel
data-native
true

CSS variables

VariableDefault
--mirror-select-popup-max-height
var(--rack-floating-available-height, none)
--mirror-select-popup-min-width
var(--rack-floating-anchor-width, auto)
--mirror-select-floating-z-index
50
--mirror-select-backdrop-position
fixed
--mirror-select-backdrop-z-index
50
--mirror-select-item-cursor
default
--mirror-select-item-disabled-cursor
not-allowed
--mirror-select-scroll-arrow-cursor
default
--mirror-select-trigger-cursor
pointer
--mirror-select-trigger-position
relative
--mirror-select-native-cursor
pointer
--mirror-select-native-disabled-cursor
not-allowed

Errors

Code
missing_select_context
missing_select_content
missing_context
missing_item_value
duplicate_item_value
invalid_multiple_value

Accessibility

The trigger is a <button> with aria-haspopup="listbox", aria-expanded and aria-controls, and the list is a role="listbox" with aria-activedescendant pointing at the highlighted item, so focus sits on the list rather than on any item.

KeyBehaviour
ArrowDown / ArrowUp on the triggerOpens the popup and highlights the selected item, or the first enabled one.
Enter / Space on the triggerToggles the popup. On a non-native trigger, Space acts on keyup.
ArrowDown / ArrowUp in the popupMoves the highlight, wrapping while options.loop is on.
Home / End in the popupHighlights the first or last enabled item, ignoring loop.
Enter in the popupSelects the highlighted item and closes, returning focus to the trigger. Under multiple it toggles and stays open.
Space in the popupAs Enter. During a typeahead search the space is consumed as a character.
EscapeCloses the popup and returns focus to the trigger.
TabSelects the highlighted item, closes, and lets focus move on.
Printable characters in the popupTypeahead. Highlights the first enabled item whose label starts with the typed string, resetting after resetAfter.
Printable characters on the closed triggerTypeahead again, this time selecting outright the way a native select does. Nothing is registered while the popup is unmounted, so the first keystroke opens the list and the search carries on there unless Select.Content has force-mount.

Every keyboard move scrolls the highlighted item into view, so a popup capped by --mirror-select-popup-max-height cannot lose the highlight off screen. A pointer highlight leaves the scroll position where the reader put it.

PageUp, PageDown, ArrowLeft and ArrowRight are left to the browser.

Under options.native the whole table is left to the browser: the <select> is the control, so it carries the focus, the tab stop and every key, and the trigger around it carries no role and no tabindex of its own. Select.Value goes aria-hidden there, since the <select> already announces the selection.