Zum Inhalt springen
UI Kit
Accent

Chips für React

Kompakte Marken, die man direkt beim Lesen drückt. Eingabe-Chips zum Entfernen mit Entf, Filter-Chips mit Zählern, Auswahl-Chips als Listbox und Vorschlags-Chips, die handeln und gehen.

5 VariantsEine von 9 Components in Buttons

Kleine Marken, die sofort handeln

Chips sind kompakte Marken, die direkt dort gedrückt werden, wo Menschen lesen. Jede Art hier hat ihre eigene Aufgabe und eigene Semantik, Screenreader sagen also an, was ein Chip tut, vom Entfernen eines Empfängers bis zur Wahl genau einer Uhrzeit.

„Empfänger“ zeigt Personen als Eingabe-Chips mit Gesicht oder Initialen, die Pfeiltasten wandern durch die Reihe, Entf oder Rücktaste entfernt und der Fokus geht zum Nachbarn. „Aktive Filter“ zeigt, was gefiltert ist, über den Ergebnissen, jeder Chip entfernt sich selbst, der letzte Knopf löscht alle. „Filter-Chips“ schalten mehrere zugleich, jeder ein Umschaltknopf mit aria-pressed, ein Haken zeichnet sich und Zähler zeigen, was kommt. „Auswahl-Chips“ wählen genau einen aus einer Menge als Listbox, volle Zeiten bleiben sichtbar und treten zurück. „Vorschläge“ stehen unter einem Assistenzfeld, ein Druck füllt das Feld und der Chip verlässt die Reihe.

Wähle die Art nach der Aufgabe, Tags beschriften nur, eine Mehrfachauswahl passt zu langen Listen, Chips handeln sofort. Halte Chip-Texte bei ein oder zwei Wörtern. Zeig Zähler auf Filter-Chips, dann wählt niemand einen Filter, der nichts übrig lässt.

Variants

EmpfängerNeu in 1.2

Personen als Eingabe-Chips mit Gesicht oder Initialen. Mit Tab in die Reihe, mit den Pfeiltasten wechseln, Entf oder Rücktaste entfernt und der Fokus geht zum Nachbarn. Enter im Feld fügt einen Namen hinzu.

Aktive FilterNeu in 1.2

Was gefiltert ist, steht als Chips mit Symbol über den Ergebnissen. Jeder entfernt sich selbst, der letzte Knopf löscht alle, und die Zahl folgt.

Filter-ChipsNeu in 1.2

Mehrere zugleich, jeder ein Umschaltknopf mit aria-pressed. Beim Drücken zeichnet sich ein Haken, Zähler zeigen, was kommt, die Liste darunter folgt.

Auswahl-ChipsNeu in 1.2

Genau einer aus einer Menge, als Listbox: Pfeiltasten wechseln, Leertaste oder Enter wählt. Volle Zeiten bleiben sichtbar und treten zurück.

VorschlägeNeu in 1.2

Vorschläge unter einem Assistenzfeld. Ein Druck füllt das Feld und der Chip verlässt die Reihe, am Telefon scrollt die Reihe seitlich mit Auslauf.

Details

Was Chips mitbringt, aus Registry und Quellcode gelesen.

Kategorie
Buttons
Bereich
Elements
Variants
5
Version
1.0.0 · seit 1.2.0
Datei
components/ui/chips.tsx
Bringt mit
utilskiticonavatartag
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 6 eigene Texte
Studio
Im Code anpassen

Props

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

  • variantoptional
    ChipsVariant

    inputfilterchoicesuggestion

  • itemsPflicht
    ChipItem[]
  • labelPflicht
    string

    Accessible name of the set, e.g. "Recipients"

  • valueoptional
    string[]

    filter: the pressed values. choice: the first entry is the chosen one.

  • defaultValueoptional
    string[]
  • onValueChangeoptional
    (value: string[]) => void
  • onRemoveoptional
    (value: string) => void

    input: runs after a chip shrank away. Without it the chips keep their own list.

  • clearableoptional
    boolean

    input: show a button that removes every chip

  • onClearoptional
    () => void
  • onSelectoptional
    (value: string) => void

    suggestion: runs with the value of the pressed chip

  • dismissOnSelectoptional
    boolean

    suggestion: the pressed chip leaves the row

  • sizeoptional
    'sm' | 'md'
  • layoutoptional
    'wrap' | 'scroll'

    wrap: several lines. scroll: one line that scrolls sideways on narrow screens.

  • toneoptional
    'accent' | 'neutral'

    filter and choice: the pressed chip in the accent color or quietly raised

Eingebaute Texte

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

Alle 6 Texte anzeigen
removeEnglisch RemoveDeutsch Entfernen
removedEnglisch RemovedDeutsch Entfernt
clearAllEnglisch Clear allDeutsch Alle entfernen
clearedEnglisch All removedDeutsch Alle entfernt
hintEnglisch Delete or Backspace removes it, the arrow keys moveDeutsch Entf oder Rücktaste entfernt, Pfeiltasten wechseln
selectedEnglisch selectedDeutsch ausgewählt

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Chips reagiert selbst auf die Tasten Enter, Pfeil hoch, Pfeil runter, Pfeil links, Pfeil rechts, Pos1, Ende, Rücktaste, Entf und Leertaste.
  • Setzt aria-describedby, aria-disabled, aria-label, aria-live, aria-orientation, aria-pressed und aria-selected, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen group, list, listbox, listitem und option.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Zeigt bei Bedienung mit der Tastatur einen sichtbaren Fokusring.
  • 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, 364 Zeilen
components/ui/chips.tsx
'use client';

import { useEffect, useId, useRef, useState } from 'react';
import { cn, useLocale, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Avatar } from '@/components/ui/avatar';
import { shrinkAway } from '@/components/ui/tag';

/*
 * Chips: compact, interactive tokens in four jobs, each with its own semantics.
 *
 *   input       things already chosen (recipients, applied filters). Every chip is
 *               a stop for the arrow keys, Delete or Backspace removes it, focus
 *               moves on to the neighbour and the removal is announced.
 *   filter      switch several on and off (aria-pressed), with counts.
 *   choice      exactly one of a set, as a listbox (aria-selected).
 *   suggestion  one press acts and the chip leaves (prompts, quick replies).
 *
 * Tags only label, a multi select picks from a long list in a field. Chips are
 * what a person presses right where they read.
 */

export type ChipsVariant = 'input' | 'filter' | 'choice' | 'suggestion';

export interface ChipItem {
  value: string;
  label: string;
  icon?: IconName;
  /** A person: initials from the name, or a drawn image */
  avatar?: { name: string; image?: React.ReactNode };
  /** Quiet number after the label */
  count?: number;
  disabled?: boolean;
}

const TEXTS = {
  en: { remove: 'Remove', removed: 'Removed', clearAll: 'Clear all', cleared: 'All removed', hint: 'Delete or Backspace removes it, the arrow keys move', selected: 'selected' },
  de: { remove: 'Entfernen', removed: 'Entfernt', clearAll: 'Alle entfernen', cleared: 'Alle entfernt', hint: 'Entf oder Rücktaste entfernt, Pfeiltasten wechseln', selected: 'ausgewählt' },
};
type ChipsTexts = (typeof TEXTS)['en'];

export interface ChipsProps {
  variant?: ChipsVariant;
  items: ChipItem[];
  /** Accessible name of the set, e.g. "Recipients" */
  label: string;
  /** filter: the pressed values. choice: the first entry is the chosen one. */
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (value: string[]) => void;
  /** input: runs after a chip shrank away. Without it the chips keep their own list. */
  onRemove?: (value: string) => void;
  /** input: show a button that removes every chip */
  clearable?: boolean;
  onClear?: () => void;
  /** suggestion: runs with the value of the pressed chip */
  onSelect?: (value: string) => void;
  /** suggestion: the pressed chip leaves the row */
  dismissOnSelect?: boolean;
  size?: 'sm' | 'md';
  /** wrap: several lines. scroll: one line that scrolls sideways on narrow screens. */
  layout?: 'wrap' | 'scroll';
  /** scroll: on phones the row reaches into the padding of its surface by --chips-bleed (default 1rem), set it to that padding */
  /** filter and choice: the pressed chip in the accent color or quietly raised */
  tone?: 'accent' | 'neutral';
  labels?: Partial<ChipsTexts>;
  className?: string;
}

const SIZE = {
  sm: { chip: 'h-7 gap-1.5 px-2.5 text-[12px] after:-inset-y-2', icon: 13, avatar: '-ms-1.5', x: 'size-5 after:-inset-3', xi: 11 },
  md: { chip: 'h-8 gap-1.5 px-3 text-[13px]', icon: 14, avatar: '-ms-2', x: 'size-6 after:-inset-2.5', xi: 12 },
};

const BASE =
  'relative inline-flex shrink-0 cursor-pointer select-none items-center whitespace-nowrap rounded-full border font-medium leading-none outline-none transition-[background-color,border-color,color,box-shadow,scale,opacity] duration-200 ease-[var(--ease-kit)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring active:scale-[0.96] active:duration-100 motion-reduce:active:scale-100 aria-disabled:pointer-events-none aria-disabled:opacity-45 disabled:pointer-events-none disabled:opacity-45 ' +
  // The finger target reaches 44 px without making the chip itself taller
  'after:absolute after:inset-x-0 after:-inset-y-1.5 after:content-[""] after:rounded-full';

const IDLE = 'border-border-strong bg-background text-foreground shadow-[var(--shadow-sm)] hover:bg-accent';
const ON: Record<'accent' | 'neutral', string> = {
  accent: 'border-transparent bg-primary text-primary-foreground shadow-[inset_0_1px_0_rgb(255_255_255/0.16),var(--shadow-sm)]',
  neutral:
    'border-[color-mix(in_oklab,var(--foreground)_32%,var(--border-strong))] bg-[color-mix(in_oklab,var(--foreground)_9%,var(--background))] text-foreground shadow-[var(--shadow-sm)]',
};

/** A check that draws itself when a chip gets pressed */
function Check({ size }: { size: number }) {
  return (
    <svg viewBox="0 0 24 24" width={size} height={size} fill="none" aria-hidden className="shrink-0">
      <path
        d="m5 12.5 4.5 4.5L19 7.5"
        pathLength={1}
        stroke="currentColor"
        strokeWidth="2.4"
        strokeLinecap="round"
        strokeLinejoin="round"
        className="[stroke-dasharray:1] [stroke-dashoffset:0] transition-[stroke-dashoffset] duration-300 ease-[var(--ease-kit)] starting:[stroke-dashoffset:1] motion-reduce:transition-none"
      />
    </svg>
  );
}

function Lead({ item, size, pressed }: { item: ChipItem; size: 'sm' | 'md'; pressed?: boolean }) {
  const s = SIZE[size];
  if (item.avatar)
    return <Avatar name={item.avatar.name} image={item.avatar.image} alt="" size="xs" className={cn(s.avatar, size === 'sm' && 'size-5 text-[8px]')} />;
  if (pressed) return <Check size={s.icon} />;
  if (item.icon) return <Icon name={item.icon} size={s.icon} className="shrink-0 opacity-75" />;
  return null;
}

function Count({ n, on }: { n: number; on?: boolean }) {
  const locale = useLocale();
  return <span className={cn('tabular-nums', on ? 'opacity-80' : 'text-subtle-foreground')}>{new Intl.NumberFormat(locale === 'de' ? 'de-DE' : 'en-US').format(n)}</span>;
}

/** Moves focus between the chips of a row with the arrow keys, Home and End */
function roving(e: React.KeyboardEvent<HTMLElement>, selector: string) {
  const keys = ['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Home', 'End'];
  if (!keys.includes(e.key)) return false;
  const all = [...e.currentTarget.querySelectorAll<HTMLElement>(selector)].filter((el) => el.getAttribute('aria-disabled') !== 'true' && !(el as HTMLButtonElement).disabled);
  const i = all.indexOf(document.activeElement as HTMLElement);
  if (i < 0) return false;
  e.preventDefault();
  const fwd = e.key === 'ArrowRight' || e.key === 'ArrowDown';
  const next = e.key === 'Home' ? 0 : e.key === 'End' ? all.length - 1 : (i + (fwd ? 1 : -1) + all.length) % all.length;
  all[next]?.focus();
  all[next]?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
  return true;
}

/** Compact interactive tokens: input chips to remove, filter chips to toggle, choice chips to pick one, suggestion chips to act. */
export function Chips({
  variant = 'filter',
  items,
  label,
  value,
  defaultValue,
  onValueChange,
  onRemove,
  clearable,
  onClear,
  onSelect,
  dismissOnSelect,
  size = 'md',
  layout = 'wrap',
  tone = 'accent',
  labels,
  className,
}: ChipsProps) {
  const t = useTexts(TEXTS, labels);
  const hintId = useId();
  const s = SIZE[size];

  // Own list for input and suggestion chips, refreshed when the items change from outside
  const signature = items.map((i) => `${i.value}\u0001${i.label}`).join('\u0002');
  const [list, setList] = useState(items);
  useEffect(() => setList(items), [signature]); // eslint-disable-line react-hooks/exhaustive-deps

  const [inner, setInner] = useState<string[]>(defaultValue ?? (variant === 'choice' && items[0] ? [items[0].value] : []));
  const selected = value ?? inner;
  const set = (next: string[]) => {
    if (value === undefined) setInner(next);
    onValueChange?.(next);
  };

  const [announce, setAnnounce] = useState('');
  const [focusIdx, setFocusIdx] = useState(0);
  const rowRef = useRef<HTMLDivElement>(null);
  const scroll = layout === 'scroll';
  const rowClass = cn(
    'flex items-center gap-2',
    scroll ? 'kit-no-scrollbar -my-3 flex-nowrap overflow-x-auto py-3 max-sm:-mx-[var(--chips-bleed,1rem)] max-sm:px-[var(--chips-bleed,1rem)] max-sm:kit-fade-end sm:flex-wrap sm:overflow-visible' : 'flex-wrap gap-y-2.5',
    className,
  );

  /* ---------- input ---------- */
  if (variant === 'input') {
    const remove = (v: string, el: HTMLElement | null, moveFocus: boolean) => {
      const idx = list.findIndex((x) => x.value === v);
      const item = list[idx];
      const chips = [...(rowRef.current?.querySelectorAll<HTMLElement>('[data-chip]') ?? [])];
      const neighbour = chips[idx + 1] ?? chips[idx - 1];
      shrinkAway(el, () => {
        setList((l) => l.filter((x) => x.value !== v));
        setAnnounce(`${t.removed}: ${item?.label ?? ''}`);
        onRemove?.(v);
        if (moveFocus) requestAnimationFrame(() => (neighbour?.isConnected ? neighbour : rowRef.current?.querySelector<HTMLElement>('[data-chip], [data-clear]'))?.focus());
        setFocusIdx(Math.max(0, Math.min(idx, list.length - 2)));
      });
    };
    return (
      <div className={cn(scroll ? '' : 'w-full')}>
        <div
          ref={rowRef}
          role="list"
          aria-label={label}
          onKeyDown={(e) => {
            if (roving(e, '[data-chip], [data-clear]')) return;
          }}
          className={rowClass}
        >
          {list.map((item, i) => (
            <div role="listitem" key={item.value} className="flex shrink-0">
              <span
                data-chip
                tabIndex={i === Math.min(focusIdx, list.length - 1) ? 0 : -1}
                aria-describedby={hintId}
                aria-disabled={item.disabled || undefined}
                onFocus={() => setFocusIdx(i)}
                onKeyDown={(e) => {
                  if ((e.key === 'Delete' || e.key === 'Backspace') && !item.disabled) {
                    e.preventDefault();
                    remove(item.value, e.currentTarget, true);
                  }
                }}
                className={cn(BASE, 'cursor-default pe-1', s.chip, IDLE, 'hover:bg-background focus-visible:bg-accent')}
              >
                <Lead item={item} size={size} />
                <span className="max-w-[14rem] truncate">{item.label}</span>
                {item.count !== undefined && <Count n={item.count} />}
                <button
                  type="button"
                  tabIndex={-1}
                  aria-label={`${t.remove}: ${item.label}`}
                  disabled={item.disabled}
                  onClick={(e) => remove(item.value, e.currentTarget.parentElement, false)}
                  className={cn(
                    // Above the finger target of the chip itself, which is drawn after it
                    'relative z-10 grid shrink-0 cursor-pointer place-items-center rounded-full text-muted-foreground transition-[background-color,color] hover:bg-[color-mix(in_oklab,var(--foreground)_12%,transparent)] hover:text-foreground after:absolute after:content-[""]',
                    s.x,
                  )}
                >
                  <Icon name="x" size={s.xi} strokeWidth={2.2} />
                </button>
              </span>
            </div>
          ))}
          {clearable && list.length > 1 && (
            <div role="listitem" className="flex shrink-0">
              <button
                type="button"
                data-clear
                onClick={() => {
                  setList([]);
                  setAnnounce(t.cleared);
                  onClear?.();
                }}
                className={cn(BASE, s.chip, 'border-transparent bg-transparent px-1.5 text-muted-foreground underline-offset-4 shadow-none hover:text-foreground hover:underline')}
              >
                {t.clearAll}
              </button>
            </div>
          )}
        </div>
        <span id={hintId} className="sr-only">
          {t.hint}
        </span>
        <span className="sr-only" aria-live="polite">
          {announce}
        </span>
      </div>
    );
  }

  /* ---------- choice: a listbox, one chosen ---------- */
  if (variant === 'choice') {
    const chosen = selected[0];
    const activeIdx = Math.max(0, list.findIndex((x) => x.value === chosen));
    return (
      <div
        ref={rowRef}
        role="listbox"
        aria-label={label}
        aria-orientation="horizontal"
        onKeyDown={(e) => roving(e, '[role="option"]')}
        className={rowClass}
      >
        {list.map((item, i) => {
          const on = item.value === chosen;
          const pick = () => !item.disabled && set([item.value]);
          return (
            <div
              key={item.value}
              role="option"
              aria-selected={on}
              aria-disabled={item.disabled || undefined}
              tabIndex={i === activeIdx ? 0 : -1}
              onClick={pick}
              onKeyDown={(e) => {
                if (e.key === ' ' || e.key === 'Enter') {
                  e.preventDefault();
                  pick();
                }
              }}
              className={cn(BASE, s.chip, on ? ON[tone] : IDLE)}
            >
              <Lead item={item} size={size} />
              {item.label}
              {item.count !== undefined && <Count n={item.count} on={on && tone === 'accent'} />}
            </div>
          );
        })}
      </div>
    );
  }

  /* ---------- suggestion: press acts, the chip may leave ---------- */
  if (variant === 'suggestion') {
    return (
      <div ref={rowRef} role="group" aria-label={label} onKeyDown={(e) => roving(e, 'button')} className={rowClass}>
        {list.map((item) => (
          <button
            key={item.value}
            type="button"
            disabled={item.disabled}
            onClick={(e) => {
              onSelect?.(item.value);
              if (!dismissOnSelect) return;
              const el = e.currentTarget;
              const next = (el.nextElementSibling ?? el.previousElementSibling) as HTMLElement | null;
              shrinkAway(el, () => {
                setList((l) => l.filter((x) => x.value !== item.value));
                requestAnimationFrame(() => next?.focus());
              });
            }}
            className={cn(
              BASE,
              s.chip,
              'border-[color-mix(in_oklab,var(--primary)_28%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_6%,var(--background))] text-foreground hover:border-[color-mix(in_oklab,var(--primary)_50%,var(--border))] hover:bg-[color-mix(in_oklab,var(--primary)_11%,var(--background))]',
            )}
          >
            <Icon name={item.icon ?? 'sparkle'} size={s.icon} className="shrink-0 text-primary" />
            {item.label}
          </button>
        ))}
      </div>
    );
  }

  /* ---------- filter: toggle buttons ---------- */
  return (
    <div ref={rowRef} role="group" aria-label={label} onKeyDown={(e) => roving(e, 'button')} className={rowClass}>
      {list.map((item) => {
        const on = selected.includes(item.value);
        return (
          <button
            key={item.value}
            type="button"
            aria-pressed={on}
            disabled={item.disabled}
            onClick={() => set(on ? selected.filter((v) => v !== item.value) : [...selected, item.value])}
            className={cn(BASE, s.chip, on ? ON[tone] : IDLE)}
          >
            <Lead item={item} size={size} pressed={on && !item.avatar} />
            {item.label}
            {item.count !== undefined && <Count n={item.count} on={on && tone === 'accent'} />}
          </button>
        );
      })}
    </div>
  );
}

Häufige Fragen

Wie installiere ich Chips?

Mit aiw add @ai-website/chips. Der Befehl legt die Datei components/ui/chips.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind icon, avatar und tag. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Chips?

5 Varianten, Empfänger, Aktive Filter, Filter-Chips, Auswahl-Chips und Vorschläge. Du wählst sie mit der Prop variant, zum Beispiel variant="input".

Kann ich die Texte von Chips übersetzen?

Ja. Chips bringt 6 eigene Texte auf Deutsch und Englisch mit, etwa „Entfernen“, „Entfernt“ und „Alle entfernen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Chips einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Chips mit aiw_components und installieren sie mit aiw_add @ai-website/chips. Nenn im Prompt die Variante, etwa variant="input", dann schreibt der Assistent deine Inhalte direkt in die Props.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert