Skip to content

Chip

A row of compact, removable entries that shares one value and one tab stop.

View source View as Markdown

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.

Glasgow
Bath
Nuremberg
Munich
App.vue
<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.Group when the entries can be dropped and the set is the value. When the entries are only a display of a selection made somewhere else, use Combobox.Chips instead: 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 ScrollArea or 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.

NameRequiredDescription
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.RemoveRenders 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.

RVRobin Vey
CJCarla Jung
App.vue
<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

PropTypeDefault
id
stringgenerated
modelValue
Array<ChipValueType>undefined
defaultValue
Array<ChipValueType>[]
options
ChipOptionssee below

Options

OptionTypeDefault
announce
booleantrue
removedLabel
(label: string) => string${label} removed
itemToStringLabel
(value: ChipValueType) => stringdefaultItemToStringLabel
itemToStringValue
(value: ChipValueType) => stringdefaultItemToStringValue
isItemEqualToValue
(item: ChipValueType, value: ChipValueType) => booleanObject.is
name
stringinherited from Field
form
stringundefined
orientation
'horizontal' | 'vertical' | 'both''horizontal'
dir
'ltr' | 'rtl'inherited
loop
booleantrue
disabled
booleaninherited from Field
readOnly
booleaninherited from Field
required
booleaninherited from Field

Emits

EmitPayload
update:modelValue
Array<ChipValueType>

Slot props

The field state set, plus the three below.

PropType
entries
Array<ChipEntry>
value
Array<ChipValueType>
empty
boolean

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

PropTypeDefault
id
stringgenerated
value
ChipValueTypeundefined
label
stringderived
disabled
booleanfalse

Emits

EmitPayload
remove
ChipValueType | undefined

Slot props

PropType
value
ChipValueType | undefined
label
string
index
number | undefined
disabled
boolean
focused
boolean

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

PropTypeDefault
disabled
booleaninherited from the chip

Slot props

PropType
label
string
disabled
boolean

Composable

useMirrorChip(id) reaches a group from anywhere in the app.

KeyType
value
ComputedRef<Array<ChipValueType>>
entries
ComputedRef<Array<ChipEntry>>
disabled
ComputedRef<boolean>
readOnly
ComputedRef<boolean>
add
(value: ChipValueType) => void
remove
(value: ChipValueType) => void
clear
() => void

Data attributes

Chip.Group writes the field state set.

PartAttributeValue
all
data-disabled
true
Chip.Group
data-readonly
true
Chip.Group
data-required
true
Chip.Group
data-valid
true
Chip.Group
data-invalid
true
Chip.Group
data-dirty
true
Chip.Group
data-touched
true
Chip.Group
data-filled
true
Chip.Group, Chip.Root
data-focused
true
Chip.Group, Chip.Root
data-orientation
horizontal | vertical | both
Chip.Group
data-empty
true
Chip.Root
data-index
true
Chip.Root
data-standalone
true

CSS variables

PartVariableDefault
Chip.Root
--mirror-chip-cursor
default
Chip.Root
--mirror-chip-disabled-cursor
not-allowed
Chip.Remove
--mirror-chip-remove-cursor
pointer
Chip.Remove
--mirror-chip-remove-disabled-cursor
not-allowed

Errors

Code
missing_chip_value
duplicate_chip_value
missing_context

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.

KeyBehaviour
TabEnters the row at the chip that last held focus, else the first enabled one. A second press leaves the row.
ArrowRight / ArrowLeftMoves to the next or previous enabled chip. Swapped under dir="rtl", and inert while orientation is 'vertical'.
ArrowDown / ArrowUpThe same, while orientation is 'vertical' or 'both'.
Home / EndMoves to the first or last enabled chip, in either orientation.
Backspace / DeleteRemoves the focused chip and moves focus to the next one, or the previous one when there is none, or the row itself when nothing is left.

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.