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.
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
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
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 6 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle ChipsProps aus components/ui/chips.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
variantoptionalChipsVariantinputfilterchoicesuggestion
itemsPflichtChipItem[]labelPflichtstringAccessible name of the set, e.g. "Recipients"
valueoptionalstring[]filter: the pressed values. choice: the first entry is the chosen one.
defaultValueoptionalstring[]onValueChangeoptional(value: string[]) => voidonRemoveoptional(value: string) => voidinput: runs after a chip shrank away. Without it the chips keep their own list.
clearableoptionalbooleaninput: show a button that removes every chip
onClearoptional() => voidonSelectoptional(value: string) => voidsuggestion: runs with the value of the pressed chip
dismissOnSelectoptionalbooleansuggestion: 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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| remove | Englisch Remove | Deutsch Entfernen |
| removed | Englisch Removed | Deutsch Entfernt |
| clearAll | Englisch Clear all | Deutsch Alle entfernen |
| cleared | Englisch All removed | Deutsch Alle entfernt |
| hint | Englisch Delete or Backspace removes it, the arrow keys move | Deutsch Entf oder Rücktaste entfernt, Pfeiltasten wechseln |
| selected | Englisch selected | Deutsch 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
'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.