Siteplane UI

Tag Group

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

Markdown
"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

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

Complete the one-time setup first. Registry JSON lists source files and dependencies.

For this page’s examples, also install:

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

Usage

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

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

Composition

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.

"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.

"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.

"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.

"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

Component properties
PropTypeDefaultDescription
TagGroup.sizesm | md | lg | xlprovider / lgTag dimensions.
TagGroup.tabIndexnumber-1Root fallback focus target. Other div props except role are accepted.
Tag.childrenReactNodeRequiredTag content and extracted text value.
Tag.selectedbooleanfalseControlled selected state.
Tag.defaultSelectedbooleanunsetInitial 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.removeLabelstringRemove tagAccessible label for remove action.
Tag.disabledbooleanfalseDims the tag and disables its selection and removal.
Tag motion / span propsTagProps—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.

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.