Forms
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.
forminputtagschipsmulti-value
Default
Type a value and press Enter, comma, or semicolon to add a tag.
With default value
Pre-filled chips that can be removed via the × button.
design
engineering
research
With description
A muted hint sits under the field.
Separate entries with a comma or semicolon.
typescript
react
Required
Blurring with no tags surfaces a validation error.
Read-only
Tags are visible but cannot be added or removed.
bug
wontfix
Disabled
Non-interactive, dimmed state.
news
sports
Controlled
alpha
beta
2 selected
Source
Copy 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 { 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 field uses the quebi translucent-input styling (cyan-tinted
* border, teal focus ring); committed tags render as hairline chips 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.
*/
interface TagFieldProps
extends Pick<
TextFieldProps,
"isDisabled" | "isReadOnly" | "aria-label" | "aria-labelledby"
> {
/** 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
}
export function TagField({
value,
onChange,
defaultValue = [],
splitPattern = /[,;]/,
className,
inputValue: controlledInput,
onInputValueChange,
isRequired,
requiredMessage,
name = "tags",
label,
description,
placeholder,
...props
}: TagFieldProps) {
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 = Boolean(isRequired && list.length === 0 && touched)
const errorText = requiredMessage ?? "At least one item is required"
useEffect(() => {
const input = hiddenRef.current
const form = input?.form
if (!form || !input) return
const onSubmit = (e: Event) => {
if (isRequired && list.length === 0) {
e.preventDefault()
setTouched(true)
input.setCustomValidity(errorText)
form.reportValidity()
} else {
input.setCustomValidity("")
}
}
form.addEventListener("submit", onSubmit)
return () => form.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 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"]}
className="group flex w-full flex-col gap-y-1.5"
>
{label != null && (
<Label className="select-none font-semibold text-[13px] text-white 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-sm text-white placeholder:text-quebi-fg-subtle",
"rounded-quebi-sm border border-cyan-500/20 bg-white/[0.02] px-3 py-2.5",
"transition-[border-color,box-shadow] duration-200",
"enabled:hover:border-cyan-500/40",
"outline-none focus:outline-none focus:border-quebi-brand focus:ring-2 focus:ring-quebi-brand/50",
isInvalid && "border-red-500 focus:ring-red-500/50",
"disabled:cursor-not-allowed disabled:opacity-50",
"scheme-dark",
)}
/>
</span>
{description != null && (
<Text slot="description" className="block text-[12px] text-quebi-fg-muted">
{description}
</Text>
)}
<FieldError className="block text-[12px] text-red-500">
{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 border px-2.5 py-1 text-xs font-semibold leading-none",
"border-cyan-500/20 bg-white/[0.06] text-quebi-fg-muted",
"transition-colors duration-150",
allowsRemoving && "hover:border-cyan-500/40 hover:text-white",
"data-[selected]:border-quebi-brand/40 data-[selected]:bg-quebi-brand/10 data-[selected]:text-quebi-brand",
"data-[focus-visible]:ring-2 data-[focus-visible]:ring-quebi-brand/50 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-white/10 hover:text-white",
"outline-none focus-visible:ring-2 focus-visible:ring-quebi-brand/50",
"cursor-pointer",
)}
>
<X className="size-3" strokeWidth={2.5} aria-hidden="true" />
</Button>
)}
</>
)}
</Tag>
))}
</TagList>
</TagGroup>
) : null}
<input
ref={hiddenRef}
name={name}
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>
)
}