Skip to content
Lenso UI

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: :hover or [data-hovered="true"] on tag
  • Focus: :focus-visible or [data-focus-visible="true"] on tag
  • Pressed: :active or [data-pressed="true"] on tag
  • Selected: [data-selected="true"] or [aria-selected="true"] on tag
  • Disabled: :disabled or [data-disabled="true"] on tag

API Reference

TagGroup

PropTypeDefaultDescription
selectionMode"none" | "single" | "multiple""none"The type of selection that is allowed
selectedKeysSelection-The currently selected keys (controlled)
defaultSelectedKeysSelection-The initial selected keys (uncontrolled)
onSelectionChange(keys: Selection) => void-Handler called when the selection changes
disabledKeysIterable<Key>-Keys of disabled tags
isDisabledboolean-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
classNamestring-Additional CSS classes
childrenReactNode | RenderFunction-TagGroup content or render function
renderDOMRenderFunction<keyof React.JSX.IntrinsicElements, undefined>-Overrides the default DOM element with a custom render function.

TagGroup.List

PropTypeDefaultDescription
itemsIterable<T>-The items to display in the tag list
renderEmptyState() => ReactNode-Function to render when the list is empty
classNamestring-Additional CSS classes
childrenReactNode | RenderFunction-TagList content or render function
renderDOMRenderFunction<keyof React.JSX.IntrinsicElements, TagListRenderProps>-Overrides the default DOM element with a custom render function.

Tag

PropTypeDefaultDescription
idKey-The unique identifier for the tag
textValuestring-A string representation of the tag's content, used for accessibility
isDisabledboolean-Whether the tag is disabled
classNamestring-Additional CSS classes
childrenReactNode | RenderFunction-Tag content or render function
renderDOMRenderFunction<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

PropTypeDefaultDescription
classNamestring-Additional CSS classes
childrenReactNode-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.RemoveButton is included in the Tag children, a default remove button is automatically rendered.
  • Custom button: If a custom Tag.RemoveButton is provided as a child of Tag, it will be used instead of the auto-rendered button.
  • Custom icon: You can pass custom content (like icons) to Tag.RemoveButton children 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:

PropTypeDescription
isSelectedbooleanWhether the tag is selected
isDisabledbooleanWhether the tag is disabled
isHoveredbooleanWhether the tag is hovered
isPressedbooleanWhether the tag is pressed
isFocusedbooleanWhether the tag is focused
isFocusVisiblebooleanWhether the tag has keyboard focus