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 { TagField } from "@opengovsg/oui"import { useState } from "react"import type { Key } from "react-aria-components"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>  )}

Checklist

Selected options always stay in the dropdown list instead of being filtered out, so clicking an already-selected option deselects it again. Set shouldCloseOnSelect={false} to also keep the dropdown open after each pick, letting users select several options in one visit without reopening the menu. Pair it with showCheckbox to render a checkbox on every option indicating its selected state — the checkbox stays aligned to an option's first line even when its label wraps across multiple lines.

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 animals"      defaultItems={options}      shouldCloseOnSelect={false}      showCheckbox    />  )}

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 { TagField } from "@opengovsg/oui"import { cn } from "@opengovsg/oui-theme"import { useState } from "react"import type { Key } from "react-aria-components"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 { TagField } from "@opengovsg/oui"import { useState } from "react"import type { Key } from "react-aria-components"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 (or deselect, if already selected); 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.
  • With shouldCloseOnSelect={false}, the highlighted option stays highlighted after selecting or deselecting it, instead of resetting to the first option in the list.

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).
  • checkboxBox: The checkbox square rendered when showCheckbox is true. Vertically centered against the label's first line so it stays aligned when a label wraps across multiple lines.
  • checkboxIcon: The checkmark icon inside checkboxBox, shown when the option is selected.

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<TagFieldItemSlots>-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
shouldCloseOnSelectbooleantrueWhether the dropdown closes when an option is selected; set false to select multiple options in one visit, with the picked option staying highlighted
showCheckboxbooleanfalseWhether every option shows a checkbox indicating its selected state; a future major version may default this to true
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
isSelectedbooleanWhether this row is currently selected
itemPropsTagFieldBaseItemProps<T>ARIA and event props from Downshift; spread onto the root element of your custom row
classNamesSlotsToClasses<TagFieldItemSlots>The resolved slot classes from itemClassNames; apply to container, label, description, checkboxBox, checkboxIcon 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
isSelectedbooleanfalseWhether the item is currently selected; drives the checkbox's checked state when shown
classNamesSlotsToClasses<TagFieldItemSlots>-Custom Tailwind classes for item slots (container, label, description, checkboxBox, checkboxIcon)
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