Tag
Etiketten für Themen und Status, dazu Filter-Chips mit Zahlen. Ein Feld, das Wörter zu Tags macht, ist die Tag-Eingabe.
Eine von 5 Components in Data Display
Details
Was Tag mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Data Display
- Bereich
- Elements
- Variants
- 2
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/tag.tsx
- Bringt mit
- utilskiticon
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 1 eigener Text
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle TagProps aus components/ui/tag.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
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
childrenPflichtReact.ReactNode
Eingebaute Texte
Diese Texte schreibt Tag selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 1 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| remove | Englisch Remove | Deutsch Entfernen |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-label und aria-pressed, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle group.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 210 Zeilen
'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>
);
}Häufige Fragen
Wie installiere ich Tag?
Mit aiw add @ai-website/tag. Der Befehl legt die Datei components/ui/tag.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Tag?
2 Varianten, Stile und Filter-Chips.
Kann ich die Texte von Tag übersetzen?
Ja. Tag bringt 1 eigene Texte auf Deutsch und Englisch mit, etwa „Entfernen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Tag einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Tag mit aiw_components und installieren sie mit aiw_add @ai-website/tag. Nenn im Prompt die Variante, etwa variant="outline", dann schreibt der Assistent deine Inhalte direkt in die Props.