import { Tag, TagGroup } from "@/components/ui/tag-group" ;
export default function TagGroupDemo () {
return (
< TagGroup
label = "Interests"
selectionMode = "multiple"
defaultSelectedKeys = {[ "design" , "security" ]}
>
< Tag id = "design" >Design</ Tag >
< Tag id = "engineering" >Engineering</ Tag >
< Tag id = "security" >Security</ Tag >
< Tag id = "product" >Product</ Tag >
< Tag id = "data" >Data</ Tag >
</ TagGroup >
);
}
CLI Manual
$ pnpm dlx shadcn@latest add @desyne/tag-group
The CLI installs dependencies and any other components this one uses.
import { Tag, TagGroup } from "@/components/ui/tag-group" ;
< TagGroup label = "Labels" >
< Tag color = "danger" >bug</ Tag >
< Tag color = "info" >docs</ Tag >
</ TagGroup >
Tag Group — a set of short labels users scan, filter by or remove: labels on an issue, active filters, selected skills.
Badge — a single, non-interactive status or count.
Toggle Button Group — a few mutually exclusive options that behave like buttons in a toolbar.
Checkbox Group — many options in a form with their own labels and descriptions.
Combo Box with a Tag Group — picking from a long list and showing the picks as tags.
< TagGroup > { /* label, list, description, error */ }
< Tag > { /* role="row" */ }
< Icon />
Label
{ /* remove button, when onRemove is set */ }
</ Tag >
</ TagGroup >
Part Renders Notes TagGroup<div>Root with label, description and errorMessage. data-slot="tag-group". Tag list <div role="grid">Wrapping flex row of tags. Shows renderEmptyState when empty. Tag<div role="row">A tag. Plain-string children become its textValue. data-slot="tag". Remove button <button slot="remove">A small ✕, rendered automatically when the group has onRemove. Label <span>From label. Description / error <span slot="…">From description and errorMessage.
A color gives a tag the tinted soft look. Without a color, tags use the neutral outline style.
import { Tag, TagGroup } from "@/components/ui/tag-group" ;
export default function TagGroupColors () {
return (
< TagGroup aria-label = "Labels" >
< Tag color = "primary" >feature</ Tag >
< Tag color = "danger" >bug</ Tag >
< Tag color = "warning" >needs review</ Tag >
< Tag color = "success" >approved</ Tag >
< Tag color = "info" >docs</ Tag >
< Tag color = "neutral" >chore</ Tag >
</ TagGroup >
);
}
Set variant on the group to style every tag: outline (default without a color) or soft. A variant on an individual Tag overrides it.
import { Tag, TagGroup } from "@/components/ui/tag-group" ;
const labels = [ "frontend" , "accessibility" , "good first issue" , "docs" ];
export default function TagGroupVariants () {
return (
< div className = "flex flex-col gap-4" >
< TagGroup label = "Outline (default)" variant = "outline" >
{labels. map (( l ) => (
< Tag key = {l}>{l}</ Tag >
))}
</ TagGroup >
< TagGroup label = "Soft" variant = "soft" >
{labels. map (( l ) => (
< Tag key = {l}>{l}</ Tag >
))}
</ TagGroup >
</ div >
);
}
sm (20px) for dense tables and cards, md (24px) by default. Set it on the group or per tag.
import { Tag, TagGroup } from "@/components/ui/tag-group" ;
export default function TagGroupSizes () {
return (
< div className = "flex flex-col gap-4" >
< TagGroup label = "Small" size = "sm" >
< Tag color = "success" >stable</ Tag >
< Tag color = "info" >v2.4.0</ Tag >
< Tag >MIT</ Tag >
</ TagGroup >
< TagGroup label = "Medium (default)" size = "md" >
< Tag color = "success" >stable</ Tag >
< Tag color = "info" >v2.4.0</ Tag >
< Tag >MIT</ Tag >
</ TagGroup >
</ div >
);
}
Icons are sized to 12px automatically. Pass textValue when children aren't a plain string.
import {
BugIcon,
GaugeIcon,
LockIcon,
SparklesIcon,
WrenchIcon,
} from "lucide-react" ;
import { Tag, TagGroup } from "@/components/ui/tag-group" ;
export default function TagGroupIcons () {
return (
< TagGroup
label = "Change type"
selectionMode = "multiple"
defaultSelectedKeys = {[ "feature" ]}
>
< Tag id = "feature" textValue = "Feature" color = "brand" >
< SparklesIcon /> Feature
</ Tag >
< Tag id = "fix" textValue = "Bug fix" color = "danger" >
< BugIcon /> Bug fix
</ Tag >
< Tag id = "perf" textValue = "Performance" color = "success" >
< GaugeIcon /> Performance
</ Tag >
< Tag id = "security" textValue = "Security" color = "warning" >
< LockIcon /> Security
</ Tag >
< Tag id = "chore" textValue = "Chore" color = "neutral" >
< WrenchIcon /> Chore
</ Tag >
</ TagGroup >
);
}
selectionMode="single" turns tags into choice chips. Add disallowEmptySelection so one is always selected. Selected tags always use the solid primary style.
import { Tag, TagGroup } from "@/components/ui/tag-group" ;
export default function TagGroupSingleSelection () {
return (
< TagGroup
label = "Shipping speed"
selectionMode = "single"
disallowEmptySelection
defaultSelectedKeys = {[ "standard" ]}
>
< Tag id = "economy" >Economy · 5–8 days</ Tag >
< Tag id = "standard" >Standard · 3–5 days</ Tag >
< Tag id = "express" >Express · 1–2 days</ Tag >
< Tag id = "overnight" >Overnight</ Tag >
</ TagGroup >
);
}
selectedKeys and onSelectionChange own the selection in multiple mode. Handle both a Set of keys and "all".
We'll suggest Japanese, Thai places.
import { useState } from "react" ;
import type { Selection } from "react-aria-components" ;
import { Tag, TagGroup } from "@/components/ui/tag-group" ;
const cuisines = [
{ id: "italian" , name: "Italian" },
{ id: "japanese" , name: "Japanese" },
{ id: "mexican" , name: "Mexican" },
{ id: "indian" , name: "Indian" },
{ id: "thai" , name: "Thai" },
{ id: "ethiopian" , name: "Ethiopian" },
{ id: "lebanese" , name: "Lebanese" },
];
export default function TagGroupControlled () {
const [ selected , setSelected ] = useState < Selection >(
new Set ([ "japanese" , "thai" ]),
);
const names =
selected === "all"
? cuisines. map (( c ) => c.name)
: cuisines. filter (( c ) => selected. has (c.id)). map (( c ) => c.name);
return (
< div className = "flex w-full max-w-md flex-col gap-3" >
< TagGroup
label = "Favorite cuisines"
items = {cuisines}
selectionMode = "multiple"
selectedKeys = {selected}
onSelectionChange = {setSelected}
>
{( c ) => < Tag >{c.name}</ Tag >}
</ TagGroup >
< p className = "text-muted-foreground text-xs" >
{names. length
? `We'll suggest ${ names . join ( ", " ) } places.`
: "Pick at least one." }
</ p >
</ div >
);
}
onRemove adds a remove button to every tag and enables Backspace / Delete . It receives the keys to remove; useListData makes updating the list a one-liner. renderEmptyState shows when the last tag is gone.
Skills Backspace or Delete removes the focused tag.
import { useListData } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { Tag, TagGroup } from "@/components/ui/tag-group" ;
const initialItems = [
{ id: 1 , name: "react" },
{ id: 2 , name: "typescript" },
{ id: 3 , name: "tailwindcss" },
{ id: 4 , name: "react-aria" },
{ id: 5 , name: "vitest" },
];
export default function TagGroupRemovable () {
const list = useListData ({ initialItems });
return (
< div className = "flex w-full max-w-md flex-col items-start gap-3" >
< TagGroup
label = "Skills"
items = {list.items}
onRemove = {( keys ) => list. remove ( ... keys)}
description = "Backspace or Delete removes the focused tag."
renderEmptyState = {() => (
< span className = "text-muted-foreground text-sm" >
No skills added.
</ span >
)}
>
{( item ) => < Tag >{item.name}</ Tag >}
</ TagGroup >
{list.items. length < initialItems. length && (
< Button
size = "xs"
variant = "ghost"
onPress = {() => {
list. remove ( ... list.items. map (( i ) => i.id));
list. append ( ... initialItems);
}}
>
Reset
</ Button >
)}
</ div >
);
}
disabledKeys keeps tags visible but not focusable, selectable or removable.
Available sizes Sold-out sizes are disabled.
import { Tag, TagGroup } from "@/components/ui/tag-group" ;
export default function TagGroupDisabled () {
return (
< TagGroup
label = "Available sizes"
selectionMode = "multiple"
defaultSelectedKeys = {[ "m" ]}
disabledKeys = {[ "xs" , "xxl" ]}
description = "Sold-out sizes are disabled."
>
< Tag id = "xs" >XS</ Tag >
< Tag id = "s" >S</ Tag >
< Tag id = "m" >M</ Tag >
< Tag id = "l" >L</ Tag >
< Tag id = "xl" >XL</ Tag >
< Tag id = "xxl" >XXL</ Tag >
</ TagGroup >
);
}
onAction makes tags actionable without selecting them, for suggestions and quick filters.
Click a suggestion to search.
import { useState } from "react" ;
import { Tag, TagGroup } from "@/components/ui/tag-group" ;
const suggestions = [
"pricing" ,
"refund policy" ,
"API rate limits" ,
"SSO setup" ,
"export data" ,
];
export default function TagGroupActions () {
const [ query , setQuery ] = useState ( "" );
return (
< div className = "flex w-full max-w-md flex-col gap-3" >
< TagGroup
label = "Popular searches"
variant = "soft"
onAction = {( key ) => setQuery ( String (key))}
>
{suggestions. map (( s ) => (
< Tag key = {s} id = {s} className = "cursor-pointer" >
{s}
</ Tag >
))}
</ TagGroup >
< p className = "text-muted-foreground text-xs" >
{query ? `Searching for “${ query }”…` : "Click a suggestion to search." }
</ p >
</ div >
);
}
Give tags an href to render them as links. They work with client-side routers through React Aria's RouterProvider.
import { Tag, TagGroup } from "@/components/ui/tag-group" ;
const topics = [
{ id: "forms" , name: "Forms" , href: "/docs/components/text-field" },
{ id: "dates" , name: "Dates" , href: "/docs/components/date-picker" },
{ id: "collections" , name: "Collections" , href: "/docs/components/table" },
{ id: "overlays" , name: "Overlays" , href: "/docs/components/dialog" },
];
export default function TagGroupLinks () {
return (
< TagGroup label = "Related topics" items = {topics} variant = "soft" >
{( t ) => (
< Tag href = {t.href} className = "cursor-pointer data-hovered:underline" >
{t.name}
</ Tag >
)}
</ TagGroup >
);
}
description explains the group; errorMessage replaces it with red text. Both are linked to the tag list for screen readers. Tag groups have no built-in validation, so decide when to show the error yourself.
Teams you want to hear from You can pick up to 3 teams (4 selected).
import { useState } from "react" ;
import type { Selection } from "react-aria-components" ;
import { Tag, TagGroup } from "@/components/ui/tag-group" ;
const MAX = 3 ;
const interests = [
"Design" ,
"Engineering" ,
"Product" ,
"Data" ,
"Security" ,
"Marketing" ,
"Sales" ,
];
export default function TagGroupValidation () {
const [ selected , setSelected ] = useState < Selection >(
new Set ([ "Design" , "Engineering" , "Product" , "Data" ]),
);
const count = selected === "all" ? interests. length : selected.size;
const tooMany = count > MAX ;
return (
< TagGroup
label = "Teams you want to hear from"
selectionMode = "multiple"
selectedKeys = {selected}
onSelectionChange = {setSelected}
description = {tooMany ? undefined : `Pick up to ${ MAX }.` }
errorMessage = {
tooMany
? `You can pick up to ${ MAX } teams (${ count } selected).`
: undefined
}
>
{interests. map (( i ) => (
< Tag key = {i} id = {i}>
{i}
</ Tag >
))}
</ TagGroup >
);
}
A TextField adds a tag on Enter , with duplicate detection and a maximum count. The tags below are removable.
import { useState } from "react" ;
import { useListData } from "react-aria-components" ;
import { Tag, TagGroup } from "@/components/ui/tag-group" ;
import { TextField } from "@/components/ui/text-field" ;
const MAX_TAGS = 6 ;
export default function TagGroupRecipeTagInput () {
const tags = useListData ({
initialItems: [{ id: "billing" }, { id: "enterprise" }],
});
const [ draft , setDraft ] = useState ( "" );
const value = draft. trim (). toLowerCase ();
const duplicate = value !== "" && tags. getItem (value) !== undefined ;
const full = tags.items. length >= MAX_TAGS ;
const add = () => {
if ( ! value || duplicate || full) return ;
tags. append ({ id: value });
setDraft ( "" );
};
return (
< div className = "flex w-full max-w-sm flex-col gap-3" >
< TextField
label = "Add a label"
placeholder = "e.g. onboarding"
description = {
full ? `You can add up to ${ MAX_TAGS } labels.` : "Press Enter to add."
}
value = {draft}
onChange = {setDraft}
onKeyDown = {( e ) => {
if (e.key === "Enter" ) {
e. preventDefault ();
add ();
}
}}
isInvalid = {duplicate}
errorMessage = "That label is already added."
isDisabled = {full}
/>
< TagGroup
aria-label = "Labels"
items = {tags.items}
onRemove = {( keys ) => tags. remove ( ... keys)}
description = { `${ tags . items . length } of ${ MAX_TAGS } labels` }
renderEmptyState = {() => (
< span className = "text-muted-foreground text-sm" >No labels yet.</ span >
)}
>
{( t ) => < Tag >{t.id}</ Tag >}
</ TagGroup >
</ div >
);
}
Pill-shaped, single-select chips that filter a list. disallowEmptySelection guarantees an active filter.
Designing accessible date pickers 8 min Server components and client islands 12 min How we price usage-based plans 6 min Color tokens that survive dark mode 5 min Zero-downtime Postgres migrations 15 min Writing a changelog people read 4 min
import { useState } from "react" ;
import type { Key } from "react-aria-components" ;
import { Tag, TagGroup } from "@/components/ui/tag-group" ;
const articles = [
{
id: 1 ,
title: "Designing accessible date pickers" ,
category: "design" ,
minutes: 8 ,
},
{
id: 2 ,
title: "Server components and client islands" ,
category: "engineering" ,
minutes: 12 ,
},
{
id: 3 ,
title: "How we price usage-based plans" ,
category: "product" ,
minutes: 6 ,
},
{
id: 4 ,
title: "Color tokens that survive dark mode" ,
category: "design" ,
minutes: 5 ,
},
{
id: 5 ,
title: "Zero-downtime Postgres migrations" ,
category: "engineering" ,
minutes: 15 ,
},
{
id: 6 ,
title: "Writing a changelog people read" ,
category: "product" ,
minutes: 4 ,
},
];
const categories = [
{ id: "all" , name: "All" },
{ id: "design" , name: "Design" },
{ id: "engineering" , name: "Engineering" },
{ id: "product" , name: "Product" },
];
export default function TagGroupRecipeFilterChips () {
const [ category , setCategory ] = useState < Key >( "all" );
const visible = articles. filter (
( a ) => category === "all" || a.category === category,
);
return (
< div className = "flex w-full max-w-md flex-col gap-3" >
< TagGroup
aria-label = "Category"
items = {categories}
selectionMode = "single"
disallowEmptySelection
selectedKeys = {[category]}
onSelectionChange = {( keys ) => {
const [ key ] = [ ... keys];
if (key !== undefined ) setCategory (key);
}}
>
{( c ) => < Tag className = "rounded-full px-3" >{c.name}</ Tag >}
</ TagGroup >
< ul className = "divide-y rounded-lg border bg-card" >
{visible. map (( a ) => (
< li
key = {a.id}
className = "flex items-center justify-between gap-3 px-3 py-2.5 text-sm"
>
< span className = "truncate" >{a.title}</ span >
< span className = "shrink-0 text-muted-foreground text-xs" >
{a.minutes} min
</ span >
</ li >
))}
</ ul >
</ div >
);
}
Removable soft tags that summarize applied filters, with a "Clear all" action. Each tag's textValue reads the field and value together.
import { useListData } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { Tag, TagGroup } from "@/components/ui/tag-group" ;
const initialFilters = [
{ id: "status:open" , field: "Status" , value: "Open" },
{ id: "assignee:me" , field: "Assignee" , value: "Me" },
{ id: "label:bug" , field: "Label" , value: "bug" },
{ id: "updated:7d" , field: "Updated" , value: "Last 7 days" },
];
export default function TagGroupRecipeActiveFilters () {
const filters = useListData ({ initialItems: initialFilters });
return (
< div className = "flex w-full max-w-lg flex-wrap items-center gap-2 rounded-lg border bg-card p-2.5 shadow-xs" >
< span className = "px-1 text-muted-foreground text-xs" >Filters</ span >
< TagGroup
aria-label = "Active filters"
items = {filters.items}
variant = "soft"
onRemove = {( keys ) => filters. remove ( ... keys)}
renderEmptyState = {() => (
< span className = "text-muted-foreground text-xs" >None applied</ span >
)}
className = "flex-1"
>
{( f ) => (
< Tag textValue = { `${ f . field }: ${ f . value }` }>
< span className = "text-muted-foreground" >{f.field}:</ span >
{f.value}
</ Tag >
)}
</ TagGroup >
{filters.items. length > 0 ? (
< Button
size = "xs"
variant = "ghost"
onPress = {() => filters. remove ( ... filters.items. map (( f ) => f.id))}
>
Clear all
</ Button >
) : (
< Button
size = "xs"
variant = "ghost"
onPress = {() => filters. append ( ... initialFilters)}
>
Restore
</ Button >
)}
</ div >
);
}
The tag list is a role="grid" and each tag a role="row", so screen readers announce position and selected state. The group is labelled by label (or aria-label).
The whole group is one tab stop; arrow keys move between tags. If the last tag is removed while focused, focus moves to the list itself so it isn't lost.
Remove buttons are labelled "Remove" together with the tag text. Keyboard users can also press Backspace / Delete on the focused tag.
description and errorMessage are linked with aria-describedby.
Colors are decoration only. Put the meaning in the text ("bug", "blocked"), not just the color.
Key Action Tab Moves focus into and out of the group ← / → Moves between tags (reversed in RTL) Home / End Moves to the first / last tag Space / Enter Selects the tag, triggers onAction or follows a link Backspace / Delete Removes the focused tag (with onRemove) Esc Clears the selection
On each Tag:
Attribute Present when data-selectedThe tag is selected (solid primary style) data-focused / data-focus-visibleThe tag has focus / keyboard focus data-hovered / data-pressedHovered with a mouse / being pressed data-disabledThe tag is disabled data-allows-removingThe group has onRemove data-selection-modesingle or multiple
On the tag list (the element with role="grid"): data-empty, data-focused, data-focus-visible. The root TagGroup is a Tailwind group (group/field).
data-slotElement tag-groupRoot tagEach tag label, descriptionField text
Like Button , tag colors come from the --tone variable, so a one-off color is just a class:
< Tag color = "primary" className = "[--tone:var(--color-pink-600)]" >design</ Tag >
className on TagGroup styles the root (a string). className on Tag accepts a function of the render props.
Add rounded-full px-3 to a Tag for pill-shaped chips.
Also accepts every prop of React Aria's TagGroup .