Tag Group
A focusable list of tags with support for selection and removal
Usage
<script setup lang="ts">
import { TagGroup } from '@hareui/vue'
</script><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 key | data-slot | Element |
|---|---|---|
base | tag-group | <div> root |
list | tag-group-list | <div role="grid"> |
tag (tag theme) | tag | <div role="row">, one per item |
removeButton (tag theme) | tag-remove-button | CloseButton (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 stringExamples
Sizes
<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
<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
<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.
<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.
<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
<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.
<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
<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.
Selected:
<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.
<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 key | data-slot | Element |
|---|---|---|
base | empty-state | <div> root |
| Prop | Type | Default | Description |
|---|---|---|---|
ui | ComponentSlots<{ slots: { base: string; }; }> | - | Per-slot class overrides. |
Customization
Tailwind CSS
<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 messagelist→[data-slot="tag-group-list"]– wrapping flex layout holding the tagstag(from thetagtheme) →[data-slot="tag"]– one tagremoveButton(from thetagtheme) →[data-slot="tag-remove-button"]– the remove button
Interactive States
- Selected:
aria-selected="true"/[data-selected="true"]on a tag (only meaningful whenselection-modeisn'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 whenitemsis empty
API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | TagGroupItem[] | [] | The tags to render. |
selectionMode | "multiple" | "single" | "none" | "none" | The type of selection allowed. |
defaultSelected | string[] | [] | The ids selected initially when uncontrolled. |
disabledKeys | string[] | - | Ids of tags that are disabled, in addition to each item's own disabled. |
removable | boolean | false | Whether 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. |
label | string | - | The group label. |
description | string | - | Help text rendered below the tags. |
errorMessage | string | - | Error message rendered below the description. Rendered whenever this is given (even '') or
#errorMessage is, matching HeroUI's always-mounted <ErrorMessage>. |
disabled | boolean | false | Whether the whole group is disabled. |
aria-label | string | - | Accessible name of the tag list, required when label / #label is omitted. |
id | string | - | 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. |
selected | string[] | - | The selected tag ids. Bind with v-model:selected. |
Slots
| Slot | Props | Description |
|---|---|---|
label | any | The group label content. Replaces the label prop. |
description | any | The group description content. Replaces the description prop. |
errorMessage | any | The 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. |
empty | any | Content rendered inside the list when items is empty (HeroUI's renderEmptyState), usually an
<EmptyState>. Nothing is rendered when omitted. |
Emits
| Event | Payload | Description |
|---|---|---|
remove | [ids: string[]] | - |
update:selected | [value: string[] | undefined] | - |
Accessibility
- The list has
role="grid"; each tag hasrole="row"wrapping arole="gridcell", matching React Aria'sTagGroup aria-multiselectableis set on the list whenselection-modeis'multiple'labelnames the list througharia-labelledby; without one, passaria-labeldescriptionanderror-messageare both referenced by the list'saria-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





