Skip to content
UI Kit
Accent color

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.

5 variantsOne of 9 components in Buttons

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

RecipientsNew in 1.2

People as input chips with face or initials. Tab into the row, move with the arrow keys, Delete or Backspace removes and the focus moves to the neighbour. Enter in the field adds a name.

Applied filtersNew in 1.2

What is filtered stands above the results as chips with an icon. Each one removes itself, the last button clears all and the count follows.

Filter chipsNew in 1.2

Several on at once, each a toggle button with aria-pressed. A check draws in on press, counts show what to expect, the list below follows.

Choice chipsNew in 1.2

Exactly one of a set, as a listbox: the arrow keys move, Space or Enter chooses. Full slots stay visible and step aside.

SuggestionsNew in 1.2

Prompts under an assistant field. A press fills the field and the chip leaves the row, on phones the row scrolls sideways with a fade.

Details

What Chips brings along, read from the registry and the source.

Category
Buttons
Variants
5
Version
1.0.0 · since 1.2.0
File
components/ui/chips.tsx
Brings along
utilskiticonavatartag
npm packages
None
Languages
English and German, 6 texts of its own
Studio
Adjust in code

Props

The ChipsProps interface from components/ui/chips.tsx, with the comments from the code.

  • variantoptional
    ChipsVariant

    inputfilterchoicesuggestion

  • itemsrequired
    ChipItem[]
  • labelrequired
    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

Built-in texts

Chips writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 6 texts
removeEnglish RemoveGerman Entfernen
removedEnglish RemovedGerman Entfernt
clearAllEnglish Clear allGerman Alle entfernen
clearedEnglish All removedGerman Alle entfernt
hintEnglish Delete or Backspace removes it, the arrow keys moveGerman Entf oder Rücktaste entfernt, Pfeiltasten wechseln
selectedEnglish selectedGerman 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
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>
  );
}

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.

Often searched

Jump to

Copied to the clipboard