Tag
Labels for topics and status, plus filter chips with counts. For a field that turns words into tags, see Tag input.
One of 5 components in Data display
Details
What Tag brings along, read from the registry and the source.
- Category
- Data display
- Area
- Elements
- Variants
- 2
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/tag.tsx
- Brings along
- utilskiticon
- npm packages
- None
- Languages
- English and German, 1 text of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The TagProps interface from components/ui/tag.tsx, with the comments from the code.
variantoptionalTagVariantsoftoutlinesolidmuted
sizeoptional'sm' | 'md'iconoptionalIconNamedotoptionalTagDotaccentsuccesswarningdestructivemuted
A small colored point in front, e.g. for status
onRemoveoptional() => voidShows a remove button. The tag text is used in its label. The tag shrinks away first, then this runs.
hrefoptionalstringTurns the tag into a link
childrenrequiredReact.ReactNode
Built-in texts
Tag writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 1 texts
| Key | English | German |
|---|---|---|
| remove | English Remove | German Entfernen |
Accessibility
Read from the source of the component.
- Sets aria-label and aria-pressed, so screen readers announce purpose and state.
- Uses the role group.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 210 lines
'use client';
import { useEffect, useRef } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
export type TagVariant = 'soft' | 'outline' | 'solid' | 'muted';
export type TagDot = 'accent' | 'success' | 'warning' | 'destructive' | 'muted';
const VARIANT: Record<TagVariant, string> = {
soft: 'bg-[color-mix(in_oklab,var(--primary)_11%,transparent)] text-[color-mix(in_oklab,var(--primary)_82%,var(--foreground))]',
outline: 'border border-border-strong bg-background text-foreground',
solid: 'bg-primary text-primary-foreground',
muted: 'bg-muted text-muted-foreground',
};
const DOT: Record<TagDot, string> = {
accent: 'bg-primary',
success: 'bg-success',
warning: 'bg-warning',
destructive: 'bg-destructive',
muted: 'bg-subtle-foreground',
};
const SIZE = {
sm: 'h-6 gap-1 rounded-md px-2 text-[12px]',
md: 'h-8 gap-1.5 rounded-lg px-2.5 text-[13px]',
};
const TEXTS = {
en: { remove: 'Remove' },
de: { remove: 'Entfernen' },
};
type TagTexts = (typeof TEXTS)['en'];
/* ------------------------------------------------------------------ */
/* Motion */
/* ------------------------------------------------------------------ */
const EASE = 'cubic-bezier(0.2,0.7,0.2,1)';
const canAnimate = (el: Element | null): el is HTMLElement => Boolean(el) && !prefersReducedMotion() && typeof (el as HTMLElement).animate === 'function';
/*
* Tags that are on the page from the start just stand there. Tags that
* arrive later (added by the visitor) pop in. The first effect of any tag
* marks the moment the page came alive, later mounts count as arrivals.
*/
let alive = 0;
/** ms since the first tag effect ran on this page */
function sinceAlive() {
const now = performance.now();
if (!alive) alive = now;
return now - alive;
}
function useArrival<T extends HTMLElement>() {
const ref = useRef<T>(null);
useEffect(() => {
const el = ref.current;
if (sinceAlive() < 400 || !canAnimate(el)) return;
el.animate(
[
{ transform: 'scale(0.6)', opacity: 0 },
{ transform: 'scale(1.06)', opacity: 1, offset: 0.6 },
{ transform: 'scale(1)', opacity: 1 },
],
{ duration: 380, easing: EASE },
);
}, []);
return ref;
}
/** Shrinks the element away, then runs done. Without motion done runs at once. */
export function shrinkAway(el: HTMLElement | null, done: () => void) {
if (!canAnimate(el)) return done();
const a = el.animate([{ transform: 'scale(1)', opacity: 1 }, { transform: 'scale(0.6)', opacity: 0 }], { duration: 180, easing: 'cubic-bezier(0.4,0,1,1)', fill: 'forwards' });
a.onfinish = () => {
done();
// Still there (the owner kept it, e.g. after a confirm): bring it back
requestAnimationFrame(() => requestAnimationFrame(() => el.isConnected && a.cancel()));
};
}
/** A check that draws itself when it appears after the page came alive */
function DrawnCheck({ size }: { size: number }) {
const ref = useRef<SVGPathElement>(null);
useEffect(() => {
const el = ref.current;
if (sinceAlive() < 400 || !el || prefersReducedMotion() || typeof el.animate !== 'function') return;
el.animate([{ strokeDashoffset: 1 }, { strokeDashoffset: 0 }], { duration: 360, easing: EASE });
}, []);
return (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" aria-hidden className="shrink-0">
<path ref={ref} d="m5 12.5 4.5 4.5L19 7.5" pathLength={1} strokeDasharray="1 1" strokeDashoffset={0} stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export interface TagProps {
variant?: TagVariant;
size?: 'sm' | 'md';
icon?: IconName;
/** A small colored point in front, e.g. for status */
dot?: TagDot;
/** Shows a remove button. The tag text is used in its label. The tag shrinks away first, then this runs. */
onRemove?: () => void;
/** Turns the tag into a link */
href?: string;
labels?: Partial<TagTexts>;
className?: string;
children: React.ReactNode;
}
/** A label for topics, attributes and status. Removable or a link. Tags added later pop in, removed ones shrink away. */
export function Tag({ variant = 'soft', size = 'md', icon, dot, onRemove, href, labels, className, children }: TagProps) {
const t = useTexts(TEXTS, labels);
const ref = useArrival<HTMLElement>();
const cls = cn('inline-flex max-w-full shrink-0 items-center whitespace-nowrap font-medium leading-none', SIZE[size], VARIANT[variant], onRemove && (size === 'sm' ? 'pr-0.5' : 'pr-1'), className);
const inner = (
<>
{dot && <span aria-hidden className={cn('size-1.5 shrink-0 rounded-full', DOT[dot])} />}
{icon && <Icon name={icon} size={size === 'sm' ? 12 : 14} className="opacity-80" />}
<span className="truncate">{children}</span>
</>
);
if (href) {
return (
<a ref={ref as React.Ref<HTMLAnchorElement>} href={href} className={cn(cls, 'relative transition-[background-color,border-color,color,translate] duration-200 hover:-translate-y-px motion-reduce:hover:translate-y-0 after:absolute after:inset-x-0 after:-inset-y-2.5 after:content-[""] hover:border-foreground/30 hover:bg-accent hover:text-foreground')}>
{inner}
</a>
);
}
return (
<span ref={ref as React.Ref<HTMLSpanElement>} className={cls}>
{inner}
{onRemove && (
<button
type="button"
onClick={(e) => {
// Focus moves to a neighbour once the tag is gone, so the keyboard does not fall back to the page start
const host = e.currentTarget.parentElement;
const next = (host?.nextElementSibling ?? host?.previousElementSibling)?.querySelector<HTMLElement>('button, a');
shrinkAway(host, () => {
onRemove();
if (document.activeElement === document.body || !document.activeElement) next?.focus();
});
}}
aria-label={`${t.remove}: ${typeof children === 'string' ? children : ''}`.replace(/: $/, '')}
className={cn(
'relative ml-0.5 grid shrink-0 place-items-center rounded-[5px] opacity-70 transition-[opacity,background-color] hover:bg-[color-mix(in_oklab,currentColor_14%,transparent)] hover:opacity-100 after:absolute after:-inset-2 after:content-[""]',
size === 'sm' ? 'size-5' : 'size-6',
)}
>
<Icon name="x" size={size === 'sm' ? 11 : 13} strokeWidth={2} />
</button>
)}
</span>
);
}
/* ------------------------------------------------------------------ */
export interface TagOption {
value: string;
label: string;
count?: number;
icon?: IconName;
}
export interface TagGroupProps {
options: TagOption[];
value: string[];
onChange: (value: string[]) => void;
/** One choice at a time, like tabs */
single?: boolean;
/** Accessible name of the group, e.g. "Filter by diet" */
label: string;
className?: string;
}
/** Filter chips: toggle buttons with optional counts. A press dips the chip, the check draws itself. */
export function TagGroup({ options, value, onChange, single, label, className }: TagGroupProps) {
useEffect(() => void sinceAlive(), []);
const toggle = (v: string) => {
if (single) onChange(value[0] === v ? [] : [v]);
else onChange(value.includes(v) ? value.filter((x) => x !== v) : [...value, v]);
};
return (
<div role="group" aria-label={label} className={cn('flex flex-wrap gap-2', className)}>
{options.map((o) => {
const on = value.includes(o.value);
return (
<button
key={o.value}
type="button"
aria-pressed={on}
onClick={() => toggle(o.value)}
className={cn(
'inline-flex min-h-11 items-center gap-1.5 rounded-lg border px-3.5 text-[13px] font-medium transition-[background-color,border-color,color,box-shadow,scale] duration-200 ease-[var(--ease-kit)] active:scale-[0.95] active:duration-100 motion-reduce:active:scale-100 sm:min-h-9',
on
? 'border-transparent bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.16),var(--shadow-sm)]'
: 'border-border-strong bg-background text-foreground hover:bg-accent',
)}
>
{on ? <DrawnCheck size={14} /> : o.icon && <Icon name={o.icon} size={14} className="text-muted-foreground" />}
{o.label}
{o.count !== undefined && <span className={cn('tabular-nums', on ? 'opacity-75' : 'text-subtle-foreground')}>{o.count}</span>}
</button>
);
})}
</div>
);
}Frequent questions
How do I install Tag?
With aiw add @ai-website/tag. The command puts components/ui/tag.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.
Which variants does Tag have?
2 variants, Styles and Filter chips.
Can I translate the texts of Tag?
Yes. Tag carries 1 texts of its own in English and German, such as “Remove”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Tag?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Tag with aiw_components and install it with aiw_add @ai-website/tag. Name the variant in your prompt, for example variant="outline", and the assistant writes your content straight into the props.