component — inputs
Tag Field
Text field that commits typed entries into removable hairline chips on Enter, comma, or semicolon. Built on react-aria-components and styled with the quebi design system; supports controlled and uncontrolled values, case-insensitive de-duplication, required validation, and a hidden mirror input for native form submission.
- form
- input
- tags
- chips
- multi-value
08 examples
Default
Type a value and press Enter, comma, or semicolon to add a tag.
Sizes
xs, sm, and the default md — Input's three steps, so a tag field lines up with the field beside it. The chips keep their own small type; they are chips, not a line of the field.
With default value
Pre-filled chips that can be removed via the × button.
With description
A muted hint sits under the field.
Required
Blurring with no tags surfaces a validation error.
Read-only
Tags are visible but cannot be added or removed.
Disabled
Non-interactive, dimmed state.
Controlled
2 selected
source
tag-field.tsxCopy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import { X } from "lucide-react"
import { useEffect, useMemo, useRef, useState } from "react"
import {
Button,
composeRenderProps,
FieldError,
Input,
type Key,
Label,
type Selection,
Tag,
TagGroup,
TagList,
Text,
TextField,
type TextFieldProps,
} from "react-aria-components"
import { useFieldSizing } from "@/lib/field-size"
import { cn } from "@/lib/utils"
/**
* TagField — quebi design system
*
* Built on react-aria-components. A text field that turns typed entries into
* removable chips: press Enter, comma, or semicolon to commit the current
* input. The input is drawn like `Input` — boxed by default (underlined inside
* `quebi-editorial`), its frame doubled on focus; committed tags render as pills with a remove button. Casing-insensitive de-duplication, optional split pattern,
* and a hidden mirror input so the comma-joined value submits with a form.
*/
/**
* The field size scale — `Input`'s three steps, on the text input a TagField
* draws: `xs` is 30px, `sm` 38px, `md` 42px and the default. The chips under
* the input keep their own `text-xs`; they are chips, not a line of the field.
*
* Written out rather than imported from `input.tsx`, as in `select.tsx` and
* `number-field.tsx`: three strings are not worth making `Input` a registry
* dependency of this file.
*/
const tagFieldSizeStyles = {
xs: "text-xs px-(--q-field-px) py-1.5",
sm: "text-sm px-(--q-field-px) py-2",
md: "text-sm px-(--q-field-px) py-2.5",
} as const
type TagFieldSize = keyof typeof tagFieldSizeStyles
export interface TagFieldProps
extends Pick<
TextFieldProps,
"isDisabled" | "isReadOnly" | "aria-label" | "aria-labelledby" | "aria-describedby"
> {
/**
* Force the invalid styling on. Left undefined the field decides for itself
* (empty + `isRequired` + touched), which is what a standalone TagField does;
* a field whose validity is owned elsewhere — a Conform schema, say — passes
* it in.
*/
isInvalid?: boolean
/** id of the visible text input, so an external Label can point at it. */
id?: string
/** The `<form>` to associate the hidden mirror input with, by id. */
form?: string
/** Controlled set of tags. */
value?: Selection
/** Called with the next set of tags whenever they change. */
onChange?: (next: Selection) => void
/** Uncontrolled initial tags. */
defaultValue?: string[]
/** Splits a single entry into multiple tags. Defaults to comma/semicolon. */
splitPattern?: RegExp
className?: string
/** Controlled value of the text input. */
inputValue?: string
onInputValueChange?: (v: string) => void
isRequired?: boolean
requiredMessage?: string
/** Name of the hidden mirror input that carries the comma-joined value. */
name?: string
/** Field label. */
label?: React.ReactNode
/** Muted hint shown under the field. */
description?: React.ReactNode
placeholder?: string
/**
* Control height. Matches `Input`'s scale and `Button`'s `xs` / `sm`. Left
* out, it is whatever the surrounding surface asked for — a table cell being
* the one that does. See `@/lib/field-size`.
*/
size?: TagFieldSize
}
export function TagField({
value,
onChange,
defaultValue = [],
splitPattern = /[,;]/,
className,
inputValue: controlledInput,
onInputValueChange,
isRequired,
isInvalid: isInvalidProp,
requiredMessage,
name = "tags",
id,
form,
label,
description,
placeholder,
size: sizeProp,
...props
}: TagFieldProps) {
const { size } = useFieldSizing({ size: sizeProp })
const [internalSelection, setInternalSelection] = useState<Selection>(new Set(defaultValue))
const [uncontrolledInput, setUncontrolledInput] = useState("")
const [touched, setTouched] = useState(false)
const hiddenRef = useRef<HTMLInputElement>(null)
const selection: Selection = value ?? internalSelection
const inputValue = controlledInput ?? uncontrolledInput
const setInputValue = onInputValueChange ?? setUncontrolledInput
const applySelection = (next: Selection) => (onChange ?? setInternalSelection)(next)
const list = useMemo(() => {
return selection === "all" ? [] : Array.from(selection).map((v) => String(v))
}, [selection])
const isInvalid = isInvalidProp ?? Boolean(isRequired && list.length === 0 && touched)
const errorText = requiredMessage ?? "At least one item is required"
useEffect(() => {
const input = hiddenRef.current
// Resolved from the DOM rather than the `form` prop: the input may simply
// be nested inside its form, and either way this is the element that fires.
const formEl = input?.form
if (!formEl || !input) return
const onSubmit = (e: Event) => {
if (isRequired && list.length === 0) {
e.preventDefault()
setTouched(true)
input.setCustomValidity(errorText)
formEl.reportValidity()
} else {
input.setCustomValidity("")
}
}
formEl.addEventListener("submit", onSubmit)
return () => formEl.removeEventListener("submit", onSubmit)
}, [isRequired, list.length, errorText])
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" || e.key === "," || e.key === ";") {
e.preventDefault()
addTag()
}
}
function addTag() {
if (selection === "all") return
const next = new Set<Key>(Array.from(selection))
inputValue.split(splitPattern).forEach((raw) => {
const formatted = raw
.trim()
.replace(/\s\s+/g, " ")
.replace(/\t|\\t|\r|\\r|\n|\\n/g, "")
if (formatted === "") return
const exists = Array.from(next).some(
(id) => String(id).toLocaleLowerCase() === formatted.toLocaleLowerCase(),
)
if (!exists) next.add(formatted)
})
applySelection(next)
setInputValue("")
setTouched(true)
}
function removeKeys(keys: Selection) {
if (selection === "all") return
const next = new Set<Key>(Array.from(selection))
if (keys !== "all") {
for (const k of keys) next.delete(k)
}
applySelection(next)
setTouched(true)
}
return (
<div data-slot="control" className={cn("flex w-full flex-col gap-y-1.5", className)}>
<TextField
value={inputValue}
onChange={setInputValue}
onKeyDown={handleKeyDown}
onBlur={() => setTouched(true)}
isInvalid={isInvalid}
isDisabled={props.isDisabled}
isReadOnly={props.isReadOnly}
aria-label={props["aria-label"]}
aria-labelledby={props["aria-labelledby"]}
aria-describedby={props["aria-describedby"]}
id={id}
className="group flex w-full flex-col gap-y-1.5"
>
{label != null && (
<Label className="quebi-eyebrow block select-none group-disabled:opacity-50">
{label}
</Label>
)}
<span data-slot="control" className="relative block w-full">
<Input
placeholder={placeholder}
className={cn(
"relative block w-full appearance-none text-quebi-fg placeholder:text-quebi-fg-subtle",
"quebi-field",
tagFieldSizeStyles[size],
"transition-[border-color,box-shadow] duration-150",
"outline-none focus:outline-none focus:shadow-(--q-field-focus)",
isInvalid &&
"[--q-field-edge:var(--q-danger)] focus:shadow-(--q-field-focus-danger)",
"disabled:cursor-not-allowed disabled:opacity-50",
)}
/>
</span>
{description != null && (
<Text slot="description" className="block text-quebi-caption text-quebi-fg-subtle">
{description}
</Text>
)}
{isInvalidProp === undefined && (
<FieldError className="block text-quebi-caption text-quebi-danger">
{isInvalid ? errorText : undefined}
</FieldError>
)}
</TextField>
{list.length > 0 ? (
<TagGroup
disabledKeys={props.isDisabled ? new Set(list) : undefined}
className="mt-0.5"
aria-label="Selected tags"
{...(!props.isReadOnly && !props.isDisabled ? { onRemove: removeKeys } : {})}
>
<TagList className="flex flex-wrap gap-1.5 outline-none">
{list.map((id) => (
<Tag
key={id}
id={id}
textValue={id}
className={composeRenderProps("", (_, { allowsRemoving }) =>
cn(
"group inline-flex items-center gap-1 whitespace-nowrap",
"rounded-full px-2.5 py-1 text-quebi-tag leading-none",
"bg-quebi-raised text-quebi-fg-muted",
"transition-colors duration-150",
allowsRemoving && "hover:text-quebi-fg",
"data-[selected]:bg-quebi-selected data-[selected]:text-quebi-on-selected",
"data-[focus-visible]:ring-2 data-[focus-visible]:ring-quebi-focus data-[focus-visible]:ring-offset-2 data-[focus-visible]:ring-offset-quebi-bg",
"data-[disabled]:opacity-50",
"outline-none",
),
)}
>
{({ allowsRemoving }) => (
<>
<span>{id}</span>
{allowsRemoving && (
<Button
slot="remove"
aria-label={`Remove ${id}`}
className={cn(
"-mr-1 flex size-4 shrink-0 items-center justify-center rounded-full",
"text-quebi-fg-subtle transition-colors duration-150",
"hover:bg-quebi-pressed hover:text-quebi-fg",
"outline-none focus-visible:ring-2 focus-visible:ring-quebi-focus focus-visible:ring-offset-0",
"cursor-pointer",
)}
>
<X className="size-3" strokeWidth={2.5} aria-hidden="true" />
</Button>
)}
</>
)}
</Tag>
))}
</TagList>
</TagGroup>
) : null}
<input
ref={hiddenRef}
name={name}
form={form}
value={list.join(",")}
required={Boolean(isRequired)}
readOnly
aria-hidden="true"
tabIndex={-1}
className="sr-only absolute -z-10 h-0 w-0 opacity-0"
/>
</div>
)
}