Zum Inhalt springen
UI Kit
Accent

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

2 Variants

Variants

Stile

Flächen, Statuspunkte, Icons, Links und entfernbare Tags.

Filter-Chips

Umschaltbare Chips mit Zahlen, die eine Liste filtern.

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

Props

Die Schnittstelle TagProps aus components/ui/tag.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • variantoptional
    TagVariant

    softoutlinesolidmuted

  • sizeoptional
    'sm' | 'md'
  • iconoptional
    IconName
  • dotoptional
    TagDot

    accentsuccesswarningdestructivemuted

    A small colored point in front, e.g. for status

  • onRemoveoptional
    () => void

    Shows a remove button. The tag text is used in its label. The tag shrinks away first, then this runs.

  • hrefoptional
    string

    Turns the tag into a link

  • childrenPflicht
    React.ReactNode

Eingebaute Texte

Diese Texte schreibt Tag selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 1 Texte anzeigen
removeEnglisch RemoveDeutsch 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
components/ui/tag.tsx
'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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert