Autocomplete
A text input that suggests as you type, where the text is the value.
Autocomplete is a text field that offers suggestions while the user types, and
the text in the field is the value. Reach for it when anything the user writes
is acceptable and the list is there to save typing. When only a value from the
list will do, that is a Combobox.
<template>
<div class="flex w-64 flex-col gap-1.5">
<Autocomplete.Root v-model="value" :items="fonts" :options="options">
<Autocomplete.InputGroup
class="group border-surface rounded-component-lg has-[:focus-visible]:focus-ring data-[popup-open=true]:focus-ring relative isolate flex h-12 w-full cursor-text items-center justify-between gap-1.5 border-2 px-3.5 outline-4 outline-transparent transition-all duration-100 ease-linear"
>
<label
class="flex max-h-full w-full cursor-text flex-col-reverse justify-center gap-0 px-1 transition-all duration-100 ease-linear group-has-[:focus]:gap-1 group-has-[input:not(:placeholder-shown)]:gap-1 before:absolute before:-inset-0.5 before:-z-10 before:content-['']"
>
<span
class="relative flex h-0 w-full items-end transition-all duration-100 ease-linear group-has-[:focus]:h-[0.9375rem] group-has-[input:not(:placeholder-shown)]:h-[0.9375rem]"
>
<Autocomplete.Input
class="text-primary-solid type-component-lg -my-1.25 box-content block h-[1lh] w-full min-w-0 bg-transparent py-1.25 [line-height:normal]! transition-colors duration-100 ease-linear outline-none placeholder:text-transparent"
placeholder=" "
/>
</span>
<span
class="text-primary-muted type-component-lg group-has-[:focus]:type-component-2xs group-has-[input:not(:placeholder-shown)]:type-component-2xs flex w-full items-center [line-height:normal]! transition-all duration-100 ease-linear"
>
Font
</span>
</label>
<Autocomplete.Clear
class="text-primary-muted hover:text-primary-solid 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.5 4.5 7 7m0-7-7 7"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
/>
</svg>
</Autocomplete.Clear>
<Autocomplete.Trigger
class="text-primary-muted group-has-[input:not(:placeholder-shown)]:text-primary-solid flex shrink-0 items-center transition-all duration-100 ease-linear"
>
<Autocomplete.Icon
class="flex items-center transition-transform duration-100 ease-linear data-[popup-open=true]:rotate-180"
>
<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>
</Autocomplete.Icon>
</Autocomplete.Trigger>
</Autocomplete.InputGroup>
<Autocomplete.Status
v-slot="{ count, empty, open }"
class="type-component-2xs text-primary-muted px-1 tabular-nums"
>
<template v-if="open">
{{ empty ? 'No matching fonts' : `${count} fonts` }}
</template>
<template v-else>Start typing to search</template>
</Autocomplete.Status>
<Autocomplete.Content
class="rounded-component-2xl bg-primary-inverted shadow-component-high w-(--rack-floating-anchor-width) overflow-y-auto p-1.5 backdrop-blur-[8rem]"
>
<Autocomplete.Collection v-slot="{ item, value: font }">
<Autocomplete.Item
class="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 px-5.5 transition-colors duration-100 ease-linear select-none [--mirror-autocomplete-item-cursor:pointer]"
:value="font"
>
<span class="truncate">{{ item }}</span>
</Autocomplete.Item>
</Autocomplete.Collection>
<Autocomplete.Empty
class="type-component-lg text-primary-muted flex h-12 items-center pl-5.5"
>
No matching fonts.
</Autocomplete.Empty>
</Autocomplete.Content>
</Autocomplete.Root>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Autocomplete } from '@maas/mirror/vue'
const options = {
autoHighlight: true,
openOnInputClick: true,
portal: { disabled: true },
floating: { sideOffset: 28 },
}
const fonts = [
'Alte Haas Grotesk',
'Basis Grotesque',
'Bely Display',
'Cardinal Fruit',
'Domaine Text',
'Editorial New',
'Fraunces',
'Gambetta',
'Instrument Serif',
'Louize',
]
const value = ref('')
</script>
Usage guidelines
- Hand the root an
itemsarray and it filters for you. Passfilter: nullwhen the narrowing already happened somewhere else, on a server for instance, and render whatever you were given. - Make sure the autocomplete has an accessible name, either through a
Labelinside aFieldor anaria-labelon the input. Autocomplete.Statusis a live region, so keep it mounted outside the popup. A region that appears together with its first text is announced unreliably.Autocomplete.Contentteleports to the end of<body>unless you turn that off withoptions.portal.disabled. Leave it on where the field 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.
Anatomy
Assemble the parts. Autocomplete.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 |
|---|---|---|
| Autocomplete.Root | true | Renders its children. Owns the text, the source items and the filtering. |
| Autocomplete.InputGroup | Optional. Renders a <div> around the input and its buttons, carrying the popup state. | |
| Autocomplete.Input | true | Renders an <input role="combobox">. Holds focus for the whole interaction. |
| Autocomplete.Clear | Renders a <button>. Empties the text. | |
| Autocomplete.Trigger | Renders a <button>. Toggles the popup without ever taking focus. | |
| Autocomplete.Icon | Renders a <span aria-hidden="true"> that follows the open state. | |
| Autocomplete.Value | Renders a <span> holding the current text. | |
| Autocomplete.Status | Renders a <div role="status"> announcing how many items are left. | |
| Autocomplete.Content | true | Renders the teleport, the floating box, the popup and the <div role="listbox">. |
| Autocomplete.Collection | Renders nothing. Walks the filtered items through a scoped slot. | |
| Autocomplete.Item | true | Renders a <div role="option">. |
| Autocomplete.Group | Renders a <div role="group"> holding one group’s items. | |
| Autocomplete.GroupLabel | Renders a <div> and names the group. | |
| Autocomplete.Separator | Renders a <div role="separator">. | |
| Autocomplete.Row | Renders a <div role="row"> for a grid layout. | |
| Autocomplete.Empty | Renders a <div> while the popup is open and no item is registered. |
<script setup lang="ts">
import { Autocomplete, Field, Label } from '@maas/mirror/vue'
</script>
<template>
<Field.Root :options="{ name: 'font' }">
<Label>Font</Label>
<Autocomplete.Root v-model="font" :items="fonts">
<Autocomplete.Input placeholder=" " />
<Autocomplete.Clear />
<Autocomplete.Trigger>
<Autocomplete.Icon />
</Autocomplete.Trigger>
<Autocomplete.Status v-slot="{ count }">{{ count }} fonts</Autocomplete.Status>
<Autocomplete.Content>
<Autocomplete.Collection v-slot="{ item, value }">
<Autocomplete.Item :value="value">{{ item }}</Autocomplete.Item>
</Autocomplete.Collection>
<Autocomplete.Empty>No matching fonts.</Autocomplete.Empty>
</Autocomplete.Content>
</Autocomplete.Root>
</Field.Root>
</template>
Examples
The four modes
options.mode decides two things at once: whether the list narrows as the user
types, and whether the highlighted item is written back into the input.
<template>
<Autocomplete.Root v-model="font" :items="fonts" :options="{ mode: 'both' }" />
</template>
Under both and inline the highlighted item’s label goes into the field while
the query stays what the user typed, and the part they did not type is left
selected, so the next keystroke replaces it. The input takes data-completing
for as long as that lasts, and moving the highlight away or pressing Escape puts
the typed text back.
.input[data-completing='true'] {
color: var(--app-color-primary-fg-muted);
}
Filtering
The default filter keeps every item whose text contains the query, ignoring case and accents, so “jose” finds “José”. Replace it with your own to match differently.
<template>
<Autocomplete.Root
:items="fonts"
:options="{
filter: (item, query, toStringValue) =>
toStringValue(item).toLowerCase().startsWith(query.toLowerCase()),
}"
/>
</template>
Set filter: null when the items arrive already narrowed, from a server for
instance, and the root renders them untouched. limit caps how many items make
it through, so a long list never paints in full.
Items can be anything, not just strings. itemToStringValue is how the filter,
the default label and Autocomplete.Collection read one.
<template>
<Autocomplete.Root
:items="fonts"
:options="{ itemToStringValue: (item) => item.family }"
/>
</template>
Grouping
A grouped source is an array of objects that each carry their own items. The
outer Autocomplete.Collection walks the groups, the inner one walks the items
of the group it sits in, and a group with nothing left after filtering is
dropped rather than rendered as a bare heading.
<script setup lang="ts">
const fonts = [
{ value: 'Sans', items: ['Alte Haas Grotesk', 'Basis Grotesque'] },
{ value: 'Serif', items: ['Bely Display', 'Cardinal Fruit'] },
]
</script>
<template>
<Autocomplete.Content>
<Autocomplete.Collection v-slot="{ item: group, index }">
<Autocomplete.Separator v-if="index > 0" />
<Autocomplete.Group :items="group.items">
<Autocomplete.GroupLabel>{{ group.value }}</Autocomplete.GroupLabel>
<Autocomplete.Collection v-slot="{ item, value }">
<Autocomplete.Item :value="value">{{ item }}</Autocomplete.Item>
</Autocomplete.Collection>
</Autocomplete.Group>
</Autocomplete.Collection>
</Autocomplete.Content>
</template>
Telling the user what happened
Autocomplete.Status is a polite live region with the registered item count in
its slot. Write the sentence yourself, because only you know the noun. Nothing
is registered while the popup is closed, so read open as well or a field
nobody has touched reads as a field with no matches.
<template>
<Autocomplete.Status v-slot="{ count, empty, open }">
<template v-if="open">
{{ empty ? 'No matching fonts' : `${count} fonts` }}
</template>
<template v-else>Start typing to search</template>
</Autocomplete.Status>
</template>
Autocomplete.Empty covers the same state visually, inside the popup, and reads
the registered items rather than your array, which keeps it right once the items
are grouped or virtualised.
When the popup opens
Typing always opens it. Beyond that there are two switches.
<template>
<Autocomplete.Root
:options="{ openOnInputClick: true, autoHighlight: true }"
/>
</template>
openOnInputClick opens the popup when the user clicks into a field that
already has text, and autoHighlight seeds the first enabled item so Enter
always has something to commit. Both are off by default, because a seeded
highlight lets Enter commit an item the user never chose.
Inside a field
Autocomplete.Input is a field control, and a native one, so the form reads it
directly and no hidden input is needed.
<template>
<Field.Root :options="{ name: 'font' }">
<Label>Font</Label>
<Autocomplete.Root v-model="font" :items="fonts">
<Autocomplete.Input />
</Autocomplete.Root>
<Field.Error />
</Field.Root>
</template>
The field’s disabled, readOnly, required and name reach the input unless
the root or the input overrides them, and the validity comes back out as
data-valid and data-invalid.
Positioning
Autocomplete.Content positions itself against the input, 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.
<template>
<Autocomplete.Root
:options="{
floating: { side: 'top', align: 'center', sideOffset: 8, flip: false },
}"
/>
</template>
Wrap the control in an Autocomplete.InputGroup and the popup anchors on the
group instead, which is what it needs when the frame the user sees is wider
than the input inside it. Pass anchor to position against something else
again, and pass any of the eight floating keys straight to the content to
override the options for one instance.
<template>
<Autocomplete.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, so the popup is already as wide as the anchor and never
taller than the space around it.
.popup {
min-width: var(--mirror-autocomplete-popup-min-width);
max-height: var(--mirror-autocomplete-popup-max-height);
}
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>
<Autocomplete.Root :options="{ portal: { to: '#overlays' } }" />
<Autocomplete.Root :options="{ portal: { disabled: true } }" />
</template>
options.backdrop adds a fixed <div role="presentation"> behind the popup,
for the modal case. It has no dismiss behaviour of its own: the popup’s layer
already closes on a pointer down outside.
<template>
<Autocomplete.Root :options="{ modal: true, backdrop: true }" />
</template>
Reaching the autocomplete from anywhere
Give the root an id and useMirrorAutocomplete(id) opens, clears and reads
that autocomplete from anywhere in the app.
<script setup lang="ts">
import { useMirrorAutocomplete } from '@maas/mirror/vue'
import { AutocompleteId } from '~/constants/autocompleteIds'
const { isOpen, open, clear } = useMirrorAutocomplete(AutocompleteId.Font)
</script>
<template>
<Button :aria-expanded="isOpen" @click="open()">Search fonts</Button>
<Button @click="clear()">Reset</Button>
</template>
The state is created by whichever side asks for it first, so the toolbar may mount before the autocomplete does.
Styling from state
Every part writes its state to data-*, so the styling is a matter of
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);
}
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
Autocomplete.Content is applied to the popup, the box you paint.
<template>
<Autocomplete.Content class="popup" />
</template>
.mirror-autocomplete-floating {
--mirror-autocomplete-floating-z-index: 80;
}
.popup[data-list-empty='true'] {
min-height: 0;
}
[data-scope='list'][data-grid='true'] {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
API reference
Module. A bundled options object on the root, and
useMirrorAutocomplete(id) as the programmatic API. Every part takes id to
resolve an autocomplete it is not nested inside.
Autocomplete.Root
Renders its children. It owns the text, the source items and the filtering, and renders no element of its own.
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.
Options
Emits
Slot props
Autocomplete.Input
Renders an <input role="combobox" type="text" autocomplete="off">, the focused
element for the whole interaction. It is a void element, so there is no default
slot. aria-autocomplete follows options.mode.
Props
Data attributes
The field state set, plus the autocomplete-only attributes below.
Autocomplete.InputGroup
Optional. Renders a <div> around the input and whatever sits beside it,
republishing the popup state so the whole control can be styled from one
element. It also becomes the anchor Autocomplete.Content measures.
Slot props
Autocomplete.Trigger
Renders a <button type="button" tabindex="-1" aria-hidden="true"> that toggles
the popup and hands focus straight back to the input.
Props
Slot props
Autocomplete.Clear
Renders a <button type="button" tabindex="-1"> that empties the text and
returns focus to the input. It is not rendered while there is nothing to clear,
and refuses to act while the autocomplete is disabled or read-only.
One clear button per autocomplete, so options.forceMount.clear is the only
home for keeping it mounted with nothing to clear.
Autocomplete.Value
Renders a <span> holding the current text. The default slot content is that
text, so an empty part is already useful.
Slot props
Autocomplete.Icon
Renders a <span aria-hidden="true"> carrying data-popup-open, for the
chevron that turns.
Slot props
Autocomplete.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 Autocomplete.InputGroup when there is one and on
Autocomplete.Input otherwise.
Layers
None of the four 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.
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.
Autocomplete.Collection
Renders nothing of its own and walks the filtered items through its scoped slot.
Inside an Autocomplete.Group it walks that group’s items instead, which is how
one component covers both levels of a grouped source.
Slot props
Autocomplete.Item
Renders a <div role="option">. Items register in document order, so grouping,
rows and portals cannot scramble the arrow-key sequence.
Props
Slot props
Autocomplete.Group
Renders a <div role="group"> labelled by its Autocomplete.GroupLabel, and
holds the items a nested Autocomplete.Collection walks.
Props
Slot props
Autocomplete.GroupLabel
Renders a <div> and names the group it sits in. Throws
missing_autocomplete_group outside one.
Autocomplete.Separator
Renders a <div role="separator">.
Props
Autocomplete.Row
Renders a <div role="row">, for items laid out in a grid under
options.grid.
Props
Autocomplete.Status
Renders a <div role="status" aria-live="polite" aria-atomic="true"> holding
whatever sentence you write from the item count.
Slot props
Autocomplete.Empty
Renders a <div role="presentation"> while the popup is open and no
Autocomplete.Item is registered.
One empty state per autocomplete, so options.forceMount.empty is the only
home for keeping it mounted regardless of the open state and the item count.
Slot props
Composable
useMirrorAutocomplete(id) reaches an autocomplete from anywhere in the app.
Data attributes
CSS variables
Errors
Accessibility
The input takes role="combobox", aria-autocomplete, aria-expanded,
aria-controls and aria-activedescendant, and focus never leaves it, so every
key below is handled there and all of them are ignored while the autocomplete is
disabled. aria-controls is only present while the list is, so it never points
at an element that has been unmounted.
Typing goes into the input rather than into a typeahead search, which is why
there is no typeahead option here. The trigger, the clear button and the items
all suppress pointerdown, so clicking any of them never pulls focus out of the
input.