Open UI

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.json
pnpm dlx shadcn@latest add https://oui.open.gov.sg/r/tag-field.json
npx shadcn@latest add https://oui.open.gov.sg/r/tag-field.json
bunx --bun shadcn@latest add https://oui.open.gov.sg/r/tag-field.json

TagField 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 children render prop)

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 id field — used automatically as the item's key (value passed to onSelectionChange).
  • Has a textValue field — used as the display text and filter target.
  • Neither — provide itemToKey and itemToText functions 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. TagFieldValidationValue has { selectedKeys: Set<Key> | null, inputValue: string }. Return a string to show as the error, or null/true for valid.
  • isInvalid + errorMessage — use together for realtime validation feedback without waiting for form submission. errorMessage accepts a ReactNode or 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 updated Set<Key>.
  • onInputChange — Called when the text input value changes, receiving the new string.
  • onOpenChange — Called when the dropdown opens or closes, receiving isOpen: 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 (role combobox, aria-haspopup, aria-expanded) managed by Downshift.
  • The label, description, and error message are automatically wired via aria-labelledby and aria-describedby through React Aria's useTextField.
  • If no visible label is provided, pass aria-label or aria-labelledby to the TagField.
  • 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

PropTypeDefaultDescription
children(values: TagFieldRenderProps<T>) => ReactNode-Custom render function for each dropdown option; see render-prop table below
labelReactNode-The visible label for the field
itemsT[]-Controlled list of items for the dropdown
defaultItemsT[]-Uncontrolled initial list of items for the dropdown
inputValuestring-The current text input value (controlled)
defaultInputValuestring-The default text input value (uncontrolled)
onInputChange(value: string) => void-Called when the input value changes
selectedKeysSet<Key>-The set of selected item keys (controlled)
defaultSelectedKeysSet<Key>-The default set of selected keys (uncontrolled)
onSelectionChange(keys: Set<Key>) => void-Called when the selection changes
isDisabledbooleanfalseWhether the field is disabled
isReadOnlybooleanfalseWhether the field is read-only
isRequiredbooleanfalseWhether the field is required
isInvalidbooleanfalseWhether the field should display as invalid
validate(value: TagFieldValidationValue) => ValidationError | true | null | undefined-Custom validation function
errorMessageReactNode | ((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
classNamesSlotsToClasses<TagFieldSlots>-Custom Tailwind classes for component slots
itemClassNamesSlotsToClasses<ListBoxItemSlots>-Custom Tailwind classes applied to every default TagFieldItem row
itemToText(item: T) => stringitem => item.textValueExtracts the display text from an item; required when items lack a textValue field
itemToKey(item: T) => Keyitem => item.idExtracts the unique key from an item; required when items lack an id field
defaultFilter(textValue: string, inputValue: string) => booleancase-insensitive containsFilter function for uncontrolled collections; receives the item text and current input
virtualRowHeightnumbersize-dependentOverride the estimated height of each virtualised row in pixels
isVirtualizedbooleantrueWhether to virtualise the list; set false for small lists or variable-height items
shouldCloseOnBlurbooleantrueWhether the dropdown closes when the input loses focus
descriptionReactNode | null-Helper text shown below the field
placeholderstring-Placeholder text for the text input
disabledKeysIterable<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:

PropertyTypeDescription
itemTThe item data object for this row
keystring | numberThe React key for this row; must be passed as the key prop on the root element
isHighlightedbooleanWhether this row is currently keyboard-highlighted
itemPropsTagFieldBaseItemProps<T>ARIA and event props from Downshift; spread onto the root element of your custom row
classNamesSlotsToClasses<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.

PropTypeDefaultDescription
itemT-The item data object
isHighlightedbooleanfalseWhether the item is keyboard-highlighted
classNamesSlotsToClasses<ListBoxItemSlots>-Custom Tailwind classes for item slots (container, label, description)
styleReact.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:

PropertyTypeDescription
highlightedIndexnumber | undefinedThe index of the currently keyboard-highlighted option in the dropdown list
selectedItemsT[]Array of the currently selected item objects (mirrors the selectedKeys set resolved to objects)
removeSelectedItem(item: T) => voidImperatively removes an item from the selection

On this page