Skip to content

Tag input

A field that turns typed values into removable chips, with validation and suggestions.

Status
Stable
Category
Inputs
Adoption
Not used yet
import { TagInput } from "@oration/canon/components/tag-input";
packages/canon/src/components/tag-input.tsx

Invite approvers

Approvers can release payment runs up to their limit.

priya.raman@cedarline.iotomas.ferreira@cedarline (invalid)

Paste a list from a spreadsheet or an email thread.

import { Button } from "@oration/canon/components/button";import { Field, FieldDescription, FieldError, FieldLabel } from "@oration/canon/components/field";import { TagInput } from "@oration/canon/components/tag-input";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() {    const id = React.useId();    const isEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);    const [emails, setEmails] = React.useState([        "priya.raman@cedarline.io",        "tomas.ferreira@cedarline",    ]);    const [error, setError] = React.useState<string>();    const invalid = emails.filter((email) => !isEmail(email));    return (        <form            noValidate            className="flex w-full max-w-md flex-col overflow-hidden rounded-xl bg-popover text-left shadow-lg"            onSubmit={(event) => {                event.preventDefault();                if (!emails.length) {                    setError("Add at least one email address.");                    return;                }                if (invalid.length) {                    setError(                        invalid.length === 1                            ? `Fix ${invalid[0]} before sending. Use a full address, like jordan.lee@cedarline.io.`                            : `Fix ${invalid.length} addresses before sending.`,                    );                    return;                }                setError(undefined);                toast.add({                    type: "success",                    title:                        emails.length === 1                            ? "Sent 1 invite"                            : `Sent ${emails.length} invites`,                    description:                        "They join as approvers on the Cedarline workspace.",                });                setEmails([]);            }}        >            <div className="flex flex-col gap-1 p-4 pb-0">                <p className="text-base leading-none font-medium text-foreground">                    Invite approvers                </p>                <p className="text-sm text-muted-foreground">                    Approvers can release payment runs up to their limit.                </p>            </div>            <div className="p-4">                <Field data-invalid={error ? true : undefined}>                    <FieldLabel htmlFor={`${id}-emails`}>                        Email addresses                    </FieldLabel>                    <TagInput                        id={`${id}-emails`}                        value={emails}                        onChange={(next) => {                            setEmails(next);                            setError(undefined);                        }}                        validate={isEmail}                        normalize={(raw) => raw.trim().toLowerCase()}                        split={/[,\s]+/}                        placeholder="Add an email and press Enter"                        aria-invalid={error ? true : undefined}                        aria-describedby={[                            `${id}-emails-description`,                            error ? `${id}-emails-error` : null,                        ]                            .filter(Boolean)                            .join(" ")}                    />                    <FieldDescription                        id={`${id}-emails-description`}                        className="text-[13px]"                    >                        Paste a list from a spreadsheet or an email thread.                    </FieldDescription>                    <FieldError                        id={`${id}-emails-error`}                        className="text-[13px]"                    >                        {error}                    </FieldError>                </Field>            </div>            <div className="flex items-center justify-end gap-2 border-t border-border bg-muted/50 px-4 py-3">                <Button                    type="button"                    variant="ghost"                    onClick={() => setEmails([])}                >                    Clear                </Button>                <Button type="submit">Send invites</Button>            </div>        </form>    );}

Usage#

Tag input turns typed or pasted values into removable chips inside one field: invite emails, allowed domains, opt-out keywords, enum options, header names. Enter, a comma or leaving the field commits the entry; Backspace in an empty field removes the last chip; pasting a list splits it. It is used across settings and agent configuration. The chips are values inside a control, drawn in Well Gray; they are not option tags, so they never take the categorical hues, and an entry that fails validate turns into a Signal Red tint instead.

When to use

  • For a short list of free-form values: invite emails, allowed domains, keywords, phrases.
  • For machine strings such as enum values, variable names or header names, with mono.
  • When people often paste a list, from a spreadsheet column or an email thread.
  • With suggestions when common values exist but anything is allowed, such as supplier tags.
  • With max when the list has a hard limit, such as five escalation phrases.

When not to use

  • For choosing from a fixed, known set. Use a multi-select with chips. Use Combobox
  • For displaying values on a record. Values are tags, not controls. Use Tag
  • For pairs of names and values, such as headers with their values. Use Key-value editor
  • For active filters above a list. Use Filter chip
  • For a single value. Use a plain field. Use Input

The Option Hue Rule

The ten categorical hues belong to select-option values on records. Chips in a tag input are entries being edited, so they stay Well Gray; only an invalid entry changes, to a red tint.

The Machine Mono Rule

Set mono when the entries are machine strings, such as vendor_id or X-Cedarline-Tenant. Emails, phrases and names stay in Geist Sans.

Anatomy#

Net 30lockbox
2 of 5 added
  1. Field. At least 32px tall with 10px corners and a Field Stroke border. It wraps chips onto new lines and focuses the text input when you click its padding.
  2. Chip. 24px, 8px corners, Well Gray with 13px Graphite Ink text, or Geist Mono at 12px with mono. Invalid chips are a 10% red tint with red text.
  3. Remove button. A 20px X with a 28px hit area, named Remove plus the value. It returns focus to the text input.
  4. Text input. The entry being typed, at least 96px wide. The placeholder shows only while there are no chips.
  5. Counter. With max, a 12px tabular count such as 3/5 at the right edge.

Examples#

Flag or reject invalid entries

With validate, failing entries stay as red chips so they can be fixed in place. Add rejectInvalid to keep them out of the list: the text stays in the input and the field turns red.

northwindfreight.comhalcyon (invalid)

Invalid entries stay, in red, so they can be fixed or removed.

orchardstreet.com

Try “halcyon” here: it stays in the input and the field turns red.

import { Field, FieldDescription, FieldLabel } from "@oration/canon/components/field";import { TagInput } from "@oration/canon/components/tag-input";import * as React from "react";export function Validation() {    const domain = (value: string) => /^([a-z0-9-]+\.)+[a-z]{2,}$/.test(value);    const normalize = (raw: string) =>        raw            .trim()            .toLowerCase()            .replace(/^https?:\/\//, "");    const [flagged, setFlagged] = React.useState([        "northwindfreight.com",        "halcyon",    ]);    const [rejected, setRejected] = React.useState(["orchardstreet.com"]);    return (        <div className="grid w-full max-w-2xl gap-6 sm:grid-cols-2">            <Field>                <FieldLabel htmlFor="tags-flag">Allowed websites</FieldLabel>                <TagInput                    id="tags-flag"                    value={flagged}                    onChange={setFlagged}                    validate={domain}                    normalize={normalize}                    split={/[,\s]+/}                    placeholder="Add a domain"                    aria-describedby="tags-flag-description"                />                <FieldDescription                    id="tags-flag-description"                    className="text-[13px]"                >                    Invalid entries stay, in red, so they can be fixed or                    removed.                </FieldDescription>            </Field>            <Field>                <FieldLabel htmlFor="tags-reject">Allowed websites</FieldLabel>                <TagInput                    id="tags-reject"                    value={rejected}                    onChange={setRejected}                    validate={domain}                    rejectInvalid                    normalize={normalize}                    split={/[,\s]+/}                    placeholder="Add a domain"                    aria-describedby="tags-reject-description"                />                <FieldDescription                    id="tags-reject-description"                    className="text-[13px]"                >                    Try “halcyon” here: it stays in the input and the field                    turns red.                </FieldDescription>            </Field>        </div>    );}

Suggestions

Common values open under the field as you type, filtered by substring and without the ones already added. Arrow keys, Enter and Escape work as in a combobox.

Net 30

Type “n” or “p” to see matches. Anything else works too.

import { Field, FieldDescription, FieldLabel } from "@oration/canon/components/field";import { TagInput } from "@oration/canon/components/tag-input";import * as React from "react";export function Suggestions() {    const [tags, setTags] = React.useState(["Net 30"]);    return (        <Field className="max-w-sm">            <FieldLabel htmlFor="tags-suggest">Supplier tags</FieldLabel>            <TagInput                id="tags-suggest"                value={tags}                onChange={setTags}                suggestions={[                    "Net 30",                    "Net 45",                    "early pay",                    "lockbox",                    "1099",                    "purchase order",                    "PO number",                ]}                placeholder="Add a tag"                aria-describedby="tags-suggest-description"            />            <FieldDescription                id="tags-suggest-description"                className="text-[13px]"            >                Type “n” or “p” to see matches. Anything else works too.            </FieldDescription>        </Field>    );}

Limit

max adds a counter and stops accepting entries at the limit; the input goes read-only until a chip is removed.

speak to a personstop callingwrong number
3 of 5 added

When a supplier says one of these, the agent transfers to Aisha Bello. Up to 5 phrases.

import { Field, FieldDescription, FieldLabel } from "@oration/canon/components/field";import { TagInput } from "@oration/canon/components/tag-input";import * as React from "react";export function Max() {    const [phrases, setPhrases] = React.useState([        "speak to a person",        "stop calling",        "wrong number",    ]);    return (        <Field className="max-w-md">            <FieldLabel htmlFor="tags-max">Escalation phrases</FieldLabel>            <TagInput                id="tags-max"                value={phrases}                onChange={setPhrases}                max={5}                placeholder="Add a phrase and press Enter"                aria-describedby="tags-max-description"            />            <FieldDescription id="tags-max-description" className="text-[13px]">                When a supplier says one of these, the agent transfers to Aisha                Bello. Up to 5 phrases.            </FieldDescription>        </Field>    );}

Machine strings

mono sets chips and input in Geist Mono with spell check off. Pair it with a normalize that enforces the format and a validate that flags the rest.

vendor_idinvoice_number

Lowercase snake_case. Spaces and dashes become underscores.

import { Field, FieldDescription, FieldLabel } from "@oration/canon/components/field";import { TagInput } from "@oration/canon/components/tag-input";import * as React from "react";export function Mono() {    const [headers, setHeaders] = React.useState([        "vendor_id",        "invoice_number",    ]);    return (        <Field className="max-w-md">            <FieldLabel htmlFor="tags-mono">Variables to pass</FieldLabel>            <TagInput                id="tags-mono"                value={headers}                onChange={setHeaders}                mono                normalize={(raw) =>                    raw                        .trim()                        .toLowerCase()                        .replace(/[\s-]+/g, "_")                }                validate={(tag) => /^[a-z][a-z0-9_]*$/.test(tag)}                placeholder="vendor_id"                aria-describedby="tags-mono-description"            />            <FieldDescription                id="tags-mono-description"                className="text-[13px]"            >                Lowercase snake_case. Spaces and dashes become underscores.            </FieldDescription>        </Field>    );}

States#

Empty
Focus
ap@halcyon.com
Invalid entry
ap@halcyon.comwen.zhou@ (invalid)
Invalid field
Full
Net 30lockbox
2 of 2 added
Disabled
ap@halcyon.com
import { TagInput } from "@oration/canon/components/tag-input";export function StatesRow() {    const states = [        {            name: "Empty",            value: [] as string[],            field: "",            invalid: false,            disabled: false,            max: undefined,        },        {            name: "Focus",            value: ["ap@halcyon.com"],            field: "[&>div]:border-ring [&>div]:ring-3 [&>div]:ring-ring/50",            invalid: false,            disabled: false,            max: undefined,        },        {            name: "Invalid entry",            value: ["ap@halcyon.com", "wen.zhou@"],            field: "",            invalid: false,            disabled: false,            max: undefined,        },        {            name: "Invalid field",            value: [] as string[],            field: "",            invalid: true,            disabled: false,            max: undefined,        },        {            name: "Full",            value: ["Net 30", "lockbox"],            field: "",            invalid: false,            disabled: false,            max: 2,        },        {            name: "Disabled",            value: ["ap@halcyon.com"],            field: "",            invalid: false,            disabled: true,            max: undefined,        },    ];    return (        <div className="grid w-full grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">            {states.map((state) => (                <div                    key={state.name}                    className="pointer-events-none flex min-w-0 flex-col gap-2"                >                    <span className="text-xs text-muted-foreground">                        {state.name}                    </span>                    <TagInput                        value={state.value}                        onChange={() => undefined}                        aria-label={`Recipients, ${state.name}`}                        placeholder="Add an email"                        validate={(tag) => !tag.endsWith("@")}                        aria-invalid={state.invalid || undefined}                        disabled={state.disabled}                        max={state.max}                        className={state.field}                    />                </div>            ))}        </div>    );}
States
StateTreatment
EmptyThe placeholder in Slate Meta.
FocusThe field takes an indigo border and a 3px ring at 50% while the input has focus.
Invalid chipAn entry that fails validate stays in the list as a red-tint chip, announced with (invalid).
RejectedWith rejectInvalid, a failing entry stays in the text input and the field turns red until it is edited.
Invalid fieldaria-invalid turns the border red with a 3px red ring at 20%, for errors about the whole list.
Suggestions openA popover list under the field, up to eight matches, with a Well Gray highlight that glides between options.
FullAt max, the text input becomes read-only and collapses, suggestions stop, and the counter reads 5/5.
DisabledThe field dims to 50%, and chips can't be removed.

Behavior#

  • Enter commits the entry. So does any character matched by split (a comma or a new line by default) while typing or pasting, and leaving the field.
  • Each entry runs through normalize first (trimming by default), then duplicates are dropped unless allowDuplicates is set.
  • Pasting text that contains a split character adds every part at once. Parts beyond max are dropped.
  • Backspace in an empty input removes the last chip. The X on a chip removes that chip and keeps focus in the field.
  • With suggestions, matches filter by a case-insensitive substring and exclude values already added. Arrow keys move through them and wrap; Enter picks; Escape closes the list without leaving the field.
  • Chips added after mount scale in from 0.96 with a fade on the fast spring. Chips that were there on load don't animate. Under reduced motion only the fade runs.
  • It is controlled: value is the array and onChange gets the next array on every add and remove.

Do and don't#

jordan.lee@cedarline.ioaisha.bello@ (invalid)
Do. Validate each entry and keep failing entries visible in red, so people can fix them in place.
jordan.lee@cedarline.ioaisha.bello@

Some emails are invalid.

Don't. Accept anything and fail the whole list on save with a message that doesn't say which entry is wrong.
early paylockbox1099
Do. Keep chips Well Gray. They are values being edited.
early paylockbox1099
Don't. Color chips with tag hues, which makes an input look like a record's select values.
Do. Split on the characters people actually paste: commas and new lines, plus spaces for emails and domains.
Don't. Make people add a pasted list of forty emails one at a time.

Content#

  • The label names the list: Invite by email, Allowed websites, Opt-out keywords.
  • The placeholder says how to add: Add an email and press Enter. It disappears once there's a chip.
  • Say the limit in the description when there is one: Up to 5 phrases. The counter shows how many are left.
  • When entries are invalid, the description or error says the format: Use a full domain, like northwindfreight.com.

Accessibility#

  • Name the input with id and a <label htmlFor>, or with aria-label.
  • With suggestions, the input is a combobox: aria-expanded, aria-controls, aria-autocomplete="list" and aria-activedescendant point at a listbox named Suggestions.
  • Each remove button is named Remove plus the value, so a list of chips reads clearly.
  • Invalid chips add a screen-reader-only (invalid) after the value.
  • With max, a hidden 3 of 5 added is linked to the input through aria-describedby.
  • Pass aria-describedby for your description or error; it is combined with the counter.
Keyboard interactions
KeysAction
EnterAdds the typed entry, or the highlighted suggestion.
,Adds the typed entry (any split character does).
BackspaceIn an empty input, removes the last chip.
↓↑Moves through suggestions, wrapping at the ends.
EscCloses suggestions and keeps the typed text.
TabMoves through each chip's remove button, then the text input. Leaving the input commits the entry.

Design tokens#

Design tokens
TokenUsed for
--inputField border; 30% fill in dark
--ringFocus border and 3px ring at 50%
--mutedChip fill
--destructiveInvalid chip tint at 10%, invalid border and ring
--popoverSuggestions surface
shadow-popoverSuggestions lift
--radius-lg10px field and suggestion list corners
--radius-md8px chip and option corners
spring.fastChip entrance

API reference#

TagInput

A field of removable chips. Also exported: TagInputProps.

Props of TagInput
PropTypeDefaultDescription
valueRequiredstring[]No defaultThe entries.
onChangeRequired(value: string[]) => voidNo defaultCalled with the next array on every add and remove.
idstringNo defaultThe text input's id, for <label htmlFor>.
placeholderstringNo defaultShown while there are no chips.
aria-labelstringNo defaultThe name when there's no visible label.
aria-describedbystringNo defaultIds of the description or error.
aria-invalidbooleanNo defaultMarks the whole field invalid.
validate(tag: string) => booleanNo defaultReturn false to flag an entry. Flagged entries stay as red chips.
rejectInvalidbooleanfalseKeep failing entries in the text input instead of adding them.
normalize(raw: string) => string(raw) => raw.trim()Runs on every entry before it is added, such as lowercasing emails.
splitRegExp/[,\n]+/Characters that commit an entry while typing or pasting.
suggestionsstring[]No defaultValues offered as you type.
maxnumberNo defaultThe most entries allowed. Shows a counter.
allowDuplicatesbooleanfalseAllow the same value twice.
monobooleanfalseGeist Mono chips and input, with spell check off.
disabledbooleanfalseDims the field and blocks changes.
classNamestringNo defaultOn the outer wrapper.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

Duplicates, and entries past max, are dropped without a message; the typed text just disappears.

Adding or removing a chip isn't announced, and the chips aren't in a list, so screen reader users don't hear how many entries there are unless max is set.

With mono, the text input asks for 13px (md:text-13) but renders at 14px: the cn inside packages/canon reads text-13 as a color, so md:text-sm stays and wins.

The invalid ring stays at 20% in dark, where Input uses 40%.

Chips use 8px corners inside a 10px field with 4px of padding; concentric corners would be 6px.