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.
Live preview
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-inputUsage
The demo above, as code.
'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).
| Prop | Type | Default | Description |
|---|---|---|---|
| value | TagInputTag[] | undefined | Controlled tags: { id, value, label?, image?, error? }. |
| defaultValue | TagInputTag[] | [] | Starting tags when uncontrolled. |
| validate | (value: string) => string | null | undefined | undefined | Return 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> | undefined | Look up a value after it is added, for example an email address to a name and picture. |
| suggestions | TagInputSuggestion[] | ((query: string) => TagInputSuggestion[] | Promise<TagInputSuggestion[]>) | undefined | A list to filter, or a function that finds matches. Shows up to 6 rows: { value, label?, image?, description? }. |
| normalize | (value: string) => string | (v) => v | Applied to each value before it is checked and compared, for example lower case for email addresses. |
| separators | RegExp | /[,;\n]+/ | What splits typed and pasted text into values. |
| max | number | undefined | Largest number of tags. |
| limitMessage | string | "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. |
| collapse | number | undefined | Lines to show while the field is not focused; the other chips fold into a +N pill. |
| label | string | "Add tags" | Accessible name of the field. |
| placeholder | string | undefined | Shown while there are no tags. |
| hint | string | undefined | A line under the field; errors and notices replace it while they last. |
| name | string | undefined | Form field name; submits the valid values separated by commas. |
| disabled | boolean | false | Turns the field off. |
| Event | Description |
|---|---|
| valueChange | Fires 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.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| TI1 | focus the input | field | border-color | border-primary → border-brand | duration-sm (200)ease-out | Swaps at once |
| TI2 | type text (suggestions found) | suggestions panel | clip-path (unfolds down from the field edge) | inset bottom 100% → inset bottom 0 | duration-lg (320)ease-out | Shows at once |
| TI3 | type text (suggestions found) | suggestions panel | opacity | 0 → 1 | duration-sm (200)ease-out | Shows at once |
| TI4 | Arrow keys or pointer over a row | suggestion highlight | transform translateY + height | previous row → active row | duration-md (260)ease-out | Moves at once |
| TI5 | first row becomes active, or none is active | suggestion highlight | opacity | 0 → 1 | duration-xs (160)ease-out | Shows at once |
| TI6 | Enter, Tab or a separator after typed text | chip shell | clip-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, 1 | duration-3xl (460)ease-out | Shows at once |
| TI7 | chip added | chip extra slot | opacity + transform scale + filter blur | 0, scale-swap, blur-swap → 1, 1, 0 | duration-lg (320)ease-outdelay 140ms | Shows at once |
| TI8 | paste a list, or type several separated values | new chips | transform (from the caret) + scale + opacity + filter blur | caret point, 0.86, 0, blur-text → own place, 1, 1, 0 | duration-4xl (520)ease-outdelay stagger-step x chip | Shows at once |
| TI9 | pick a suggestion | carried label copy | transform translate + scale (from the row label onto the chip label) | suggestion row → chip label | duration-3xl (460)ease-out | Moves at once |
| TI10 | any change to the list (add, remove, edit, lookup, line wrap) | every chip that moved + input | transform translate (additive offset that decays to 0) | old place → new place | duration-2xl (420)ease-out | Moves at once |
| TI11 | chip width changes (name arrives, avatar opens) | chip shell | width | drawn width → new width | duration-2xl (420)ease-out | Resizes at once |
| TI12 | line count changes, or collapse | field | height | old height → new height | duration-2xl (420)ease-out | Resizes at once |
| TI13 | value already added (typed or picked) | flying copy | transform translate + scale + opacity + filter blur (flies into its twin) | where it was typed, 1, 1, 0 → twin center, 0.92, 0, blur-swap | duration-5xl (640)ease-out | Hides at once |
| TI14 | value already added (pasted) | flying copy | as TI13, but it first fades in where it would have landed | 0.86, 0, blur-text → twin center, 0.92, 0, blur-swap | duration-6xl (900)ease-outdelay stagger-step x chip | Hides at once |
| TI15 | flying copy lands | twin chip | transform scale bump | 1 → 1.08, then 1 | duration-2xl (420)ease-throw | Stays still |
| TI16 | flying copy lands | twin chip shell | box-shadow ring flash (holds 40%, then fades) | focus color → border-primary | duration-6xl (900)ease-out | Swaps at once |
| TI17 | value is not valid | chip | transform translateX shake | 0 → -4px, 4px, -2px, 0 | duration-xl (380)ease-exitdelay 380ms | Stays still |
| TI18 | value does not fit under max | field | transform translateX shake (as TI17) | 0 → -4px, 4px, -2px, 0 | duration-xl (380)ease-exit | Stays still |
| TI19 | lookup starts or ends | chip extra slot icons | opacity + filter blur (swap spinner, error icon, avatar) | 1, 0 → 0, blur-swap | duration-md (260)ease-out | Swaps at once |
| TI20 | lookup starts or ends | chip extra slot icons | transform scale | 1 → scale-swap | duration-xl (380)ease-out | Swaps at once |
| TI21 | while looking up | spinner | transform rotate | 0deg → 360deg, repeats | duration-6xl (900)ease-linear | No loop |
| TI22 | lookup finds a name | chip label | text morph (address to name) | value → label | duration-3xl (460)ease-out | Swaps at once |
| TI23 | focus a chip (Selected) | chip shell | background-color + box-shadow ring | bg-primary, border-primary → bg-primary_hover, focus color at 70% | duration-sm (200)ease-out | Swaps at once |
| TI24 | hover the remove button | remove button | background-color, color | transparent, fg-tertiary → bg-primary_hover, fg-tertiary_hover | duration-xs (160)ease-out | Swaps at once |
| TI25 | remove a chip (button, Delete, or clear) | leaving copy | opacity + transform scale + filter blur | 1, 1, 0 → 0, 0.86, blur-text | duration-md (260)ease-out | Hides at once |
| TI26 | Backspace on a selected chip (edit) | leaving shell | clip-path + opacity (shrinks round the letters) | full pill, 1 → inset 4px around the letters, 0 | duration-xl (380)ease-out | Hides at once |
| TI27 | Backspace on a selected chip (edit) | leaving letters + avatar | opacity + filter blur | 1, 0 → 0, blur-text | duration-lg (320)ease-out | Hides at once |
| TI28 | Backspace on a selected chip (edit) | input | transform translate (from the chip place) | chip box → own place | duration-2xl (420)ease-out | Moves at once |
| TI29 | Backspace on a selected chip (edit) | input text | opacity + filter blur | 0, blur-text → 1, 0 | duration-xl (380)ease-outdelay 80ms | Shows at once |
| TI30 | focus leaves the field (Collapsed) | chips past the collapse line | opacity + filter blur | 1, 0 → 0, blur-swap | duration-md (260)ease-outdelay stagger-text x chip | Hides at once |
| TI31 | focus leaves the field (Collapsed) | more pill | opacity + filter blur | 0, blur-swap → 1, 0 | duration-md (260)ease-outdelay 120ms | Shows at once |
| TI32 | hidden count changes | more pill count | number roll | n → new n | duration-5xl (640)ease-out | Swaps at once |
| TI33 | hint, error or notice appears or ends | line | grid-template-rows (fold) | 0fr → 1fr | duration-lg (320)ease-out | Resizes at once |
| TI34 | line text changes | new line text | opacity + transform translateY + filter blur | 0, 0.35em, blur-text → 1, 0, 0 | duration-2xl (420)ease-outdelay 60ms | Swaps at once |
| TI35 | line text changes | old line text | opacity + transform translateY + filter blur | 1, 0, 0 → 0, -0.35em, blur-text | duration-sm (200)ease-out | Swaps at once |
| TI36 | line tone changes | line | color | text-tertiary → text-error-primary or text-warning-primary | duration-lg (320)ease-out | Swaps 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.