# Tag Group

Display, select, and remove a keyboard-navigable collection of tags.

Project labels

[Complete TSX file](/examples/tag-group/default.tsx)

```tsx
"use client";

import { Tag, TagGroup } from "@/siteplane/components/tag-group";

export default function Example() {
  return (
    <TagGroup aria-label="Project labels">
      <Tag>Design</Tag>
      <Tag>Engineering</Tag>
      <Tag>Research</Tag>
    </TagGroup>
  );
}
```

## Installation

Complete [one-time setup](/docs/installation/) first.

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/tag-group
```

With npm: `npx shadcn@4.18.0 add @siteplane/tag-group`. [Registry JSON](/registry/tag-group.json) lists source files and dependencies.

For this page’s examples, also install:

```bash
pnpm dlx shadcn@4.18.0 add @siteplane/form @siteplane/input
```

## Usage

```tsx
import { TagGroup, Tag } from "@/siteplane/components/tag-group";

<TagGroup aria-label="Labels">
  <Tag>Design</Tag>
</TagGroup>;
```

## Composition

```text
TagGroup
└── Tag (direct child or fragment)
```

Tags wrap onto new rows as the group needs. A tag is never wider than the group: a long or unbroken label, such as a URL, ends with an ellipsis and keeps its remove button, and the full text is the label's title, so the group never makes a card or a column wider than the space it has.

## Selectable tags

Pass `selected` and `onSelectedChange` to make a tag selectable; the application owns the selected set. For a tag that keeps its own state, pass `defaultSelected` instead.

Selectable team tags

[Complete TSX file](/examples/tag-group/selection.tsx)

```tsx
"use client";

import { Tag, TagGroup } from "@/siteplane/components/tag-group";
import { useState } from "react";

export default function Example() {
  const [selected, setSelected] = useState(["Design"]);
  return (
    <TagGroup aria-label="Teams">
      {["Design", "Engineering", "Research"].map((label) => (
        <Tag
          key={label}
          selected={selected.includes(label)}
          onSelectedChange={(checked) =>
            setSelected((current) =>
              checked
                ? [...current, label]
                : current.filter((item) => item !== label),
            )
          }
        >
          {label}
        </Tag>
      ))}
    </TagGroup>
  );
}
```

## Removable tags

Pass `onRemove` to add a remove button and keyboard removal, then remove that tag from your state. Use `removeLabel` to name each remove button.

Removable filter tags

[Complete TSX file](/examples/tag-group/remove.tsx)

```tsx
"use client";

import { Tag, TagGroup } from "@/siteplane/components/tag-group";
import { useState } from "react";

export default function Example() {
  const [tags, setTags] = useState(["Active", "Assigned to me", "This week"]);
  return (
    <TagGroup aria-label="Active filters">
      {tags.map((tag) => (
        <Tag
          key={tag}
          onRemove={() =>
            setTags((current) => current.filter((item) => item !== tag))
          }
          removeLabel={`Remove ${tag}`}
        >
          {tag}
        </Tag>
      ))}
    </TagGroup>
  );
}
```

## Adding and removing tags

Tags animate on their own: a tag you add fades in, a tag you remove fades out, and the tags around it glide to their new place. When a row of tags appears or goes, the height of the group follows instead of jumping. A tag that is leaving is hidden from assistive technology and takes no input; removal by keyboard still moves focus to the next tag. Reduced motion changes the tags at once.

Add and remove labels

[Complete TSX file](/examples/tag-group/add-remove.tsx)

```tsx
"use client";

import { Button } from "@/siteplane/components/button";
import { Form } from "@/siteplane/components/form";
import { Input } from "@/siteplane/components/input";
import { Tag, TagGroup } from "@/siteplane/components/tag-group";
import { useState } from "react";

export default function Example() {
  const [labels, setLabels] = useState(["Design", "Research", "Launch"]);
  const [draft, setDraft] = useState("");

  return (
    <div className="grid w-full max-w-sm gap-3">
      <Form
        className="flex gap-2"
        onSubmit={(event) => {
          event.preventDefault();
          const label = draft.trim();
          if (!label || labels.includes(label)) return;
          setLabels((current) => [...current, label]);
          setDraft("");
        }}
      >
        <Input
          aria-label="New label"
          onValueChange={setDraft}
          placeholder="Add a label"
          value={draft}
        />
        <Button type="submit" variant="outline">
          Add
        </Button>
      </Form>
      <TagGroup aria-label="Labels">
        {labels.map((label) => (
          <Tag
            key={label}
            onRemove={() =>
              setLabels((current) => current.filter((item) => item !== label))
            }
            removeLabel={`Remove ${label}`}
          >
            {label}
          </Tag>
        ))}
      </TagGroup>
    </div>
  );
}
```

## Sizes

Set `size` on the group; every tag in it uses that size.

Tag sizes

[Complete TSX file](/examples/tag-group/sizes.tsx)

```tsx
"use client";

import { Tag, TagGroup } from "@/siteplane/components/tag-group";

const sizes = [
  { name: "small", size: "sm" },
  { name: "medium", size: "md" },
  { name: "large", size: "lg" },
  { name: "extra large", size: "xl" },
] as const;

export default function Example() {
  return (
    <div className="grid gap-3">
      {sizes.map(({ name, size }) => (
        <TagGroup key={size} size={size} aria-label={`Project labels, ${name}`}>
          <Tag>Design</Tag>
          <Tag>Engineering</Tag>
          <Tag>Research</Tag>
        </TagGroup>
      ))}
    </div>
  );
}
```

## API reference

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| TagGroup.size | sm \| md \| lg \| xl | provider / lg | Tag dimensions. |
| TagGroup.tabIndex | number | -1 | Root fallback focus target. Other div props except role are accepted. |
| Tag.children | ReactNode | Required | Tag content and extracted text value. |
| Tag.selected | boolean | false | Controlled selected state. |
| Tag.defaultSelected | boolean | unset | Initial selection of an uncontrolled tag, which is then selectable without a handler. |
| Tag.onSelectedChange | (selected: boolean) => void | — | Makes the tag selectable. |
| Tag.onRemove | () => void | — | Adds remove affordance and keyboard removal. |
| Tag.removeLabel | string | Remove tag | Accessible label for remove action. |
| Tag.disabled | boolean | false | Dims the tag and disables its selection and removal. |
| Tag motion / span props | TagProps | — | Motion span props except layout, layoutDependency, onClick, role, tabIndex, transition and children. |

Exported types: `TagGroupProps`, `TagProps`. These describe the contracts above.

[Read the installed source](/source/tag-group).

## Usage notes

Keep stable React keys when adding and removing tags: an index as the key makes a removed tag leave as its neighbor. Only direct Tag children and fragments enter the collection; do not wrap each Tag in an application component. The group owns the animation of its tags and respects reduced motion; it uses the Motion component, which installs with it. This is not a named form field: serialize selected values in your application.
