Select
Picks one value, or several, from a fixed list the user cannot type into.
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.
<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
Labelinside aFieldor anaria-labelon the trigger. Select.Contentteleports to the end of<body>unless you turn that off withoptions.portal.disabled. Leave it on where the trigger sits inside anything withoverflow: hidden.- The content resolves everything but
sideandalignonce, 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
Comboboxinstead, so the user can narrow it by typing. - Where the OS picker is the better control, on a phone or a tablet, set
options.nativeand 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.
| Name | Required | Description |
|---|---|---|
| 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.Value | Renders a <span> with the selected item’s text. | |
| Select.Icon | Renders 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.Arrow | Renders a <div aria-hidden="true"> pointing at the trigger. Goes in the header or footer slot. | |
| Select.ScrollUpArrow | Renders a <div> that scrolls the list up while a pointer rests on it. Goes in the header slot. | |
| Select.ScrollDownArrow | Renders a <div> that scrolls the list down. Goes in the footer slot. | |
| Select.Group | Renders a <div role="group">. | |
| Select.GroupLabel | Renders a <div>, wired to its group. | |
| Select.Item | true | Renders a <div role="option">. |
| Select.ItemText | Renders a <span> with the item’s label. | |
| Select.ItemIndicator | Renders a <span> while the item is selected. | |
| Select.Separator | Renders 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.
<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
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
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
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
Slot props
The field state set, plus the four below.
Select.Value
Renders a <span> with the selected item’s text, joining a multiple selection
with a comma.
Props
Slots
Slot props
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.
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.
Slots
Slot props
All three slots take the same props.
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
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
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
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
Slot props
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.
Slot props
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
Composable
useMirrorSelect(id) reaches a select from anywhere in the app.
Data attributes
CSS variables
Errors
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.
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.