TagField
A type-ahead input for selecting multiple items from a list, rendering each as a removable tag. For single-select with filtering, use ComboBox.
import { TagField } from "@opengovsg/oui"export const Example = () => { const options = [ { id: 1, textValue: "Aerospace" }, { id: 2, textValue: "Mechanical" }, { id: 3, textValue: "Civil" }, { id: 4, textValue: "Biomedical" }, { id: 5, textValue: "Nuclear" }, { id: 6, textValue: "Industrial" }, { id: 7, textValue: "Chemical" }, { id: 8, textValue: "Agricultural" }, { id: 9, textValue: "Electrical" }, ] return <TagField label="Engineering" defaultItems={options} />}Usage
Use TagField when users select multiple items from a known list. Use ComboBox for single-select with filtering. Use TagGroup when the tags are not interactive selections (display-only).
import { TagField } from "@opengovsg/oui"<TagField
label="Engineering disciplines"
defaultItems={[
{ id: 1, textValue: "Aerospace" },
{ id: 2, textValue: "Mechanical" },
{ id: 3, textValue: "Civil" },
]}
/>Alternatively, install the component as local source via the shadcn CLI:
npx shadcn@latest add https://oui.open.gov.sg/r/tag-field.jsonpnpm dlx shadcn@latest add https://oui.open.gov.sg/r/tag-field.jsonnpx shadcn@latest add https://oui.open.gov.sg/r/tag-field.jsonbunx --bun shadcn@latest add https://oui.open.gov.sg/r/tag-field.jsonTagField combines a text input with a virtualised listbox to let users pick multiple items from a list. Each selected item is rendered as a removable tag. The component is built on Downshift for combobox behaviour and uses React Aria utilities for label/description/error wiring.
If the tag field does not have a visible label, an aria-label or aria-labelledby prop must be passed instead to identify it to assistive technology.
Anatomy
TagField consists of:
- TagField — the root component; manages state, the text input, and the dropdown
- TagFieldItem — one option row in the dropdown list (used internally by default; you can replace it via the
childrenrender prop)
- TagFieldItem — one option row in the dropdown list (used internally by default; you can replace it via the
Examples
Sizes
Use the size prop to change the size of the tag field. Defaults to "md".
import { TagField } from "@opengovsg/oui"export const Example = () => { const options = [ { id: "red panda", textValue: "Panda" }, { id: "cat", textValue: "Cat" }, { id: "dog", textValue: "Dog" }, { id: "aardvark", textValue: "Aardvark" }, { id: "kangaroo", textValue: "Kangaroo" }, { id: "snake", textValue: "Snake" }, ] return ( <div className="flex flex-col space-y-4"> <TagField label="Favorite Animal (xs)" defaultItems={options} size="xs" defaultSelectedKeys={new Set(["cat"])} /> <TagField label="Favorite Animal (sm)" defaultItems={options} size="sm" defaultSelectedKeys={new Set(["dog"])} /> <TagField label="Favorite Animal (md)" defaultItems={options} size="md" defaultSelectedKeys={new Set(["aardvark"])} /> </div> )}Controlled
Use selectedKeys and onSelectionChange to control the selection programmatically. selectedKeys is a Set<Key>; onSelectionChange receives the updated set after every change.
import type { Key } from "react-aria-components"import { useState } from "react"import { TagField } from "@opengovsg/oui"export const Example = () => { const options = [ { id: 1, textValue: "Aerospace" }, { id: 2, textValue: "Mechanical" }, { id: 3, textValue: "Civil" }, { id: 4, textValue: "Biomedical" }, { id: 5, textValue: "Nuclear" }, { id: 6, textValue: "Industrial" }, { id: 7, textValue: "Chemical" }, { id: 8, textValue: "Agricultural" }, { id: 9, textValue: "Electrical" }, ] const [selectedIds, setSelectedIds] = useState<Set<Key>>(new Set()) return ( <div className="flex flex-col"> <TagField label="Engineering" defaultItems={options} selectedKeys={selectedIds} onSelectionChange={setSelectedIds} /> <p>Selected ids: {[...selectedIds].join(", ")}</p> </div> )}With Description
Use the description prop for helper text below the input.
import { TagField } from "@opengovsg/oui"export const Example = () => { const options = [ { id: 1, textValue: "Aerospace" }, { id: 2, textValue: "Mechanical" }, { id: 3, textValue: "Civil" }, { id: 4, textValue: "Biomedical" }, { id: 5, textValue: "Nuclear" }, { id: 6, textValue: "Industrial" }, { id: 7, textValue: "Chemical" }, { id: 8, textValue: "Agricultural" }, { id: 9, textValue: "Electrical" }, ] return ( <TagField label="Engineering" defaultItems={options} description="Choose one of the above options." /> )}Custom Item Rendering
Pass a render function to children to fully customise each option row. The function receives a TagFieldRenderProps<T> object — spread itemProps onto your root element and use key as the React key. Set virtualRowHeight to the actual row height when custom items are taller than the default.
import type { Key } from "react-aria-components"import { useState } from "react"import { TagField } from "@opengovsg/oui"import { cn } from "@opengovsg/oui-theme"export const Example = () => { const options = [ { id: 1, textValue: "Aerospace", description: "Aerospace engineering" }, { id: 2, textValue: "Mechanical", description: "Mechanical engineering" }, { id: 3, textValue: "Civil", description: "Civil engineering" }, { id: 4, textValue: "Biomedical", description: "Biomedical engineering" }, { id: 5, textValue: "Nuclear", description: "Nuclear engineering" }, { id: 6, textValue: "Industrial", description: "Industrial engineering" }, { id: 7, textValue: "Chemical", description: "Chemical engineering" }, { id: 8, textValue: "Electrical", description: "Electrical engineering" }, ] const [selectedIds, setSelectedIds] = useState<Set<Key>>(new Set()) return ( <div className="flex flex-col"> <TagField label="Engineering" defaultItems={options} selectedKeys={selectedIds} onSelectionChange={setSelectedIds} virtualRowHeight={64} // Important if you have custom children > {({ itemProps, key, item, isHighlighted, classNames }) => ( <div key={key} {...itemProps} className={cn( classNames?.container, "flex flex-col p-2", isHighlighted && "bg-amber-100", )} > <span className={cn(classNames?.label)}>{item.textValue}</span> <span className={cn(classNames?.description, "text-gray-600")}> {item.description} </span> </div> )} </TagField> <p>Selected types: {[...selectedIds].join(", ")}</p> </div> )}Disable Virtualisation
By default TagField virtualises its list items for performance. Set isVirtualized={false} to render items in normal DOM flow — useful for small lists or when absolute positioning conflicts with animations or variable-height items.
import { TagField } from "@opengovsg/oui"export const Example = () => { const options = [ { id: 1, textValue: "Aerospace" }, { id: 2, textValue: "Mechanical" }, { id: 3, textValue: "Civil" }, { id: 4, textValue: "Biomedical" }, { id: 5, textValue: "Nuclear" }, { id: 6, textValue: "Industrial" }, { id: 7, textValue: "Chemical" }, { id: 8, textValue: "Agricultural" }, { id: 9, textValue: "Electrical" }, ] return ( <TagField label="Engineering" defaultItems={options} isVirtualized={false} /> )}Disabled
Use isDisabled to disable the entire field.
import { TagField } from "@opengovsg/oui"export const Example = () => { const options = [ { id: "red panda", textValue: "Panda" }, { id: "cat", textValue: "Cat" }, { id: "dog", textValue: "Dog" }, ] return <TagField label="Favorite Animal" defaultItems={options} isDisabled />}Disabled Options
Use disabledKeys to disable specific options by their key.
import { TagField } from "@opengovsg/oui"export const Example = () => { const options = [ { id: "red panda", textValue: "Panda" }, { id: "cat", textValue: "Cat" }, { id: "dog", textValue: "Dog" }, { id: "aardvark", textValue: "Aardvark" }, { id: "kangaroo", textValue: "Kangaroo" }, { id: "snake", textValue: "Snake" }, ] return ( <TagField label="Favorite Animal" defaultItems={options} disabledKeys={["cat", "kangaroo"]} /> )}Validation
Use isInvalid and errorMessage together to show an error state.
import { TagField } from "@opengovsg/oui"export const Example = () => { const options = [ { id: "red panda", textValue: "Panda" }, { id: "cat", textValue: "Cat" }, { id: "dog", textValue: "Dog" }, { id: "aardvark", textValue: "Aardvark" }, { id: "kangaroo", textValue: "Kangaroo" }, { id: "snake", textValue: "Snake" }, ] return ( <TagField label="Favorite Animal" isInvalid errorMessage="Please select a valid animal" defaultItems={options} disabledKeys={["cat", "kangaroo"]} /> )}Content
Unlike most Collection Components, TagField only accepts the dynamic collection method — you must pass an iterable of items via defaultItems (uncontrolled) or items (controlled). Static children are not supported.
// Uncontrolled — items list never changes
<TagField
label="Engineering"
defaultItems={[
{ id: 1, textValue: "Aerospace" },
{ id: 2, textValue: "Mechanical" },
]}
/>
// Controlled — swap in a filtered list externally
<TagField
label="Engineering"
items={filteredItems}
inputValue={inputValue}
onInputChange={setInputValue}
/>Each item object must satisfy one of:
- Has an
idfield — used automatically as the item's key (value passed toonSelectionChange). - Has a
textValuefield — used as the display text and filter target. - Neither — provide
itemToKeyanditemToTextfunctions to extract the key and display text from your item shape.
import type { Key } from "react-aria-components"import { useState } from "react"import { TagField } from "@opengovsg/oui"export const Example = () => { const options = [ { email: "test1@example.com", name: "Test Example 1" }, { email: "test2@example.com", name: "Test Example 2" }, { email: "test3@example.com", name: "Test Example 3" }, { email: "test4@example.com", name: "Test Example 4" }, { email: "test5@example.com", name: "Test Example 5" }, ] const [selectedIds, setSelectedIds] = useState<Set<Key>>(new Set()) return ( <div className="flex flex-col"> <TagField label="Users to notify" defaultItems={options} selectedKeys={selectedIds} onSelectionChange={setSelectedIds} itemToKey={(item) => item.email} itemToText={(item) => item.name} /> <p>Selected emails: {[...selectedIds].join(", ")}</p> </div> )}Validation
See the Forms guide for end-to-end patterns including React Hook Form + Zod.
isRequired— marks the field as required; combined with native form submission or"aria"validation behaviour.validate— a custom function(value: TagFieldValidationValue) => ValidationError | true | null | undefined.TagFieldValidationValuehas{ selectedKeys: Set<Key> | null, inputValue: string }. Return a string to show as the error, ornull/truefor valid.isInvalid+errorMessage— use together for realtime validation feedback without waiting for form submission.errorMessageaccepts aReactNodeor a function(validation: ValidationResult) => string.validationBehavior—"native"(default) blocks form submission when invalid;"aria"only marks the field via ARIA attributes and allows submission — required for React Hook Form.
Events
onSelectionChange— Called when the selection changes, receiving the full updatedSet<Key>.onInputChange— Called when the text input value changes, receiving the new string.onOpenChange— Called when the dropdown opens or closes, receivingisOpen: boolean.onFocus— Called when the input receives focus.onBlur— Called when the input loses focus.onFocusChange— Called when the focus state changes, receiving a boolean.
Accessibility
- TagField uses a native
<input>with ARIA combobox semantics (rolecombobox,aria-haspopup,aria-expanded) managed by Downshift. - The label, description, and error message are automatically wired via
aria-labelledbyandaria-describedbythrough React Aria'suseTextField. - If no visible label is provided, pass
aria-labeloraria-labelledbyto theTagField. - Keyboard interaction: type to filter options; Up/Down to navigate the list; Enter to select; Backspace on empty input removes the last tag; Escape closes the dropdown.
- Each selected tag is a focusable element; Backspace or Delete on a focused tag removes it.
Slots
Slots are named regions of the component you can target with custom Tailwind classes via the classNames prop. Each slot below corresponds to a key on the classNames object.
TagField slots:
root: The outermost flex-column container wrapping the label, field group, description, and error.label: The label element rendered above the field group.group: The bordered input group that wraps the tags, text input, and trigger button.tag: Each selected-item chip rendered inside the group.tagText: The text span inside each tag chip.tagIcon: The remove icon button inside each tag chip.field: The<input>element used for filtering.trigger: The chevron button that opens and closes the dropdown.description: The helper text rendered below the group.error: The error message rendered below the group when the field is invalid.popover: The floating popover container.list: The scrollable list inside the popover.
TagFieldItem slots (via TagFieldItem's classNames or the itemClassNames prop on TagField):
container: The list item row.label: The primary text label inside the item row.description: The secondary description text inside the item row (unused in the default renderer; available for custom item layouts).
Custom Styles
import { TagField } from "@opengovsg/oui"export const Example = () => { const options = [ { id: 1, textValue: "Aerospace" }, { id: 2, textValue: "Mechanical" }, { id: 3, textValue: "Civil" }, { id: 4, textValue: "Biomedical" }, { id: 5, textValue: "Nuclear" }, { id: 6, textValue: "Industrial" }, ] return ( <TagField label="Engineering" defaultItems={options} classNames={{ label: "text-purple-600 font-semibold", group: "border-2 border-purple-400 focus-within:border-purple-600", tag: "bg-purple-100 text-purple-700", }} /> )}Props
TagField
| Prop | Type | Default | Description |
|---|---|---|---|
children | (values: TagFieldRenderProps<T>) => ReactNode | - | Custom render function for each dropdown option; see render-prop table below |
label | ReactNode | - | The visible label for the field |
items | T[] | - | Controlled list of items for the dropdown |
defaultItems | T[] | - | Uncontrolled initial list of items for the dropdown |
inputValue | string | - | The current text input value (controlled) |
defaultInputValue | string | - | The default text input value (uncontrolled) |
onInputChange | (value: string) => void | - | Called when the input value changes |
selectedKeys | Set<Key> | - | The set of selected item keys (controlled) |
defaultSelectedKeys | Set<Key> | - | The default set of selected keys (uncontrolled) |
onSelectionChange | (keys: Set<Key>) => void | - | Called when the selection changes |
isDisabled | boolean | false | Whether the field is disabled |
isReadOnly | boolean | false | Whether the field is read-only |
isRequired | boolean | false | Whether the field is required |
isInvalid | boolean | false | Whether the field should display as invalid |
validate | (value: TagFieldValidationValue) => ValidationError | true | null | undefined | - | Custom validation function |
errorMessage | ReactNode | ((validation: ValidationResult) => string) | - | Error message displayed when the field is invalid |
validationBehavior | "native" | "aria" | "native" | Whether to use native HTML form validation or ARIA-only |
size | "xs" | "sm" | "md" | "md" | The size of the component |
classNames | SlotsToClasses<TagFieldSlots> | - | Custom Tailwind classes for component slots |
itemClassNames | SlotsToClasses<ListBoxItemSlots> | - | Custom Tailwind classes applied to every default TagFieldItem row |
itemToText | (item: T) => string | item => item.textValue | Extracts the display text from an item; required when items lack a textValue field |
itemToKey | (item: T) => Key | item => item.id | Extracts the unique key from an item; required when items lack an id field |
defaultFilter | (textValue: string, inputValue: string) => boolean | case-insensitive contains | Filter function for uncontrolled collections; receives the item text and current input |
virtualRowHeight | number | size-dependent | Override the estimated height of each virtualised row in pixels |
isVirtualized | boolean | true | Whether to virtualise the list; set false for small lists or variable-height items |
shouldCloseOnBlur | boolean | true | Whether the dropdown closes when the input loses focus |
description | ReactNode | null | - | Helper text shown below the field |
placeholder | string | - | Placeholder text for the text input |
disabledKeys | Iterable<Key> | - | Keys of items that cannot be selected |
onOpenChange | (isOpen: boolean) => void | - | Called when the dropdown opens or closes |
onFocus | (e: FocusEvent<HTMLInputElement>) => void | - | Called when the input receives focus |
onBlur | (e: FocusEvent<HTMLInputElement>) => void | - | Called when the input loses focus |
onFocusChange | (isFocused: boolean) => void | - | Called when the focus state changes |
Inherits autoFocus and other focusable props from React Aria's FocusableProps.
children render-prop argument (TagFieldRenderProps<T>)
When children is a function, it is called once per visible dropdown row with:
| Property | Type | Description |
|---|---|---|
item | T | The item data object for this row |
key | string | number | The React key for this row; must be passed as the key prop on the root element |
isHighlighted | boolean | Whether this row is currently keyboard-highlighted |
itemProps | TagFieldBaseItemProps<T> | ARIA and event props from Downshift; spread onto the root element of your custom row |
classNames | SlotsToClasses<ListBoxItemSlots> | The resolved slot classes from itemClassNames; apply to container, label, description spans as needed |
TagFieldItem
TagFieldItem is the default option renderer used internally. You can use it directly inside a custom children function when you only need partial customisation.
| Prop | Type | Default | Description |
|---|---|---|---|
item | T | - | The item data object |
isHighlighted | boolean | false | Whether the item is keyboard-highlighted |
classNames | SlotsToClasses<ListBoxItemSlots> | - | Custom Tailwind classes for item slots (container, label, description) |
style | React.CSSProperties | - | Inline styles (used internally by the virtualiser for positioning) |
Spread all remaining TagFieldBaseItemProps<T> (ARIA + event props from Downshift) onto the <li> element.
Internal: TagFieldRoot render-prop state
TagFieldRoot is an internal (non-exported) primitive that TagField delegates to. Its children render-prop function receives additional state properties for advanced headless composition — these are not accessible through TagField's public API but are listed here for completeness:
| Property | Type | Description |
|---|---|---|
highlightedIndex | number | undefined | The index of the currently keyboard-highlighted option in the dropdown list |
selectedItems | T[] | Array of the currently selected item objects (mirrors the selectedKeys set resolved to objects) |
removeSelectedItem | (item: T) => void | Imperatively removes an item from the selection |
Tabs
Organizes content into multiple panels and lets users switch between them. For page-level navigation, use a nav element or breadcrumbs instead.
TagGroupWIP
A tag group is a focusable list of labels, categories, keywords, filters, or other items, with support for keyboard navigation, selection, and removal.