TagGroup
A focusable list of tags with support for keyboard navigation, selection, and removal
Usage
import { TagGroup } from '@lenso/ui';"use client";
// Adapted from HeroUI v3.2.6 tag-group-basic (Apache-2.0).import { PlanetEarth, Rocket, ShoppingBag, SquareArticle } from "@gravity-ui/icons";import { Tag, TagGroup } from "@lenso/ui";
export function TagGroupBasic() { return ( <TagGroup aria-label="Tags" selectionMode="single"> <TagGroup.List> <Tag itemKey="default-news" textValue="News"> <SquareArticle width={12} height={12} /> News </Tag> <Tag itemKey="default-travel" textValue="Travel"> <PlanetEarth width={12} height={12} /> Travel </Tag> <Tag itemKey="default-gaming" textValue="Gaming"> <Rocket width={12} height={12} /> Gaming </Tag> <Tag itemKey="default-shopping" textValue="Shopping"> <ShoppingBag width={12} height={12} /> Shopping </Tag> </TagGroup.List> </TagGroup> );}Local adaptation source above. Derived from HeroUI v3.2.6 source.
Anatomy
import { TagGroup, Tag, Label, Description, ErrorMessage } from '@lenso/ui';
export default () => ( <TagGroup> <Label /> <TagGroup.List> <Tag> <Tag.RemoveButton /> </Tag> </TagGroup.List> <Description /> <ErrorMessage /> </TagGroup>)Examples
Sizes
"use client";// HeroUI v3.2.6 sizes adaptation (Apache-2.0).import * as stylex from "@stylexjs/stylex";import { TagGroup } from "@lenso/ui";import { Label } from "./text";import { Categories, styles } from "./categories";export function TagGroupSizes() { return ( <div {...stylex.props(styles.sizes)}> {(["sm", "md", "lg"] as const).map((size, index) => ( <TagGroup key={size} aria-label={["Small", "Medium", "Large"][index]} selectionMode="single" size={size} > <Label>{["Small", "Medium", "Large"][index]}</Label> <TagGroup.List> <Categories count={3} /> </TagGroup.List> </TagGroup> ))} </div> );}Local adaptation source above. Derived from HeroUI v3.2.6 source.
Variants
"use client";// HeroUI v3.2.6 variants adaptation (Apache-2.0).import * as stylex from "@stylexjs/stylex";import { TagGroup } from "@lenso/ui";import { Label } from "./text";import { Categories, styles } from "./categories";export function TagGroupVariants() { return ( <div {...stylex.props(styles.stack)}> {(["default", "surface"] as const).map((variant) => ( <TagGroup key={variant} aria-label={variant === "default" ? "Default" : "Surface"} selectionMode="single" variant={variant} > <Label>{variant === "default" ? "Default" : "Surface"}</Label> <TagGroup.List> <Categories count={3} /> </TagGroup.List> </TagGroup> ))} </div> );}Local adaptation source above. Derived from HeroUI v3.2.6 source.
Disabled
"use client";// HeroUI v3.2.6 disabled adaptation (Apache-2.0).import * as stylex from "@stylexjs/stylex";import { TagGroup } from "@lenso/ui";import { Description, Label } from "./text";import { Categories, styles } from "./categories";export function TagGroupDisabled() { return ( <div {...stylex.props(styles.disabled)}> <TagGroup aria-label="Disabled Tags" selectionMode="single"> <Label>Disabled Tags</Label> <TagGroup.List> <Categories disabled count={3} /> </TagGroup.List> <Description>Some tags are disabled</Description> </TagGroup> <TagGroup aria-label="Disabled Keys" disabledKeys={new Set(["travel"])} selectionMode="single" > <Label>Disabled Keys</Label> <TagGroup.List> <Categories count={3} /> </TagGroup.List> <Description>Tags disabled via disabledKeys prop</Description> </TagGroup> </div> );}Local adaptation source above. Derived from HeroUI v3.2.6 source.
Selection Modes
"use client";// HeroUI v3.2.6 selection-modes adaptation (Apache-2.0).import { useState, type Key } from "react";import * as stylex from "@stylexjs/stylex";import { TagGroup } from "@lenso/ui";import { Description, Label } from "./text";import { Categories, styles } from "./categories";export function TagGroupSelectionModes() { const [single, setSingle] = useState<Set<Key>>(new Set(["news"])); const [multiple, setMultiple] = useState<Set<Key>>(new Set(["news", "travel"])); return ( <div {...stylex.props(styles.stack)}> <TagGroup aria-label="Single Selection" selectedKeys={single} selectionMode="single" onSelectionChange={setSingle} > <Label>Single Selection</Label> <TagGroup.List> <Categories /> </TagGroup.List> <Description>Choose one category</Description> </TagGroup> <TagGroup aria-label="Multiple Selection" selectedKeys={multiple} selectionMode="multiple" onSelectionChange={setMultiple} > <Label>Multiple Selection</Label> <TagGroup.List> <Categories /> </TagGroup.List> <Description>Choose multiple categories</Description> </TagGroup> </div> );}Local adaptation source above. Derived from HeroUI v3.2.6 source.
Controlled
"use client";// HeroUI v3.2.6 controlled adaptation (Apache-2.0).import { useState, type Key } from "react";import { TagGroup } from "@lenso/ui";import { Description, Label } from "./text";import { Categories } from "./categories";export function TagGroupControlled() { const [selected, setSelected] = useState<Set<Key>>(new Set(["news", "travel"])); return ( <TagGroup aria-label="Categories (controlled)" selectedKeys={selected} selectionMode="multiple" onSelectionChange={setSelected} > <Label>Categories (controlled)</Label> <TagGroup.List> <Categories /> </TagGroup.List> <Description>Selected: {selected.size ? [...selected].join(", ") : "None"}</Description> </TagGroup> );}Local adaptation source above. Derived from HeroUI v3.2.6 source.
With Error Message
"use client";// HeroUI v3.2.6 with-error-message adaptation (Apache-2.0).import { useState, type Key } from "react";import { Tag, TagGroup } from "@lenso/ui";import { Description, ErrorMessage, Label } from "./text";const amenities = [ ["laundry", "Laundry"], ["fitness", "Fitness center"], ["parking", "Parking"], ["pool", "Swimming pool"], ["breakfast", "Breakfast"],];export function TagGroupWithErrorMessage() { const [selected, setSelected] = useState<Set<Key>>(new Set()); return ( <TagGroup aria-label="Amenities" selectedKeys={selected} selectionMode="multiple" onSelectionChange={setSelected} aria-invalid={!selected.size} > <Label>Amenities</Label> <TagGroup.List> {amenities.map(([key, name]) => ( <Tag key={key} itemKey={key!} textValue={name!}> {name} </Tag> ))} </TagGroup.List> <Description> {!selected.size ? "Select at least one category" : `Selected: ${[...selected].join(", ")}`} </Description> <ErrorMessage>{!selected.size && "Please select at least one category"}</ErrorMessage> </TagGroup> );}Local adaptation source above. Derived from HeroUI v3.2.6 source.
With List Data
"use client";// HeroUI v3.2.6 with-list-data adaptation (Apache-2.0); native controlled collection.import { useState, type Key } from "react";import * as stylex from "@stylexjs/stylex";import { Avatar, EmptyState, Tag, TagGroup } from "@lenso/ui";import { Description, Label } from "./text";import { styles } from "./categories";const initial = ["Fred", "Michael", "Jane", "Alice", "Bob", "Charlie"].map((name, index) => ({ key: name.toLowerCase(), textValue: name, color: ["blue", "green", "purple", "red", "orange", "black"][index],}));export function TagGroupWithListData() { const [items, setItems] = useState(initial); const [selected, setSelected] = useState<Set<Key>>(new Set(["fred", "michael"])); const avatar = (user: (typeof initial)[number]) => ( <Avatar xstyle={styles.avatar} size="sm"> <Avatar.Image alt={user.textValue} src={`https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/${user.color}.jpg`} /> <Avatar.Fallback>{user.textValue[0]}</Avatar.Fallback> </Avatar> ); return ( <div {...stylex.props(styles.width)}> <TagGroup aria-label="Team Members" selectedKeys={selected} selectionMode="multiple" onSelectionChange={setSelected} onRemove={(keys) => setItems((previous) => previous.filter((user) => !keys.has(user.key)))} > <Label>Team Members</Label> <TagGroup.List> {items.length ? ( items.map((user) => ( <Tag key={user.key} itemKey={user.key} textValue={user.textValue}> {avatar(user)} {user.textValue} </Tag> )) ) : ( <EmptyState>No team members</EmptyState> )} </TagGroup.List> <Description>Select team members for your project</Description> </TagGroup> {selected.size > 0 && ( <> <p>Selected:</p> <div {...stylex.props(styles.selected)}> {items .filter((user) => selected.has(user.key)) .map((user) => ( <div key={user.key} {...stylex.props(styles.selectedUser)}> {avatar(user)} <span>{user.textValue}</span> </div> ))} </div> </> )} </div> );}Local adaptation source above. Derived from HeroUI v3.2.6 source.
With Prefix
"use client";// HeroUI v3.2.6 with-prefix adaptation (Apache-2.0).import * as stylex from "@stylexjs/stylex";import { Avatar, Tag, TagGroup } from "@lenso/ui";import { Description, Label } from "./text";import { Categories, styles } from "./categories";export function TagGroupWithPrefix() { return ( <div {...stylex.props(styles.stack)}> <TagGroup aria-label="With Icons" selectionMode="single"> <Label>With Icons</Label> <TagGroup.List> <Categories icons /> </TagGroup.List> <Description>Tags with icons</Description> </TagGroup> <TagGroup aria-label="With Avatars" selectionMode="single"> <Label>With Avatars</Label> <TagGroup.List> {["Fred", "Michael", "Jane"].map((name, index) => ( <Tag key={name} itemKey={name.toLowerCase()} textValue={name}> <Avatar xstyle={styles.avatar}> <Avatar.Image alt={name} src={`https://heroui-assets.nyc3.cdn.digitaloceanspaces.com/avatars/${["blue", "green", "purple"][index]}.jpg`} /> <Avatar.Fallback>{name[0]}</Avatar.Fallback> </Avatar> {name} </Tag> ))} </TagGroup.List> <Description>Tags with avatars</Description> </TagGroup> </div> );}Local adaptation source above. Derived from HeroUI v3.2.6 source.
With Remove Button
"use client";// HeroUI v3.2.6 with-remove-button adaptation (Apache-2.0).import { useState, type Key } from "react";import * as stylex from "@stylexjs/stylex";import { Icon } from "@iconify/react";import { EmptyState, Tag, TagGroup } from "@lenso/ui";import { Description, Label } from "./text";import { styles } from "./categories";function Removable({ custom = false }: { custom?: boolean }) { const [items, setItems] = useState( custom ? ["React", "Vue", "Angular", "Svelte"] : ["News", "Travel", "Gaming", "Shopping"], ); const remove = (keys: Set<Key>) => setItems((previous) => previous.filter((name) => !keys.has(name.toLowerCase()))); return ( <TagGroup aria-label={custom ? "Custom Remove Button" : "Default Remove Button"} selectionMode="single" onRemove={remove} > <Label>{custom ? "Custom Remove Button" : "Default Remove Button"}</Label> <TagGroup.List> {items.length ? ( items.map((name) => ( <Tag key={name} itemKey={name.toLowerCase()} textValue={name}> {custom ? ({ allowsRemoving }) => ( <> {name} {allowsRemoving && ( <Tag.RemoveButton> <Icon icon="gravity-ui:circle-xmark-fill" width={12} aria-hidden="true" /> </Tag.RemoveButton> )} </> ) : name} </Tag> )) ) : ( <EmptyState>{custom ? "No frameworks found" : "No categories found"}</EmptyState> )} </TagGroup.List> <Description> {custom ? "Custom remove button with icon" : "Click the X to remove tags"} </Description> </TagGroup> );}export function TagGroupWithRemoveButton() { return ( <div {...stylex.props(styles.stack)}> <Removable /> <Removable custom /> </div> );}Local adaptation source above. Derived from HeroUI v3.2.6 source.
Render Function
"use client";// HeroUI v3.2.6 render-function adaptation (Apache-2.0).import { TagGroup } from "@lenso/ui";import { Categories } from "./categories";export function RenderFunction() { return ( <TagGroup aria-label="Tags" render={(props) => <div {...props} data-custom="foo" />} selectionMode="single" > <TagGroup.List> <Categories icons prefix="default-" /> </TagGroup.List> </TagGroup> );}Local adaptation source above. Derived from HeroUI v3.2.6 source.
Customization
Tailwind CSS
"use client";// HeroUI v3.2.6 custom-styles adaptation (Apache-2.0).import { TagGroup } from "@lenso/ui";import { Categories, styles } from "./categories";export function CustomStyles() { return ( <TagGroup aria-label="Topics" selectionMode="single"> <TagGroup.List xstyle={styles.list}> <Categories icons custom /> </TagGroup.List> </TagGroup> );}Local adaptation source above. Derived from HeroUI v3.2.6 source.
Global CSS
To customize the TagGroup component classes, you can use the @layer components directive.
Learn more.
@layer components { .tag-group { @apply flex flex-col gap-2; }
.tag-group__list { @apply flex flex-wrap gap-2; }
.tag { @apply rounded-full px-3 py-1; }
.tag__remove-button { @apply ml-1; }}Styling Reference
HeroUI follows the BEM methodology to ensure component variants and states are reusable and easy to customize.
CSS Classes
The TagGroup component uses these CSS classes (View source styles and tag.css):
Base Classes [!toc]
.tag-group- Base tag group container.tag-group__list- Container for the list of tags.tag- Base tag styles.tag__remove-button- Remove button trigger
Slot Classes [!toc]
.tag-group [slot="description"]- Description slot styles.tag-group [slot="errorMessage"]- ErrorMessage slot styles
Size Classes [!toc]
.tag--sm- Small size tag.tag--md- Medium size tag (default).tag--lg- Large size tag
Variant Classes [!toc]
.tag--default- Default variant.tag--surface- Surface variant with surface background
State Classes [!toc]
.tag[data-selected="true"]- Selected tag state.tag[data-disabled="true"]- Disabled tag state.tag[data-hovered="true"]- Hovered tag state.tag[data-pressed="true"]- Pressed tag state.tag[data-focus-visible="true"]- Focused tag state (keyboard focus)
Interactive States
The component supports both CSS pseudo-classes and data attributes for flexibility:
- Hover:
:hoveror[data-hovered="true"]on tag - Focus:
:focus-visibleor[data-focus-visible="true"]on tag - Pressed:
:activeor[data-pressed="true"]on tag - Selected:
[data-selected="true"]or[aria-selected="true"]on tag - Disabled:
:disabledor[data-disabled="true"]on tag
API Reference
TagGroup
| Prop | Type | Default | Description |
|---|---|---|---|
selectionMode | "none" | "single" | "multiple" | "none" | The type of selection that is allowed |
selectedKeys | Selection | - | The currently selected keys (controlled) |
defaultSelectedKeys | Selection | - | The initial selected keys (uncontrolled) |
onSelectionChange | (keys: Selection) => void | - | Handler called when the selection changes |
disabledKeys | Iterable<Key> | - | Keys of disabled tags |
isDisabled | boolean | - | Whether the tag group is disabled |
onRemove | (keys: Set<Key>) => void | - | Handler called when tags are removed |
size | "sm" | "md" | "lg" | "md" | Size of the tags in the group |
variant | "default" | "surface" | "default" | Visual variant of the tags |
className | string | - | Additional CSS classes |
children | ReactNode | RenderFunction | - | TagGroup content or render function |
render | DOMRenderFunction<keyof React.JSX.IntrinsicElements, undefined> | - | Overrides the default DOM element with a custom render function. |
TagGroup.List
| Prop | Type | Default | Description |
|---|---|---|---|
items | Iterable<T> | - | The items to display in the tag list |
renderEmptyState | () => ReactNode | - | Function to render when the list is empty |
className | string | - | Additional CSS classes |
children | ReactNode | RenderFunction | - | TagList content or render function |
render | DOMRenderFunction<keyof React.JSX.IntrinsicElements, TagListRenderProps> | - | Overrides the default DOM element with a custom render function. |
Tag
| Prop | Type | Default | Description |
|---|---|---|---|
id | Key | - | The unique identifier for the tag |
textValue | string | - | A string representation of the tag's content, used for accessibility |
isDisabled | boolean | - | Whether the tag is disabled |
className | string | - | Additional CSS classes |
children | ReactNode | RenderFunction | - | Tag content or render function |
render | DOMRenderFunction<keyof React.JSX.IntrinsicElements, TagRenderProps> | - | Overrides the default DOM element with a custom render function. |
Note: size, variant are inherited from the parent TagGroup component and cannot be set directly on individual Tag components.
Tag.RemoveButton
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes |
children | ReactNode | - | Custom remove button content (defaults to close icon) |
Note: The Tag.RemoveButton component supports customization similar to SearchField.ClearButton. When onRemove is provided to TagGroup:
- Auto-rendering: If no custom
Tag.RemoveButtonis included in theTagchildren, a default remove button is automatically rendered. - Custom button: If a custom
Tag.RemoveButtonis provided as a child ofTag, it will be used instead of the auto-rendered button. - Custom icon: You can pass custom content (like icons) to
Tag.RemoveButtonchildren to customize the appearance.
Example - Auto-rendered (default):
<TagGroup onRemove={handleRemove}> <TagGroup.List> <Tag id="news">News</Tag> {/* Remove button is automatically rendered */} </TagGroup.List></TagGroup>Example - Custom RemoveButton with icon:
<TagGroup onRemove={handleRemove}> <TagGroup.List> <Tag id="news"> News <Tag.RemoveButton> <CustomIcon /> </Tag.RemoveButton> </Tag> </TagGroup.List></TagGroup>Example - Custom RemoveButton in render props:
<Tag id="news"> {(renderProps) => ( <> News {!!renderProps.allowsRemoving && ( <Tag.RemoveButton> <CustomIcon /> </Tag.RemoveButton> )} </> )}</Tag>Render Props
When using render functions with TagGroup.List, these values are provided:
| Prop | Type | Description |
|---|---|---|
isSelected | boolean | Whether the tag is selected |
isDisabled | boolean | Whether the tag is disabled |
isHovered | boolean | Whether the tag is hovered |
isPressed | boolean | Whether the tag is pressed |
isFocused | boolean | Whether the tag is focused |
isFocusVisible | boolean | Whether the tag has keyboard focus |