- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
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
Tabenters the list once and lands on the first tag.ArrowLeftandArrowRightmove between tags and wrap at the ends;HomeandEndjump to the first and last.SpaceandEntertoggle the focused tag.BackspaceandDeleteremove the focused tag, or the whole selection when the focused tag is part of it, when the group hasonRemove.Escapeclears 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 };