Tag Group

A focusable list of tags with support for selection and removal

Usage

<script setup lang="ts">
import { TagGroup } from '@hareui/vue'
</script>
News
Travel
Gaming
Shopping
<script setup lang="ts">import { Icon } from '@iconify/vue'import type { TagGroupItem } from '@hareui/vue'import { TagGroup } from '@hareui/vue'const items: TagGroupItem[] = [  { id: 'default-news', label: 'News' },  { id: 'default-travel', label: 'Travel' },  { id: 'default-gaming', label: 'Gaming' },  { id: 'default-shopping', label: 'Shopping' },]const icons: Record<string, string> = {  'default-news': 'gravity-ui:square-article',  'default-travel': 'gravity-ui:planet-earth',  'default-gaming': 'gravity-ui:rocket',  'default-shopping': 'gravity-ui:shopping-bag',}</script><template>  <TagGroup aria-label="Tags" selection-mode="single" :items="items">    <template #item="{ item }">      <Icon :icon="icons[item.id]!" />      {{ item.label }}    </template>  </TagGroup></template>

Anatomy

TagGroup renders an optional Label, a grid of tags, and an optional Description / error message below it. Each tag is a role="row" wrapping a role="gridcell" with the tag content and, when removable, a remove button.

<template>
  <TagGroup label="…" :items="items">
    <template #label />                                             <!-- Label content -->
    <template #description />                                       <!-- Description content -->
    <template #errorMessage />                                      <!-- Error message content -->
    <template #item="{ item, index, selected, disabled }" />        <!-- Content of each tag (default: item.label) -->
    <template #empty />                                              <!-- Rendered inside the list when `items` is empty (usually <EmptyState>) -->
  </TagGroup>
</template>
ui keydata-slotElement
basetag-group<div> root
listtag-group-list<div role="grid">
tag (tag theme)tag<div role="row">, one per item
removeButton (tag theme)tag-remove-buttonCloseButton (iff removable)

Typing items

items takes the exported TagGroupItem type. Extra fields are kept: extend the type, and the slots receive your type, so no casts are needed.

import type { TagGroupItem } from '@hareui/vue'

interface MyItem extends TagGroupItem {
  icon: string
}

const items: MyItem[] = [/* ... */]
// <template #item="{ item }"> — item.icon is a string

Examples

Sizes

Small
News
Travel
Gaming
Medium
News
Travel
Gaming
Large
News
Travel
Gaming
<script setup lang="ts">import type { TagGroupItem } from '@hareui/vue'import { TagGroup } from '@hareui/vue'const items: TagGroupItem[] = [  { id: 'news', label: 'News' },  { id: 'travel', label: 'Travel' },  { id: 'gaming', label: 'Gaming' },]</script><template>  <div class="flex flex-col gap-6">    <TagGroup selection-mode="single" size="sm" label="Small" :items="items" />    <TagGroup selection-mode="single" size="md" label="Medium" :items="items" />    <TagGroup selection-mode="single" size="lg" label="Large" :items="items" />  </div></template>

Variants

Default
News
Travel
Gaming
Surface
News
Travel
Gaming
<script setup lang="ts">import type { TagGroupItem } from '@hareui/vue'import { TagGroup } from '@hareui/vue'const items: TagGroupItem[] = [  { id: 'news', label: 'News' },  { id: 'travel', label: 'Travel' },  { id: 'gaming', label: 'Gaming' },]</script><template>  <div class="flex flex-col gap-8">    <TagGroup selection-mode="single" variant="default" label="Default" :items="items" />    <TagGroup selection-mode="single" variant="surface" label="Surface" :items="items" />  </div></template>

With Icons & Avatars

With Icons
News
Travel
Gaming
Shopping
Tags with icons
With Avatars
F Fred
M Michael
J Jane
Tags with avatars
<script setup lang="ts">import { Icon } from '@iconify/vue'import type { TagGroupItem } from '@hareui/vue'import { Avatar, TagGroup } from '@hareui/vue'const icons: Record<string, string> = {  news: 'gravity-ui:square-article',  travel: 'gravity-ui:planet-earth',  gaming: 'gravity-ui:rocket',  shopping: 'gravity-ui:shopping-bag',}const iconItems: TagGroupItem[] = [  { id: 'news', label: 'News' },  { id: 'travel', label: 'Travel' },  { id: 'gaming', label: 'Gaming' },  { id: 'shopping', label: 'Shopping' },]interface Person extends TagGroupItem {  avatar: string  fallback: string}const people: Person[] = [  { id: 'fred', label: 'Fred', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/blue.jpg', fallback: 'F' },  { id: 'michael', label: 'Michael', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/green.jpg', fallback: 'M' },  { id: 'jane', label: 'Jane', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/purple.jpg', fallback: 'J' },]</script><template>  <div class="flex flex-col gap-8">    <TagGroup selection-mode="single" label="With Icons" description="Tags with icons" :items="iconItems">      <template #item="{ item }">        <Icon :icon="icons[item.id]!" />        {{ item.label }}      </template>    </TagGroup>    <TagGroup selection-mode="single" label="With Avatars" description="Tags with avatars" :items="people">      <template #item="{ item }">        <Avatar class="size-4" :src="item.avatar" :fallback="item.fallback" />        {{ item.label }}      </template>    </TagGroup>  </div></template>

Disabled

Tags can be disabled individually (items[].disabled), or by id via disabled-keys.

Disabled Tags
News
Travel
Gaming
Some tags are disabled
Disabled Keys
News
Travel
Gaming
Tags disabled via disabledKeys prop
<script setup lang="ts">import type { TagGroupItem } from '@hareui/vue'import { TagGroup } from '@hareui/vue'const ownDisabled: TagGroupItem[] = [  { id: 'news', label: 'News', disabled: true },  { id: 'travel', label: 'Travel' },  { id: 'gaming', label: 'Gaming', disabled: true },]const keyedItems: TagGroupItem[] = [  { id: 'news', label: 'News' },  { id: 'travel', label: 'Travel' },  { id: 'gaming', label: 'Gaming' },]</script><template>  <div class="flex flex-col gap-4">    <TagGroup selection-mode="single" label="Disabled Tags" description="Some tags are disabled" :items="ownDisabled" />    <TagGroup selection-mode="single" label="Disabled Keys" description="Tags disabled via disabledKeys prop" :items="keyedItems" :disabled-keys="['travel']" />  </div></template>

Selection Modes

selection-mode is 'none' (default), 'single' or 'multiple'. Bind the selected ids with v-model:selected.

Single Selection
News
Travel
Gaming
Shopping
Choose one category
Multiple Selection
News
Travel
Gaming
Shopping
Choose multiple categories
<script setup lang="ts">import type { TagGroupItem } from '@hareui/vue'import { TagGroup } from '@hareui/vue'import { ref } from 'vue'const items: TagGroupItem[] = [  { id: 'news', label: 'News' },  { id: 'travel', label: 'Travel' },  { id: 'gaming', label: 'Gaming' },  { id: 'shopping', label: 'Shopping' },]const single = ref<string[]>(['news'])const multiple = ref<string[]>(['news', 'travel'])</script><template>  <div class="flex flex-col gap-8">    <TagGroup v-model:selected="single" selection-mode="single" label="Single Selection" description="Choose one category" :items="items" />    <TagGroup v-model:selected="multiple" selection-mode="multiple" label="Multiple Selection" description="Choose multiple categories" :items="items" />  </div></template>

Controlled Selection

Categories (controlled)
News
Travel
Gaming
Shopping
Selected: news, travel
<script setup lang="ts">import type { TagGroupItem } from '@hareui/vue'import { TagGroup } from '@hareui/vue'import { computed, ref } from 'vue'const items: TagGroupItem[] = [  { id: 'news', label: 'News' },  { id: 'travel', label: 'Travel' },  { id: 'gaming', label: 'Gaming' },  { id: 'shopping', label: 'Shopping' },]const selected = ref<string[]>(['news', 'travel'])const description = computed(() => `Selected: ${selected.value.length ? selected.value.join(', ') : 'None'}`)</script><template>  <div class="flex flex-col gap-3">    <TagGroup v-model:selected="selected" selection-mode="multiple" label="Categories (controlled)" :description="description" :items="items" />  </div></template>

With Remove Button

Set removable to show a remove button on every (non-disabled) tag. It emits remove with the removed id(s) — also fired by pressing Delete / Backspace while a tag is focused, in which case the whole current selection is removed if the focused tag is part of it. Use the #removeButton slot to replace the default close icon.

Default Remove Button
News
Travel
Gaming
Shopping
Click the X to remove tags
Custom Remove Button
React
Vue
Angular
Svelte
Custom remove button with icon
<script setup lang="ts">import { Icon } from '@iconify/vue'import type { TagGroupItem } from '@hareui/vue'import { EmptyState, TagGroup } from '@hareui/vue'import { ref } from 'vue'const tags = ref<TagGroupItem[]>([  { id: 'news', label: 'News' },  { id: 'travel', label: 'Travel' },  { id: 'gaming', label: 'Gaming' },  { id: 'shopping', label: 'Shopping' },])const frameworks = ref<TagGroupItem[]>([  { id: 'react', label: 'React' },  { id: 'vue', label: 'Vue' },  { id: 'angular', label: 'Angular' },  { id: 'svelte', label: 'Svelte' },])function onRemoveTags(ids: string[]) {  tags.value = tags.value.filter(tag => !ids.includes(tag.id))}function onRemoveFrameworks(ids: string[]) {  frameworks.value = frameworks.value.filter(framework => !ids.includes(framework.id))}</script><template>  <div class="flex flex-col gap-8">    <div class="w-sm">      <TagGroup selection-mode="single" removable label="Default Remove Button" description="Click the X to remove tags" :items="tags" @remove="onRemoveTags">        <template #empty>          <EmptyState class="p-1">            No categories found          </EmptyState>        </template>      </TagGroup>    </div>    <div class="w-md">      <TagGroup selection-mode="single" removable label="Custom Remove Button" description="Custom remove button with icon" :items="frameworks" @remove="onRemoveFrameworks">        <template #removeButton>          <Icon icon="gravity-ui:circle-xmark-fill" />        </template>        <template #empty>          <EmptyState class="p-1">            No frameworks found          </EmptyState>        </template>      </TagGroup>    </div>  </div></template>

With Error Message

Amenities
Laundry
Fitness center
Parking
Swimming pool
Breakfast
Select at least one categoryPlease select at least one category
<script setup lang="ts">import type { TagGroupItem } from '@hareui/vue'import { TagGroup } from '@hareui/vue'import { computed, ref } from 'vue'const items: TagGroupItem[] = [  { id: 'laundry', label: 'Laundry' },  { id: 'fitness', label: 'Fitness center' },  { id: 'parking', label: 'Parking' },  { id: 'pool', label: 'Swimming pool' },  { id: 'breakfast', label: 'Breakfast' },]const selected = ref<string[]>([])const isInvalid = computed(() => selected.value.length === 0)const description = computed(() => isInvalid.value ? 'Select at least one category' : `Selected: ${selected.value.join(', ')}`)const errorMessage = computed(() => isInvalid.value ? 'Please select at least one category' : '')</script><template>  <TagGroup v-model:selected="selected" selection-mode="multiple" label="Amenities" :description="description" :error-message="errorMessage" :items="items" /></template>

With List Data

Keep the items in a ref, remove them on @remove, and render an EmptyState in the #empty slot once the list is empty.

Team Members
F Fred
M Michael
J Jane
A Alice
B Bob
C Charlie
Select team members for your project

Selected:

FFred
MMichael
<script setup lang="ts">import type { TagGroupItem } from '@hareui/vue'import { Avatar, EmptyState, TagGroup } from '@hareui/vue'import { computed, ref } from 'vue'interface User extends TagGroupItem {  label: string  avatar: string  fallback: string}const users = ref<User[]>([  { id: 'fred', label: 'Fred', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/blue.jpg', fallback: 'F' },  { id: 'michael', label: 'Michael', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/green.jpg', fallback: 'M' },  { id: 'jane', label: 'Jane', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/purple.jpg', fallback: 'J' },  { id: 'alice', label: 'Alice', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/red.jpg', fallback: 'A' },  { id: 'bob', label: 'Bob', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/orange.jpg', fallback: 'B' },  { id: 'charlie', label: 'Charlie', avatar: 'https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/black.jpg', fallback: 'C' },])const selected = ref<string[]>(['fred', 'michael'])const selectedUsers = computed(() => selected.value  .map(id => users.value.find(user => user.id === id))  .filter((user): user is User => !!user))function onRemove(ids: string[]) {  users.value = users.value.filter(user => !ids.includes(user.id))  selected.value = selected.value.filter(id => !ids.includes(id))}</script><template>  <div class="w-sm">    <TagGroup      v-model:selected="selected"      selection-mode="multiple"      removable      label="Team Members"      description="Select team members for your project"      :items="users"      @remove="onRemove"    >      <template #item="{ item }">        <Avatar class="size-4" size="sm" :src="(item as User).avatar" :fallback="(item as User).fallback" />        {{ item.label }}      </template>      <template #empty>        <EmptyState class="p-1">          No team members        </EmptyState>      </template>    </TagGroup>    <div v-if="selectedUsers.length > 0" class="mt-4 flex flex-col gap-2">      <p class="text-sm font-medium text-muted">        Selected:      </p>      <div class="flex flex-wrap gap-2">        <div          v-for="user in selectedUsers"          :key="`${user.id}-selected`"          class="flex items-center gap-2 rounded-lg bg-surface-tertiary px-2 py-1"        >          <Avatar class="size-4" size="sm" :src="user.avatar" :fallback="user.fallback" />          <span class="text-sm">{{ user.label }}</span>        </div>      </div>    </div>  </div></template>

Render Function

Attributes on TagGroup (other than aria-label) fall through to the root element.

News
Travel
Gaming
Shopping
<script setup lang="ts">import { Icon } from '@iconify/vue'import type { TagGroupItem } from '@hareui/vue'import { TagGroup } from '@hareui/vue'const icons: Record<string, string> = {  'default-news': 'gravity-ui:square-article',  'default-travel': 'gravity-ui:planet-earth',  'default-gaming': 'gravity-ui:rocket',  'default-shopping': 'gravity-ui:shopping-bag',}const items: TagGroupItem[] = [  { id: 'default-news', label: 'News' },  { id: 'default-travel', label: 'Travel' },  { id: 'default-gaming', label: 'Gaming' },  { id: 'default-shopping', label: 'Shopping' },]</script><template>  <!-- Attributes fall through to the root element (HeroUI's `render` prop) -->  <TagGroup aria-label="Tags" data-custom="foo" selection-mode="single" :items="items">    <template #item="{ item }">      <Icon :icon="icons[item.id]!" />      {{ item.label }}    </template>  </TagGroup></template>

EmptyState

EmptyState is the small muted placeholder HeroUI renders inside empty collections (TagGroup, ListBox, Table). It renders "No results found" by default; pass your own text in the default slot. Use it in TagGroup's #empty slot.

<script setup lang="ts">
import { EmptyState } from '@hareui/vue'
</script>

<template>
  <EmptyState class="p-1">
    No team members
  </EmptyState>
</template>
ui keydata-slotElement
baseempty-state<div> root
PropTypeDefaultDescription
uiComponentSlots<{ slots: { base: string; }; }>-Per-slot class overrides.

Customization

Tailwind CSS

News
Travel
Gaming
Shopping
<script setup lang="ts">import type { TagGroupItem } from '@hareui/vue'import { TagGroup } from '@hareui/vue'const tagClass = 'rounded-full border border-neutral-300/80 bg-white/80 px-2.5 py-1 text-sm font-medium text-neutral-700 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition-colors data-[selected=true]:border-neutral-500 data-[selected=true]:bg-neutral-900 data-[selected=true]:text-neutral-50 dark:border-neutral-600/80 dark:bg-neutral-900/60 dark:text-neutral-200 dark:ring-white/10 dark:data-[selected=true]:border-neutral-400 dark:data-[selected=true]:bg-neutral-100 dark:data-[selected=true]:text-neutral-900'const items: TagGroupItem[] = [  { id: 'news', label: 'News' },  { id: 'travel', label: 'Travel' },  { id: 'gaming', label: 'Gaming' },  { id: 'shopping', label: 'Shopping' },]</script><template>  <TagGroup aria-label="Topics" selection-mode="single" :items="items" :ui="{ list: 'gap-2', tag: tagClass }" /></template>

Global Configuration

app.use(createHareUI({
  ui: { tagGroup: { slots: { list: 'gap-2' } }, tag: { slots: { base: 'rounded-full' } } },
}))

Styling Reference

Slots

  • base → [data-slot="tag-group"] – column layout wrapping the label, list, description and error message
  • list → [data-slot="tag-group-list"] – wrapping flex layout holding the tags
  • tag (from the tag theme) → [data-slot="tag"] – one tag
  • removeButton (from the tag theme) → [data-slot="tag-remove-button"] – the remove button

Interactive States

  • Selected: aria-selected="true" / [data-selected="true"] on a tag (only meaningful when selection-mode isn't 'none')
  • Disabled: aria-disabled="true" / [data-disabled="true"] on a tag; its remove button is also disabled
  • Pressed: [data-pressed="true"] on a tag
  • Focus: [data-focus-visible="true"] on a tag
  • Empty: [data-empty="true"] on the list when items is empty

API Reference

Props

PropTypeDefaultDescription
itemsTagGroupItem[][]The tags to render.
selectionMode"multiple" | "single" | "none""none"The type of selection allowed.
defaultSelectedstring[][]The ids selected initially when uncontrolled.
disabledKeysstring[]-Ids of tags that are disabled, in addition to each item's own disabled.
removablebooleanfalseWhether tags show a remove button and can be removed by pressing it, or Delete / Backspace while the tag is focused.
size"md" | "sm" | "lg"'md'Size of the tags.
variant"surface" | "default"'default'Visual style of the tags.
labelstring-The group label.
descriptionstring-Help text rendered below the tags.
errorMessagestring-Error message rendered below the description. Rendered whenever this is given (even '') or #errorMessage is, matching HeroUI's always-mounted <ErrorMessage>.
disabledbooleanfalseWhether the whole group is disabled.
aria-labelstring-Accessible name of the tag list, required when label / #label is omitted.
idstring-Base id of the group; label/description/error ids derive from it. Generated when omitted.
ui(ComponentSlots<{ slots: { base: string[]; list: string; }; }> & { tag?: any; removeButton?: any; })-Per-slot class overrides. tag and removeButton are the tag theme's slots.
selectedstring[]-The selected tag ids. Bind with v-model:selected.

Slots

SlotPropsDescription
labelanyThe group label content. Replaces the label prop.
descriptionanyThe group description content. Replaces the description prop.
errorMessageanyThe error content. Replaces the errorMessage prop. Rendered whenever either is given.
item{ item: TagGroupItem; index: number; selected: boolean; disabled: boolean; }Content of each tag. Replaces item.label.
removeButton{ item: TagGroupItem; index: number; }Content of each tag's remove button (shown when removable). Replaces the default close icon.
emptyanyContent rendered inside the list when items is empty (HeroUI's renderEmptyState), usually an <EmptyState>. Nothing is rendered when omitted.

Emits

EventPayloadDescription
remove[ids: string[]]-
update:selected[value: string[] | undefined]-

Accessibility

  • The list has role="grid"; each tag has role="row" wrapping a role="gridcell", matching React Aria's TagGroup
  • aria-multiselectable is set on the list when selection-mode is 'multiple'
  • label names the list through aria-labelledby; without one, pass aria-label
  • description and error-message are both referenced by the list's aria-describedby
  • Keyboard: Arrow keys move roving focus between tags, skipping disabled ones; Home / End jump to the first / last enabled tag; Enter / Space toggles selection; Delete / Backspace removes the focused tag (or the whole selection, if it's part of it) when removable; Escape clears the selection

On this page

No Headings