Tag input
A field that turns typed values into removable chips, with validation and suggestions.
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
suggestionswhen common values exist but anything is allowed, such as supplier tags. - With
maxwhen 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 Machine Mono Rule
mono when the entries are machine strings, such as vendor_id or X-Cedarline-Tenant. Emails, phrases and names stay in Geist Sans.Anatomy#
- 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.
- 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. - Remove button. A 20px X with a 28px hit area, named Remove plus the value. It returns focus to the text input.
- Text input. The entry being typed, at least 96px wide. The placeholder shows only while there are no chips.
- 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.
Invalid entries stay, in red, so they can be fixed or removed.
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.
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.
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.
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#
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> );}| State | Treatment |
|---|---|
| Empty | The placeholder in Slate Meta. |
| Focus | The field takes an indigo border and a 3px ring at 50% while the input has focus. |
| Invalid chip | An entry that fails validate stays in the list as a red-tint chip, announced with (invalid). |
| Rejected | With rejectInvalid, a failing entry stays in the text input and the field turns red until it is edited. |
| Invalid field | aria-invalid turns the border red with a 3px red ring at 20%, for errors about the whole list. |
| Suggestions open | A popover list under the field, up to eight matches, with a Well Gray highlight that glides between options. |
| Full | At max, the text input becomes read-only and collapses, suggestions stop, and the counter reads 5/5. |
| Disabled | The 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
normalizefirst (trimming by default), then duplicates are dropped unlessallowDuplicatesis set. - Pasting text that contains a split character adds every part at once. Parts beyond
maxare 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:
valueis the array andonChangegets the next array on every add and remove.
Do and don't#
Some emails are invalid.
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
idand a<label htmlFor>, or witharia-label. - With
suggestions, the input is a combobox:aria-expanded,aria-controls,aria-autocomplete="list"andaria-activedescendantpoint 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 througharia-describedby. - Pass
aria-describedbyfor your description or error; it is combined with the counter.
| Keys | Action |
|---|---|
| Enter | Adds the typed entry, or the highlighted suggestion. |
| , | Adds the typed entry (any split character does). |
| Backspace | In an empty input, removes the last chip. |
| ↓↑ | Moves through suggestions, wrapping at the ends. |
| Esc | Closes suggestions and keeps the typed text. |
| Tab | Moves through each chip's remove button, then the text input. Leaving the input commits the entry. |
Design tokens#
| Token | Used for |
|---|---|
--input | Field border; 30% fill in dark |
--ring | Focus border and 3px ring at 50% |
--muted | Chip fill |
--destructive | Invalid chip tint at 10%, invalid border and ring |
--popover | Suggestions surface |
shadow-popover | Suggestions lift |
--radius-lg | 10px field and suggestion list corners |
--radius-md | 8px chip and option corners |
spring.fast | Chip entrance |
API reference#
TagInput
A field of removable chips. Also exported: TagInputProps.
| Prop | Type | Default | Description |
|---|---|---|---|
valueRequired | string[] | No default | The entries. |
onChangeRequired | (value: string[]) => void | No default | Called with the next array on every add and remove. |
id | string | No default | The text input's id, for <label htmlFor>. |
placeholder | string | No default | Shown while there are no chips. |
aria-label | string | No default | The name when there's no visible label. |
aria-describedby | string | No default | Ids of the description or error. |
aria-invalid | boolean | No default | Marks the whole field invalid. |
validate | (tag: string) => boolean | No default | Return false to flag an entry. Flagged entries stay as red chips. |
rejectInvalid | boolean | false | Keep 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. |
split | RegExp | /[,\n]+/ | Characters that commit an entry while typing or pasting. |
suggestions | string[] | No default | Values offered as you type. |
max | number | No default | The most entries allowed. Shows a counter. |
allowDuplicates | boolean | false | Allow the same value twice. |
mono | boolean | false | Geist Mono chips and input, with spell check off. |
disabled | boolean | false | Dims the field and blocks changes. |
className | string | No default | On 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.