Chip
A row of compact, removable entries that shares one value and one tab stop.
Chip is a row of compact entries the user can drop one by one: the tags on a post, the filters on a search, the people on an invitation. The row is one tab stop, the arrow keys move inside it, and Backspace removes whatever the user is standing on.
<template>
<div class="flex flex-col items-start gap-4">
<Chip.Group v-model="cities" aria-label="Cities" :class="group">
<template #default="{ entries, empty }">
<Chip.Root
v-for="entry in entries"
:key="entry.key"
:class="chip"
:value="entry.value"
>
<svg class="size-4 shrink-0" viewBox="0 0 16 16" aria-hidden="true">
<path
d="M8 14s4.5-4.2 4.5-7.5a4.5 4.5 0 1 0-9 0C3.5 9.8 8 14 8 14Z"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linejoin="round"
/>
<circle cx="8" cy="6.5" r="1.5" fill="currentColor" />
</svg>
<span class="truncate">{{ entry.label }}</span>
<Chip.Remove :class="remove">
<svg class="size-4" 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>
</Chip.Remove>
</Chip.Root>
<span v-if="empty" class="type-component-sm text-surface-muted">
Nothing left to remove.
</span>
</template>
</Chip.Group>
<Button :class="reset" @click="cities = [...defaults]">Reset</Button>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Button, Chip } from '@maas/mirror/vue'
const defaults = ['Glasgow', 'Bath', 'Nuremberg', 'Munich']
const cities = ref([...defaults])
const group = 'flex min-h-8 flex-wrap items-center gap-1.5'
const chip = [
'inline-flex h-8 max-w-full shrink-0 items-center gap-1.5 px-1.5',
'rounded-component-sm border-2 border-[transparent] type-component-md',
'bg-primary-subtle text-primary-on-subtle',
'transition-all duration-100 ease-linear',
'outline-4 outline-transparent focus-visible:focus-ring',
'hover:bg-primary-subtle-hover active:bg-primary-subtle-active',
'data-[disabled=true]:bg-disabled-subtle data-[disabled=true]:text-disabled-on-subtle',
].join(' ')
const remove = [
'flex size-6 shrink-0 items-center justify-center -mr-1',
'rounded-component-compact-sm border-2 border-[transparent]',
'transition-all duration-100 ease-linear',
'outline-4 outline-transparent focus-visible:focus-ring',
'hover:opacity-70 active:opacity-50',
].join(' ')
const reset = [
'inline-flex h-9 items-center justify-center px-3.5',
'rounded-component-md border-2 border-[transparent] type-component-sm',
'bg-primary-solid text-primary-on-solid',
'transition-all duration-100 ease-linear',
'outline-4 outline-transparent focus-visible:focus-ring',
'hover:bg-primary-solid-hover active:bg-primary-solid-active',
].join(' ')
</script>
Usage guidelines
- Only one kind of chip needs a component of its own: the removable collection. The other kinds are compositions of components that already exist. The Chip types section below has all four.
- Reach for
Chip.Groupwhen the entries can be dropped and the set is the value. When the entries are only a display of a selection made somewhere else, useCombobox.Chipsinstead: it reads the combobox’s own value and hands focus back to the input. - The row is one tab stop, so keep it short enough to walk through with the
arrow keys. Give a long collection a
ScrollAreaor a search instead. - The remove buttons sit outside the tab order: Backspace on a focused chip is the keyboard route, and a second stop per chip would double the length of the row for anyone tabbing through the page.
- Chips ship unstyled. The preview above and the recipes below use the same utility classes as the rest of the documentation; there is no chip-specific token.
Anatomy
Assemble the group, one Chip.Root per entry the group hands out.
| Name | Required | Description |
|---|---|---|
| Chip.Group | true | Renders a div with role="group", plus one hidden input per value. |
| Chip.Root | true | Renders a div carrying one value. One per entry. |
| Chip.Remove | Renders a button that drops the chip it sits in. |
<script setup lang="ts">
import { ref } from 'vue'
import { Chip, Field, Label } from '@maas/mirror/vue'
const tags = ref(['design', 'engineering'])
</script>
<template>
<Field.Root :options="{ name: 'tags' }">
<Label :native-label="false">Tags</Label>
<Chip.Group v-model="tags" v-slot="{ entries }">
<Chip.Root v-for="entry in entries" :key="entry.key" :value="entry.value">
{{ entry.label }}
<Chip.Remove />
</Chip.Root>
</Chip.Group>
</Field.Root>
</template>
Chip.Group owns the array and hands it back as entries, one entry per value,
each carrying the key a v-for needs and the label the chip shows. Loop
over entries so the markup follows the value as chips are removed.
Chip types
Chips come in four kinds. Only one of them is a component here; the other three are compositions of existing components, styled with the same classes.
<template>
<div class="flex flex-col items-start gap-6">
<div class="flex flex-wrap items-center gap-1.5">
<Button
v-for="action in actions"
:key="action"
:class="[chip, outline]"
>
{{ action }}
</Button>
</div>
<ToggleGroup
v-model="filters"
aria-label="Filters"
class="flex flex-wrap items-center gap-1.5"
:options="{ multiple: true }"
>
<Toggle
v-for="filter in available"
:key="filter"
:class="[chip, translucent]"
:value="filter"
>
{{ filter }}
</Toggle>
</ToggleGroup>
<Chip.Group v-model="people" aria-label="People" :class="group">
<template #default="{ entries }">
<Chip.Root
v-for="entry in entries"
:key="entry.key"
:class="[chip, tone]"
:value="entry.value"
>
<Avatar.Root :class="avatar">
<Avatar.Fallback class="font-medium tracking-[0.02em]">
{{ initials(entry.label) }}
</Avatar.Fallback>
</Avatar.Root>
<span class="truncate">{{ entry.label }}</span>
<Chip.Remove :class="remove">
<svg class="size-4" 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>
</Chip.Remove>
</Chip.Root>
</template>
</Chip.Group>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Avatar, Button, Chip, Toggle, ToggleGroup } from '@maas/mirror/vue'
const actions = ['Add to calendar', 'Share']
const available = ['Draft', 'Scheduled', 'Published']
const filters = ref(['Draft'])
const people = ref(['Robin Vey', 'Carla Jung'])
function initials(name: string) {
return name
.split(' ')
.map((part) => part.charAt(0))
.join('')
}
const group = 'flex min-h-8 flex-wrap items-center gap-1.5'
const chip = [
'inline-flex h-8 max-w-full shrink-0 items-center gap-1.5 px-1.5',
'rounded-component-sm border-2 type-component-md',
'transition-all duration-100 ease-linear',
'outline-4 outline-transparent focus-visible:focus-ring',
].join(' ')
const outline = [
'border-primary-subtle text-primary-on-subtle',
'hover:border-primary-subtle-hover active:border-primary-subtle-active',
].join(' ')
const translucent = [
'border-[transparent] bg-primary-subtle text-primary-on-subtle',
'hover:bg-primary-subtle-hover active:bg-primary-subtle-active',
'data-[state=on]:bg-primary-solid data-[state=on]:text-primary-on-solid',
'data-[state=on]:hover:bg-primary-solid-hover',
'data-[state=on]:active:bg-primary-solid-active',
].join(' ')
const tone = [
'border-[transparent] bg-primary-muted text-primary-on-muted',
'hover:bg-primary-muted-hover active:bg-primary-muted-active',
].join(' ')
const remove = [
'flex size-6 shrink-0 items-center justify-center -mr-1',
'rounded-component-compact-sm border-2 border-[transparent]',
'transition-all duration-100 ease-linear',
'outline-4 outline-transparent focus-visible:focus-ring',
'hover:opacity-70 active:opacity-50',
].join(' ')
const avatar = [
'flex size-6 shrink-0 items-center justify-center overflow-hidden',
'rounded-component-round type-component-3xs bg-primary-solid text-primary-on-solid',
].join(' ')
</script>
Assist and suggestion chips
A chip that runs an action or fills something in is a
Button with chip classes. It has no state to hold and
nothing to remove, so a component of its own would add a wrapper and take away
as, asChild and the loading state.
<template>
<Button class="chip" @click="addToCalendar">Add to calendar</Button>
</template>
Filter chips
A chip that holds an on state is a Toggle, and a row of
them under one value is a ToggleGroup. Paint it
from data-state, which is on or off.
<template>
<ToggleGroup v-model="filters" :options="{ multiple: true }">
<Toggle v-for="filter in available" :key="filter" :value="filter">
{{ filter }}
</Toggle>
</ToggleGroup>
</template>
Chip.Root takes asChild, so a filter chip that also needs the chip data
attributes can wrap a Toggle instead of copying its classes.
<template>
<Chip.Root as-child label="Draft">
<Toggle v-model="draft" value="draft">Draft</Toggle>
</Chip.Root>
</template>
Input chips
A chip standing for something the user picked from a list belongs to the
control that owns the list. Combobox ships that anatomy already, and its
chips hand focus back to the input the moment one is dropped, behaviour that
belongs to the combobox rather than to a standalone group.
See Combobox.Chips.
Avatar chips
An avatar chip is an ordinary Chip.Root with an
Avatar at the front of its slot. The chip renders
whatever the slot holds, so a whole component fits where an icon would.
Examples
A chip on its own
A single dismissible chip needs no collection. Leave the group out and
Chip.Root publishes data-standalone, its remove button joins the tab order,
and pressing it emits remove with the chip’s value rather than dropping
anything.
<template>
<Chip.Root :value="notice.id" :label="notice.title" @remove="dismiss">
{{ notice.title }}
<Chip.Remove />
</Chip.Root>
</template>
Orientation and direction
orientation decides which arrow keys move between chips. It is
'horizontal' by default, which is how a wrapping row reads; set 'vertical'
for a stacked list, or 'both' when the row wraps far enough that either pair
should work. dir swaps ArrowLeft and ArrowRight; unset, it follows a
DirectionProvider or the surrounding dir attribute, as described in
Composition.
<template>
<Chip.Group v-model="tags" :options="{ orientation: 'both', dir: 'rtl' }" />
</template>
Values that are not strings
Chip.Group holds whatever the consumer holds. Three options tell it how to
read an entry: itemToStringLabel for the text, itemToStringValue for the
key and the submitted form value, and isItemEqualToValue for the comparison a
removal runs.
<script setup lang="ts">
const people = ref([
{ id: 1, name: 'Robin Vey' },
{ id: 2, name: 'Carla Jung' },
])
const options = {
itemToStringLabel: (entry) => entry.name,
itemToStringValue: (entry) => String(entry.id),
isItemEqualToValue: (a, b) => a.id === b.id,
}
</script>
<template>
<Chip.Group v-model="people" :options="options" />
</template>
Disabling
A chip is disabled when either the group or the chip says so. A disabled chip
is skipped by the arrow keys and refuses Backspace, and its remove button
carries the native disabled attribute.
<template>
<Chip.Group v-model="tags" v-slot="{ entries }">
<Chip.Root
v-for="entry in entries"
:key="entry.key"
:disabled="entry.key === 'locked'"
:value="entry.value"
>
{{ entry.label }}
<Chip.Remove />
</Chip.Root>
</Chip.Group>
</template>
readOnly blocks every removal while keeping the whole row focusable, so the
values can still be read and copied.
Inside a form
Chip.Group renders one visually hidden input per value, all sharing the
group’s name, so the form sees it. The
submitted value is the string form of the entry, which is what
itemToStringValue returns.
<template>
<form @submit.prevent="submit">
<Chip.Group v-model="tags" :options="{ name: 'tags', required: true }" />
</form>
</template>
Without a name, from either the group or the field, nothing is submitted at
all. A group rendered outside the <form> it belongs to points at it by id
through form, which reaches every hidden input.
Styling from state
Paint the chip from data-focused and data-disabled, and the row from
data-empty.
<template>
<Chip.Group v-model="tags" class="chips">
<Chip.Root value="design" class="chip">
design
<Chip.Remove class="chip-remove" />
</Chip.Root>
</Chip.Group>
</template>
<style>
.chips[data-empty='true'] {
display: none;
}
.chip[data-disabled='true'] {
opacity: 0.5;
}
</style>
API reference
Module. A bundled options object, and useMirrorChip(id) as the
programmatic API.
Chip.Group
Renders a <div role="group"> plus one visually hidden <input type="hidden">
per value, and a polite live region that announces removals.
Props
Options
Emits
Slot props
The field state set, plus the three below.
Chip.Root
Renders a <div>. Inside a group it is a roving-focus stop and carries a
required value; on its own it is a lone chip that emits rather than removing.
Props
Emits
Slot props
Chip.Remove
Renders a <button type="button"> that drops the chip it sits in. Inside a
group it carries tabindex="-1" and the group keeps the tab stop; on a lone
chip it is the tab stop. Throws missing_context outside a Chip.Root.
aria-label defaults to Remove {label}, or Remove when the chip has no
label. Pass your own to override it.
Props
Slot props
Composable
useMirrorChip(id) reaches a group from anywhere in the app.
Data attributes
Chip.Group writes the
field state set.
CSS variables
Errors
Accessibility
The row is role="group" and each chip is a focusable element inside it, so
the whole row is one tab stop and the arrow keys move within it. A removal is
announced through a polite live region, so a screen-reader user hears that
the focused chip is gone.
Disabled chips are skipped throughout, and the ends wrap while loop is on and
stop while it is off. The remove buttons are never reached by Tab inside a
group, so a pointer user clicks them and a keyboard user presses Backspace.