Lunia DS

Actions and inputs

Tag input

A field that turns typed or pasted values into chips that grow around the letters, glide to their new place when the list changes, and fly into an existing chip when a value is already there.

Loading preview

An "Invite people" card with the field labelled "Invite by email", hint "Separate addresses with a comma.", max 8, collapse 2, and suggestions for 5 team members with avatars. Lookup turns known addresses into names after 700ms; addresses without "@" show "Enter a full email address." Replay clears the field, types "maya@northwind.io" and a comma, then pastes "leo@northwind.io, ana@northwind.io, maya@northwind.io, sam" (one duplicate, one not valid).

Installation

Run this in your app after the one-time setup on the Getting started page.

$ npx @ganisigit/lunia-ds add tag-input

Usage

The demo above, as code.

demo.tsx
'use client';

import * as React from 'react';
import { Button } from '../button/button';
import { TagInput, type TagInputHandle, type TagInputSuggestion } from './tag-input';

// Self-drawn placeholder portraits (a head and shoulders on a tinted card), so the demo needs no photos.
const portrait = (bg: string, fg: string) =>
  `data:image/svg+xml,${encodeURIComponent(
    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" fill="${bg}"/><circle cx="32" cy="26" r="11" fill="${fg}"/><path d="M10 64c2-13 11-21 22-21s20 8 22 21z" fill="${fg}"/></svg>`,
  )}`;

const TEAM: TagInputSuggestion[] = [
  { value: 'maya@northwind.io', label: 'Maya Chen', image: portrait('#d9ccf5', '#8b6fd6') },
  { value: 'leo@northwind.io', label: 'Leo Park', image: portrait('#f7d9c4', '#d08a5c') },
  { value: 'ana@northwind.io', label: 'Ana Ruiz' },
  { value: 'marco@northwind.io', label: 'Marco Diaz' },
  { value: 'sam.okafor@northwind.io', label: 'Sam Okafor', image: portrait('#c9e4dc', '#5f9e8a') },
];

// How long the pretend directory takes to find a name (ms).
const LOOKUP = 700;

const validate = (value: string) => (value.includes('@') ? null : 'Enter a full email address.');
const normalize = (value: string) => value.toLowerCase();
const resolve = (value: string) =>
  new Promise<{ label?: string; image?: string } | null>((done) => {
    const person = TEAM.find((p) => p.value === value);
    setTimeout(() => done(person ? { label: person.label, image: person.image } : null), LOOKUP);
  });

/** Types text into the field the way a person would, one letter at a time. */
function typeInto(input: HTMLInputElement, text: string, later: (ms: number, run: () => void) => void) {
  const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
  for (let i = 0; i < text.length; i++)
    later(i * 45, () => {
      set?.call(input, input.value + (text[i] ?? ''));
      input.dispatchEvent(new Event('input', { bubbles: true }));
    });
}

export default function TagInputDemo({ replay = 0 }: { replay?: number }) {
  const field = React.useRef<TagInputHandle>(null);
  const card = React.useRef<HTMLDivElement>(null);
  const timers = React.useRef<number[]>([]);

  const later = React.useCallback((ms: number, run: () => void) => {
    timers.current.push(window.setTimeout(run, ms));
  }, []);

  React.useEffect(() => {
    if (replay === 0) return;
    for (const timer of timers.current) window.clearTimeout(timer);
    timers.current = [];
    field.current?.clear();
    const input = card.current?.querySelector<HTMLInputElement>('input[role="combobox"]');
    if (!input) return;
    later(400, () => {
      field.current?.focus();
      typeInto(input, 'maya@northwind.io,', later);
    });
    later(2600, () => field.current?.paste('leo@northwind.io, ana@northwind.io, maya@northwind.io, sam'));
  }, [replay, later]);

  React.useEffect(
    () => () => {
      for (const timer of timers.current) window.clearTimeout(timer);
    },
    [],
  );

  return (
    <div
      ref={card}
      className="flex w-full max-w-[448px] flex-col gap-6 rounded-2xl border border-secondary bg-primary p-8 shadow-xs"
    >
      <div className="flex flex-col gap-1">
        <h3 className="text-lg font-semibold text-primary">Invite people</h3>
        <p className="text-sm text-tertiary">Each person gets an email with a link to join.</p>
      </div>
      <TagInput
        ref={field}
        label="Invite by email"
        placeholder="name@company.com"
        hint="Separate addresses with a comma."
        max={8}
        collapse={2}
        suggestions={TEAM}
        validate={validate}
        normalize={normalize}
        resolve={resolve}
        name="invites"
      />
      <div className="flex justify-end gap-3">
        <Button hierarchy="secondary">Cancel</Button>
        <Button>Send invites</Button>
      </div>
    </div>
  );
}

API

Parts: root, field (border, height eases), chip list (role list), chip (shell + extra slot + label + remove button), chip extra slot (spinner, error icon, avatar or initials), chip label (text morph), input (combobox), more pill (+N count, number roll), suggestions panel (listbox), suggestion highlight, suggestion row (avatar, name, second line), create row (Add "text"), line (hint, error or notice), ghost layer (leaving, flying and carried copies), keyboard help (screen reader only), status (live region, screen reader only).

PropTypeDefaultDescription
valueTagInputTag[]undefinedControlled tags: { id, value, label?, image?, error? }.
defaultValueTagInputTag[][]Starting tags when uncontrolled.
validate(value: string) => string | null | undefinedundefinedReturn a message to mark a value as not valid; the chip turns red and stays until it is fixed or removed.
resolve(value: string) => Promise<{ label?: string; image?: string } | null>undefinedLook up a value after it is added, for example an email address to a name and picture.
suggestionsTagInputSuggestion[] | ((query: string) => TagInputSuggestion[] | Promise<TagInputSuggestion[]>)undefinedA list to filter, or a function that finds matches. Shows up to 6 rows: { value, label?, image?, description? }.
normalize(value: string) => string(v) => vApplied to each value before it is checked and compared, for example lower case for email addresses.
separatorsRegExp/[,;\n]+/What splits typed and pasted text into values.
maxnumberundefinedLargest number of tags.
limitMessagestring"Tag limit reached"Shown when a value does not fit under max.
createLabel((text: string) => string) | null(text) => `Add "${text}"`Label of the suggestion row that adds the typed text as it is; null hides the row.
collapsenumberundefinedLines to show while the field is not focused; the other chips fold into a +N pill.
labelstring"Add tags"Accessible name of the field.
placeholderstringundefinedShown while there are no tags.
hintstringundefinedA line under the field; errors and notices replace it while they last.
namestringundefinedForm field name; submits the valid values separated by commas.
disabledbooleanfalseTurns the field off.
EventDescription
valueChangeFires with the new tag list after each add, remove, edit or lookup.

Motion

Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.

IDTriggerElementPropertyFrom → toTimingReduced motion
TI1focus the inputfieldborder-colorborder-primary → border-brandduration-sm (200)ease-outSwaps at once
TI2type text (suggestions found)suggestions panelclip-path (unfolds down from the field edge)inset bottom 100% → inset bottom 0duration-lg (320)ease-outShows at once
TI3type text (suggestions found)suggestions panelopacity0 → 1duration-sm (200)ease-outShows at once
TI4Arrow keys or pointer over a rowsuggestion highlighttransform translateY + heightprevious row → active rowduration-md (260)ease-outMoves at once
TI5first row becomes active, or none is activesuggestion highlightopacity0 → 1duration-xs (160)ease-outShows at once
TI6Enter, Tab or a separator after typed textchip shellclip-path + opacity (grows from the outline of the typed letters; the letters stay where they were typed)inset 4px around the letters, 0 → full pill, 1duration-3xl (460)ease-outShows at once
TI7chip addedchip extra slotopacity + transform scale + filter blur0, scale-swap, blur-swap → 1, 1, 0duration-lg (320)ease-outdelay 140msShows at once
TI8paste a list, or type several separated valuesnew chipstransform (from the caret) + scale + opacity + filter blurcaret point, 0.86, 0, blur-text → own place, 1, 1, 0duration-4xl (520)ease-outdelay stagger-step x chipShows at once
TI9pick a suggestioncarried label copytransform translate + scale (from the row label onto the chip label)suggestion row → chip labelduration-3xl (460)ease-outMoves at once
TI10any change to the list (add, remove, edit, lookup, line wrap)every chip that moved + inputtransform translate (additive offset that decays to 0)old place → new placeduration-2xl (420)ease-outMoves at once
TI11chip width changes (name arrives, avatar opens)chip shellwidthdrawn width → new widthduration-2xl (420)ease-outResizes at once
TI12line count changes, or collapsefieldheightold height → new heightduration-2xl (420)ease-outResizes at once
TI13value already added (typed or picked)flying copytransform translate + scale + opacity + filter blur (flies into its twin)where it was typed, 1, 1, 0 → twin center, 0.92, 0, blur-swapduration-5xl (640)ease-outHides at once
TI14value already added (pasted)flying copyas TI13, but it first fades in where it would have landed0.86, 0, blur-text → twin center, 0.92, 0, blur-swapduration-6xl (900)ease-outdelay stagger-step x chipHides at once
TI15flying copy landstwin chiptransform scale bump1 → 1.08, then 1duration-2xl (420)ease-throwStays still
TI16flying copy landstwin chip shellbox-shadow ring flash (holds 40%, then fades)focus color → border-primaryduration-6xl (900)ease-outSwaps at once
TI17value is not validchiptransform translateX shake0 → -4px, 4px, -2px, 0duration-xl (380)ease-exitdelay 380msStays still
TI18value does not fit under maxfieldtransform translateX shake (as TI17)0 → -4px, 4px, -2px, 0duration-xl (380)ease-exitStays still
TI19lookup starts or endschip extra slot iconsopacity + filter blur (swap spinner, error icon, avatar)1, 0 → 0, blur-swapduration-md (260)ease-outSwaps at once
TI20lookup starts or endschip extra slot iconstransform scale1 → scale-swapduration-xl (380)ease-outSwaps at once
TI21while looking upspinnertransform rotate0deg → 360deg, repeatsduration-6xl (900)ease-linearNo loop
TI22lookup finds a namechip labeltext morph (address to name)value → labelduration-3xl (460)ease-outSwaps at once
TI23focus a chip (Selected)chip shellbackground-color + box-shadow ringbg-primary, border-primary → bg-primary_hover, focus color at 70%duration-sm (200)ease-outSwaps at once
TI24hover the remove buttonremove buttonbackground-color, colortransparent, fg-tertiary → bg-primary_hover, fg-tertiary_hoverduration-xs (160)ease-outSwaps at once
TI25remove a chip (button, Delete, or clear)leaving copyopacity + transform scale + filter blur1, 1, 0 → 0, 0.86, blur-textduration-md (260)ease-outHides at once
TI26Backspace on a selected chip (edit)leaving shellclip-path + opacity (shrinks round the letters)full pill, 1 → inset 4px around the letters, 0duration-xl (380)ease-outHides at once
TI27Backspace on a selected chip (edit)leaving letters + avataropacity + filter blur1, 0 → 0, blur-textduration-lg (320)ease-outHides at once
TI28Backspace on a selected chip (edit)inputtransform translate (from the chip place)chip box → own placeduration-2xl (420)ease-outMoves at once
TI29Backspace on a selected chip (edit)input textopacity + filter blur0, blur-text → 1, 0duration-xl (380)ease-outdelay 80msShows at once
TI30focus leaves the field (Collapsed)chips past the collapse lineopacity + filter blur1, 0 → 0, blur-swapduration-md (260)ease-outdelay stagger-text x chipHides at once
TI31focus leaves the field (Collapsed)more pillopacity + filter blur0, blur-swap → 1, 0duration-md (260)ease-outdelay 120msShows at once
TI32hidden count changesmore pill countnumber rolln → new nduration-5xl (640)ease-outSwaps at once
TI33hint, error or notice appears or endslinegrid-template-rows (fold)0fr → 1frduration-lg (320)ease-outResizes at once
TI34line text changesnew line textopacity + transform translateY + filter blur0, 0.35em, blur-text → 1, 0, 0duration-2xl (420)ease-outdelay 60msSwaps at once
TI35line text changesold line textopacity + transform translateY + filter blur1, 0, 0 → 0, -0.35em, blur-textduration-sm (200)ease-outSwaps at once
TI36line tone changeslinecolortext-tertiary → text-error-primary or text-warning-primaryduration-lg (320)ease-outSwaps at once

Accessibility

  • The input is a combobox (aria-expanded, aria-controls, aria-activedescendant, aria-autocomplete list) with the label as its name; suggestions are a listbox of options.
  • Chips are list items in a list named by the label; each chip's aria-label reads the shown text, the value when a name is shown, the error, and "looking up" while resolving.
  • Keyboard: Enter, Tab or a separator adds; Arrow Up and Down move in the suggestions; Escape closes them; Backspace or Arrow Left at the start of the input selects the last chip; on a chip, Arrow Left and Right move, Backspace edits, Delete removes, Escape goes back to the input.
  • A hidden help line on every chip says: "Press Backspace to edit a tag, Delete to remove it, and the arrow keys to move between tags."
  • Each remove button has aria-label "Remove <tag>".
  • A polite live region reads short results: "Added Maya Chen.", "Added 4. 1 not valid. 1 already added.", "Removed Maya Chen.", "Editing maya@northwind.io.", "Removed all tags."
  • The line under the field is linked to the input with aria-describedby while it shows.
  • Chips are 28px tall; remove buttons are 20px inside a 28px chip, so the chip edge counts toward the 24px target; suggestion rows are at least 40px.
  • Reduced motion: chips and copies appear, move and leave at once; no shake, bump or spinner turn; the line and suggestions show at once.