For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Tag Group

A labelled set of tags for filtering, picking or pruning, built on Base UI.

Installation

$ pnpm dlx shadcn@latest add @cobalt/tag-group

Usage

import { Tag, TagGroup } from "@/components/ui/tag-group";
 
<TagGroup
  label="Topics"
  selectionMode="multiple"
  defaultSelectedKeys={["design"]}
>
  <Tag id="design" color="blue">
    Design
  </Tag>
  <Tag id="docs" color="yellow">
    Docs
  </Tag>
</TagGroup>;

selectionMode is none by default, which renders static tags. single and multiple make the tags toggle, and a selected tag fills with the accent colour. Pass onRemove to give every tag a remove button; Backspace and Delete then remove the focused tag, or the whole selection when the focused tag is part of it.

Every tag needs a stable id. It is the selection key, and the list uses it to keep a single tab stop. Use selectedKeys with onSelectionChange for a controlled group, defaultSelectedKeys for an uncontrolled one, and disabledKeys or disabled to switch single tags off. Render tags from data with a plain .map(), as above.

Tags take Badge's colour pairs through color, an optional icon before the label, and size="xs" for 24px tags instead of 28px. The group renders label above the tags, hint below them, and emptyState in place of the tags when there are none.

Keyboard

  • Tab enters the list once and lands on the first tag.
  • ArrowLeft and ArrowRight move between tags and wrap at the ends; Home and End jump to the first and last.
  • Space and Enter toggle the focused tag.
  • Backspace and Delete remove the focused tag, or the whole selection when the focused tag is part of it, when the group has onRemove.
  • Escape clears the selection.

Source

"use client";

import { Button as ButtonPrimitive } from "@base-ui/react/button";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { RiCloseLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";

// A set of tags to filter by, pick from or prune. The source builds this on
// React Aria's TagGroup, which is a grid with a single tab stop: arrows move
// between tags, Space and Enter toggle selection, Backspace or Delete removes,
// Escape clears the selection, and the list announces removals while focused.
// That keyboard model and the grid/row/gridcell roles are rebuilt here on
// plain elements.
// Colours reuse Badge's tone tokens, which carry the same chip pairs the
// source shares between Chip and Tag, so they follow theme-cobalt. Every other
// token has a semantic fallback, so the tags also render without the theme.
const tagTone: Record<TagColor, string> = {
  blue: "bg-[var(--badge-blue-background,var(--muted))] text-[color:var(--badge-blue-foreground,var(--foreground))]",
  cyan: "bg-[var(--badge-cyan-background,var(--muted))] text-[color:var(--badge-cyan-foreground,var(--foreground))]",
  gray: "bg-[var(--surface-subtle,var(--muted))] text-foreground",
  lime: "bg-[var(--badge-lime-background,var(--muted))] text-[color:var(--badge-lime-foreground,var(--foreground))]",
  neutral:
    "bg-[var(--surface-strong,var(--muted))] text-[color:var(--foreground-secondary,var(--muted-foreground))]",
  orange:
    "bg-[var(--badge-orange-background,var(--muted))] text-[color:var(--badge-orange-foreground,var(--foreground))]",
  purple:
    "bg-[var(--badge-purple-background,var(--muted))] text-[color:var(--badge-purple-foreground,var(--foreground))]",
  rose: "bg-[var(--badge-rose-background,var(--muted))] text-[color:var(--badge-rose-foreground,var(--foreground))]",
  soft: "bg-[var(--surface-subtle,var(--muted))] text-[color:var(--foreground-secondary,var(--muted-foreground))]",
  yellow:
    "bg-[var(--badge-yellow-background,var(--muted))] text-[color:var(--badge-yellow-foreground,var(--foreground))]",
};

export type TagColor =
  | "orange"
  | "lime"
  | "rose"
  | "yellow"
  | "cyan"
  | "blue"
  | "purple"
  | "neutral"
  | "gray"
  | "soft";

export type TagKey = string;

/**
 * 28px tags (`default`) or 24px (`xs`), the source's medium and small.
 */
export type TagSize = "default" | "xs";

export type TagSelectionMode = "none" | "single" | "multiple";

export type TagGroupProps = Omit<
  React.ComponentProps<"div">,
  "children" | "onChange"
> & {
  /** Label rendered above the tags and wired to the list with aria-labelledby. */
  label?: React.ReactNode;
  /** Caption rendered below the tags and wired with aria-describedby. */
  hint?: React.ReactNode;
  /** Shown in place of the tags when there are none. */
  emptyState?: React.ReactNode;
  /** 28px tags by default, 24px with `xs`. */
  size?: TagSize;
  /** `none` renders static tags; `single` and `multiple` make them toggle. */
  selectionMode?: TagSelectionMode;
  /** Controlled selection. Pass an array or a Set. */
  selectedKeys?: Iterable<TagKey>;
  defaultSelectedKeys?: Iterable<TagKey>;
  onSelectionChange?: (keys: Set<TagKey>) => void;
  /** Adds a remove button to every tag and enables Backspace and Delete. */
  onRemove?: (keys: Set<TagKey>) => void;
  disabledKeys?: Iterable<TagKey>;
  children?: React.ReactNode;
};

interface TagGroupContextValue {
  allowsRemoving: boolean;
  disabledKeys: Set<TagKey>;
  firstKey: TagKey | undefined;
  focusedKey: TagKey | null;
  remove: (keys: Set<TagKey>) => void;
  selectionMode: TagSelectionMode;
  selectedKeys: Set<TagKey>;
  size: TagSize;
  toggle: (key: TagKey) => void;
  setFocusedKey: (key: TagKey | null) => void;
}

const TagGroupContext = React.createContext<TagGroupContextValue | null>(null);

function useTagGroupContext() {
  const context = React.useContext(TagGroupContext);
  if (!context) {
    throw new Error("Tag must be used inside a TagGroup");
  }
  return context;
}

// The list is a grid with one tab stop, so the tab order needs to know which
// tag comes first. Read it off the Tag elements' `id` props, descending into
// fragments but not into other components. Disabled tags are skipped because
// they are not tab stops.
function findFirstKey(
  children: React.ReactNode,
  disabledKeys: Set<TagKey>
): TagKey | undefined {
  let first: TagKey | undefined;
  const visit = (nodes: React.ReactNode) => {
    React.Children.forEach(nodes, (child) => {
      if (first !== undefined || !React.isValidElement(child)) {
        return;
      }
      const childProps = child.props as {
        children?: React.ReactNode;
        disabled?: boolean;
        id?: unknown;
      };
      if (child.type === React.Fragment) {
        visit(childProps.children);
        return;
      }
      if (typeof childProps.id !== "string" || childProps.disabled) {
        return;
      }
      if (disabledKeys.has(childProps.id)) {
        return;
      }
      first = childProps.id;
    });
  };
  visit(children);
  return first;
}

function TagGroup({
  label,
  hint,
  emptyState,
  size = "default",
  selectionMode = "none",
  selectedKeys,
  defaultSelectedKeys,
  onSelectionChange,
  onRemove,
  disabledKeys,
  children,
  className,
  "aria-label": ariaLabel,
  ...props
}: TagGroupProps) {
  const labelId = React.useId();
  const hintId = React.useId();
  const listRef = React.useRef<HTMLDivElement>(null);
  const isControlled = selectedKeys !== undefined;
  const [uncontrolledKeys, setUncontrolledKeys] = React.useState<Set<TagKey>>(
    () => (defaultSelectedKeys ? new Set(defaultSelectedKeys) : new Set())
  );
  const [focusedKey, setFocusedKey] = React.useState<TagKey | null>(null);
  const [focusWithin, setFocusWithin] = React.useState(false);
  // Where focus should land after a removal. Set on remove, consumed in the
  // effect below once the removed tag has left the DOM.
  const pendingFocusRef = React.useRef<number | null>(null);

  const selection = React.useMemo(
    () => (isControlled ? new Set(selectedKeys) : uncontrolledKeys),
    [isControlled, selectedKeys, uncontrolledKeys]
  );
  const disabled = React.useMemo(
    () => (disabledKeys ? new Set(disabledKeys) : new Set<TagKey>()),
    [disabledKeys]
  );
  const firstKey = React.useMemo(
    () => findFirstKey(children, disabled),
    [children, disabled]
  );

  const getTags = React.useCallback(
    () => [
      ...(listRef.current?.querySelectorAll<HTMLElement>(
        '[data-slot="tag"]:not([data-disabled])'
      ) ?? []),
    ],
    []
  );

  const updateSelection = React.useCallback(
    (next: Set<TagKey>) => {
      if (!isControlled) {
        setUncontrolledKeys(next);
      }
      onSelectionChange?.(next);
    },
    [isControlled, onSelectionChange]
  );

  const toggle = React.useCallback(
    (key: TagKey) => {
      if (selectionMode === "none") {
        return;
      }
      const next = new Set(selection);
      if (next.has(key)) {
        next.delete(key);
      } else {
        if (selectionMode === "single") {
          next.clear();
        }
        next.add(key);
      }
      updateSelection(next);
    },
    [selection, selectionMode, updateSelection]
  );

  const remove = React.useCallback(
    (keys: Set<TagKey>) => {
      if (!onRemove || keys.size === 0) {
        return;
      }
      const tags = getTags();
      const index = tags.findIndex(
        (tag) =>
          tag === document.activeElement || tag.contains(document.activeElement)
      );
      pendingFocusRef.current = index === -1 ? null : index;
      onRemove(keys);
    },
    [getTags, onRemove]
  );

  // After a removal, move focus to the tag that took its place, or to the list
  // itself when it is now empty.
  React.useEffect(() => {
    if (pendingFocusRef.current === null) {
      return;
    }
    const index = pendingFocusRef.current;
    pendingFocusRef.current = null;
    const tags = getTags();
    const next = tags[Math.min(index, tags.length - 1)];
    if (next) {
      next.focus();
    } else {
      listRef.current?.focus();
    }
  });

  const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    const target = (event.target as HTMLElement).closest<HTMLElement>(
      '[data-slot="tag"]'
    );
    if (!target) {
      return;
    }
    const tags = getTags();
    const index = tags.indexOf(target);
    const { key } = target.dataset;

    switch (event.key) {
      case "ArrowRight":
      case "ArrowLeft": {
        if (tags.length < 2) {
          return;
        }
        event.preventDefault();
        const step = event.key === "ArrowRight" ? 1 : -1;
        tags[(index + step + tags.length) % tags.length]?.focus();
        break;
      }
      case "Home": {
        event.preventDefault();
        tags[0]?.focus();
        break;
      }
      case "End": {
        event.preventDefault();
        tags.at(-1)?.focus();
        break;
      }
      case "Enter":
      case " ": {
        // The remove button owns Enter and Space while it holds focus.
        if (
          !key ||
          selectionMode === "none" ||
          (event.target as HTMLElement).closest('[data-slot="tag-remove"]')
        ) {
          return;
        }
        event.preventDefault();
        toggle(key);
        break;
      }
      case "Escape": {
        if (selectionMode === "none" || selection.size === 0) {
          return;
        }
        event.preventDefault();
        updateSelection(new Set());
        break;
      }
      case "Backspace":
      case "Delete": {
        if (!key || !onRemove) {
          return;
        }
        event.preventDefault();
        remove(selection.has(key) ? new Set(selection) : new Set([key]));
        break;
      }
      default: {
        break;
      }
    }
  };

  const isEmpty = React.Children.toArray(children).length === 0;

  return (
    <div
      data-slot="tag-group"
      className={cn("flex flex-col items-start gap-1.5", className)}
      {...props}
    >
      {label ? (
        <span
          data-slot="tag-group-label"
          id={labelId}
          className="flex cursor-default items-center gap-0.5 text-sm leading-5 font-medium text-foreground"
        >
          {label}
        </span>
      ) : null}
      {/* The role is always set ("grid", or "group" when empty); the rule cannot
          read a computed role. */}
      {/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions */}
      <div
        ref={listRef}
        data-slot="tag-group-list"
        data-empty={isEmpty ? "" : undefined}
        role={isEmpty ? "group" : "grid"}
        aria-label={ariaLabel}
        aria-labelledby={label ? labelId : undefined}
        aria-describedby={hint ? hintId : undefined}
        aria-multiselectable={selectionMode === "multiple" ? true : undefined}
        aria-atomic={false}
        aria-relevant="additions"
        aria-live={focusWithin ? "polite" : "off"}
        tabIndex={isEmpty ? 0 : -1}
        onFocus={() => setFocusWithin(true)}
        onBlur={(event) => {
          if (!event.currentTarget.contains(event.relatedTarget as Node)) {
            setFocusWithin(false);
          }
        }}
        onKeyDown={handleKeyDown}
        className="flex flex-wrap gap-1.5 outline-none"
      >
        <TagGroupContext.Provider
          value={{
            allowsRemoving: Boolean(onRemove),
            disabledKeys: disabled,
            firstKey,
            focusedKey,
            remove,
            selectedKeys: selection,
            selectionMode,
            setFocusedKey,
            size,
            toggle,
          }}
        >
          {isEmpty && emptyState ? (
            <span className="py-1 text-sm leading-5 text-[color:var(--foreground-faint,var(--muted-foreground))]">
              {emptyState}
            </span>
          ) : null}
          {children}
        </TagGroupContext.Provider>
      </div>
      {hint ? (
        <span
          data-slot="tag-group-hint"
          id={hintId}
          className="pt-px text-xs leading-4 font-medium tracking-[0.15px] text-[color:var(--icon-foreground,var(--muted-foreground))]"
        >
          {hint}
        </span>
      ) : null}
    </div>
  );
}

export type TagProps = Omit<
  React.ComponentProps<"div">,
  "children" | "color" | "id"
> & {
  /** Selection key. A generated id is used when omitted. */
  id?: TagKey;
  /** Badge's colour pairs. Default neutral. */
  color?: TagColor;
  /** Icon before the label. */
  icon?: React.ComponentType<{
    "aria-hidden"?: boolean | "true" | "false";
    className?: string;
  }>;
  /** Announced label when the children are not plain text. */
  textValue?: string;
  disabled?: boolean;
  render?: useRender.RenderProp;
  children?: React.ReactNode;
};

function Tag({
  id,
  color = "neutral",
  icon: Icon,
  textValue,
  disabled = false,
  className,
  children,
  render,
  ...props
}: TagProps) {
  const {
    allowsRemoving,
    disabledKeys,
    firstKey,
    focusedKey,
    remove,
    selectedKeys,
    selectionMode,
    setFocusedKey,
    size,
    toggle,
  } = useTagGroupContext();
  const generatedId = React.useId();
  const key = id ?? generatedId;
  const isSelected = selectedKeys.has(key);
  const isDisabled = disabled || disabledKeys.has(key);
  const allowsSelection = selectionMode !== "none";
  const label =
    textValue ?? (typeof children === "string" ? children : undefined);
  const tabbableKey = focusedKey ?? firstKey;
  const tabIndex =
    isDisabled || (tabbableKey !== undefined && tabbableKey !== key) ? -1 : 0;

  return useRender({
    defaultTagName: "div",
    props: mergeProps<"div">(
      {
        "aria-disabled": isDisabled || undefined,
        "aria-label": label,
        "aria-selected": allowsSelection ? isSelected : undefined,
        children: (
          <div role="gridcell" style={{ display: "contents" }}>
            {Icon ? (
              <Icon
                aria-hidden
                className={cn(
                  "shrink-0",
                  size === "xs" ? "size-3.5" : "size-4"
                )}
              />
            ) : null}
            <span className="truncate">{children}</span>
            {allowsRemoving ? (
              <ButtonPrimitive
                type="button"
                data-slot="tag-remove"
                tabIndex={-1}
                aria-label={label ? `Remove ${label}` : "Remove"}
                disabled={isDisabled}
                onClick={(event) => {
                  event.stopPropagation();
                  remove(new Set([key]));
                }}
                className={cn(
                  "flex shrink-0 cursor-pointer items-center justify-center rounded-[6px] opacity-60 outline-none",
                  "transition-[opacity,background-color] duration-150 ease-out hover:bg-[color-mix(in_oklab,currentColor_15%,transparent)] hover:opacity-100 motion-reduce:transition-none",
                  size === "xs" ? "size-4" : "size-5"
                )}
              >
                <RiCloseLine
                  aria-hidden
                  className={size === "xs" ? "size-3" : "size-3.5"}
                />
              </ButtonPrimitive>
            ) : null}
          </div>
        ),
        className: cn(
          "inline-flex max-w-full items-center gap-1 rounded-[8px] outline-none",
          "transition-[background-color,color,box-shadow,filter] duration-150 ease-out motion-reduce:transition-none",
          size === "xs"
            ? "h-6 px-1.5 text-xs leading-4 font-medium tracking-[0.15px]"
            : "h-7 px-2 text-sm leading-5 font-medium",
          allowsRemoving && (size === "xs" ? "pe-0.5" : "pe-1"),
          isSelected
            ? "bg-[var(--tag-group-selected-background,oklch(from_var(--primary)_calc(l_+_0.08)_c_h))] text-[color:var(--tag-group-selected-foreground,var(--primary-foreground))]"
            : cn(
                tagTone[color],
                !isDisabled &&
                  allowsSelection &&
                  (color === "neutral"
                    ? "hover:bg-[var(--trace-guide,var(--border))]"
                    : "hover:brightness-95")
              ),
          allowsSelection ? "cursor-pointer" : "cursor-default",
          "focus-visible:ring-2 focus-visible:ring-ring",
          isDisabled && "cursor-not-allowed opacity-50",
          className
        ),
        onClick: () => {
          if (isDisabled || !allowsSelection) {
            return;
          }
          toggle(key);
        },
        onFocus: () => setFocusedKey(key),
        role: "row",
        tabIndex,
      },
      props
    ),
    render,
    state: {
      color,
      disabled: isDisabled,
      key,
      selected: isSelected,
      size,
      slot: "tag",
    },
  });
}

export { Tag, TagGroup };