React chips
Compact tokens a person presses where they read. Input chips to remove with Delete, filter chips with counts, choice chips as a listbox and suggestion chips that act and leave.
Small tokens that act on the spot
Chips are compact tokens pressed right where people read. Each kind here has its own job and its own semantics, so screen readers announce what a chip does, from removing a recipient to choosing exactly one time slot.
Recipients shows people as input chips with face or initials, the arrow keys move through the row, Delete or Backspace removes and the focus moves to the neighbour. Applied filters lists what is filtered above the results, each chip removes itself and the last button clears all. Filter chips turn several on at once, each a toggle button with aria-pressed, a check draws in and counts show what to expect. Choice chips pick exactly one of a set as a listbox, full slots stay visible and step aside. Suggestions sit under an assistant field, a press fills the field and the chip leaves the row.
Pick the kind by the job, tags only label and a multi select suits long lists, chips act on the spot. Keep chip labels to one or two words. Show counts on filter chips, then nobody picks a filter that leaves nothing.
Variants
Details
What Chips brings along, read from the registry and the source.
- Category
- Buttons
- Area
- Elements
- Variants
- 5
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/chips.tsx
- npm packages
- None
- Languages
- English and German, 6 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ChipsProps interface from components/ui/chips.tsx, with the comments from the code.
variantoptionalChipsVariantinputfilterchoicesuggestion
itemsrequiredChipItem[]labelrequiredstringAccessible 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
Built-in texts
Chips writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 6 texts
| Key | English | German |
|---|---|---|
| remove | English Remove | German Entfernen |
| removed | English Removed | German Entfernt |
| clearAll | English Clear all | German Alle entfernen |
| cleared | English All removed | German Alle entfernt |
| hint | English Delete or Backspace removes it, the arrow keys move | German Entf oder Rücktaste entfernt, Pfeiltasten wechseln |
| selected | English selected | German ausgewählt |
Accessibility
Read from the source of the component.
- Chips handles the keys Enter, Arrow up, Arrow down, Arrow left, Arrow right, Home, End, Backspace, Delete and Space itself.
- Sets aria-describedby, aria-disabled, aria-label, aria-live, aria-orientation, aria-pressed and aria-selected, so screen readers announce purpose and state.
- Uses the roles group, list, listbox, listitem and option.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- 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, 364 lines
'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>
);
}Frequent questions
How do I install Chips?
With aiw add @ai-website/chips. The command puts components/ui/chips.tsx into your project, the code is yours from then on. It brings along icon, avatar and tag. It needs no further npm packages.
Which variants does Chips have?
5 variants, Recipients, Applied filters, Filter chips, Choice chips and Suggestions. You pick one with the variant prop, for example variant="input".
Can I translate the texts of Chips?
Yes. Chips carries 6 texts of its own in English and German, such as “Remove”, “Removed” and “Clear all”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Chips?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Chips with aiw_components and install it with aiw_add @ai-website/chips. Name the variant in your prompt, for example variant="input", and the assistant writes your content straight into the props.