Inputs
Tag input
Collects a short list of free-text values, such as labels or email domains, as removable tags.
- Built on
<input type="text"><input type="hidden">role=status:has()- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 3.8 kB
- Dependencies
- React
- bug
- frontend
Try its options
Loading its options.
Code
import { TagInput } from "@/components/equip/tag-input";
export default function Demo() {
return (
<div style={{ width: 216 }}>
<TagInput label="Labels" defaultValue={["bug", "frontend"]} placeholder="Add label" name="labels" />
</div>
);
}Your versionchange its options in Look, This part
Examples
With a hint
Paste a comma-separated list and every entry becomes its own tag.
- acme.com
- acme.co.uk
Press Enter or type a comma after each domain.
Code
import { TagInput } from "@/components/equip/tag-input";
export default function Example() {
return (
<div style={{ width: "100%", maxWidth: 300 }}>
<TagInput
label="Allowed email domains"
hint="Press Enter or type a comma after each domain."
defaultValue={["acme.com", "acme.co.uk"]}
placeholder="example.com"
name="domains"
/>
</div>
);
}Controlled
- maya
- jonas
2 approvals needed to merge.
Code
"use client";
import { useState } from "react";
import { TagInput } from "@/components/equip/tag-input";
export default function Example() {
const [reviewers, setReviewers] = useState(["maya", "jonas"]);
return (
<div style={{ display: "grid", gap: 8, width: "100%", maxWidth: 300 }}>
<TagInput label="Required reviewers" value={reviewers} onValueChange={setReviewers} placeholder="Add a handle" />
<p style={{ margin: 0, fontSize: 13 }}>
{reviewers.length === 0
? "Anyone can approve this branch."
: `${reviewers.length} ${reviewers.length === 1 ? "approval" : "approvals"} needed to merge.`}
</p>
</div>
);
}Read-only and disabled
Both drop the remove buttons; read-only keeps the tags readable at full strength.
- production
- staging
- eu-west-1
- us-east-1
Code
import { TagInput } from "@/components/equip/tag-input";
export default function Example() {
return (
<div style={{ display: "grid", gap: 16, width: "100%", maxWidth: 300 }}>
<TagInput label="Environments" defaultValue={["production", "staging"]} readOnly />
<TagInput label="Regions" defaultValue={["eu-west-1", "us-east-1"]} disabled />
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
label | ReactNode | none | Visible label, tied to the text input. |
hint | ReactNode | none | Help text under the control, linked with aria-describedby. |
error | ReactNode | none | Error text under the control. Marks the input invalid and is announced with it. |
value | string[] | none | Controlled list of tags. |
defaultValue | string[] | [] | Uncontrolled starting list of tags. |
onValueChange | (value: string[]) => void | none | Called with the new list after a tag is added or removed. |
name | string | none | Submits each tag as its own hidden field under this name. |
...rest | ComponentProps<"input"> | none | Every native input attribute for the text input, including placeholder, disabled and ref. className and style go to the wrapper. |
Anatomy
| root | .eq-tag-input | Wrapper that carries className and style. |
|---|---|---|
| label | .eq-tag-input-label | |
| control | .eq-tag-input-control | The bordered box that wraps tags and the text input. |
| list | .eq-tag-input-list | Only rendered when there are tags. |
| tag | .eq-tag-input-tag | |
| tag label | .eq-tag-input-tag-label | |
| remove | .eq-tag-input-remove | Its hit area reaches 24px past the 16px icon. |
| input | .eq-tag-input-input | |
| hint | .eq-tag-input-hint | |
| error | .eq-tag-input-error | |
| status | .eq-tag-input-status | Visually hidden polite live region. |
Keyboard
| Enter | Adds the typed text as a tag. It never submits the form. |
|---|---|
| , | Adds the text before the comma as a tag; pasted comma lists add every entry. |
| Backspace | Removes the last tag when the text input is empty. |
| Tab | Moves through each tag's remove button, then the text input. |
| EnterSpace | On a remove button, removes that tag and returns focus to the text input. |
Notes
- Adds, removals and rejected duplicates are announced through a polite live region.
- Duplicates are compared without case, so Frontend is rejected when frontend is already there.
- Text left in the input is not a tag until Enter or a comma; it is not submitted under name.
- Enter while an input method editor is composing is left to the editor.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/tag-input.tsx205 lines
"use client";
import { useCallback, useId, useRef, useState } from "react";
import type { ChangeEvent, ComponentProps, KeyboardEvent, ReactNode, Ref } from "react";
import "./tag-input.css";
export type TagInputProps = Omit<ComponentProps<"input">, "value" | "defaultValue" | "name" | "type"> & {
/** Visible label. */
label: ReactNode;
/** Help text under the control, linked with aria-describedby. */
hint?: ReactNode;
/** Error text. When set, the input is marked invalid and the text is announced with it. */
error?: ReactNode;
/** Controlled list of tags. */
value?: string[];
/** Uncontrolled starting list of tags. */
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
/** Submits each tag as its own hidden field under this name. */
name?: string;
};
/** True when there is something to render; false, null and "" count as nothing. */
function present(node: ReactNode): boolean {
return node != null && node !== false && node !== "";
}
function setRefs<T>(node: T, ...refs: (Ref<T> | undefined)[]) {
for (const ref of refs) {
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}
}
export function TagInput({
label,
hint,
error,
value,
defaultValue = [],
onValueChange,
name,
className,
style,
id,
disabled,
readOnly,
ref,
onChange,
onKeyDown,
"aria-describedby": describedBy,
"aria-invalid": ariaInvalid,
...rest
}: TagInputProps) {
const autoId = useId();
const inputId = id ?? autoId;
const hintId = `${autoId}-hint`;
const errorId = `${autoId}-error`;
const inputRef = useRef<HTMLInputElement | null>(null);
const [inner, setInner] = useState(defaultValue);
const [draft, setDraft] = useState("");
const [message, setMessage] = useState("");
const tags = value ?? inner;
const mergedRef = useCallback((node: HTMLInputElement | null) => setRefs(node, inputRef, ref), [ref]);
const locked = disabled || readOnly;
function update(next: string[]) {
if (value === undefined) setInner(next);
onValueChange?.(next);
}
/** Adds each new, non-empty entry and announces the result. Returns true if any entry was a duplicate. */
function add(entries: string[], list: string[]) {
let next = list;
const added: string[] = [];
const repeated: string[] = [];
for (const raw of entries) {
const tag = raw.trim();
if (!tag) continue;
if (next.some((t) => t.toLowerCase() === tag.toLowerCase())) repeated.push(tag);
else {
next = [...next, tag];
added.push(tag);
}
}
if (added.length) update(next);
const parts: string[] = [];
if (added.length) parts.push(`Added ${added.join(", ")}.`);
if (repeated.length) parts.push(`${repeated.join(", ")} ${repeated.length > 1 ? "are" : "is"} already added.`);
if (parts.length) setMessage(parts.join(" "));
return repeated.length > 0;
}
function remove(tag: string) {
update(tags.filter((t) => t !== tag));
setMessage(`Removed ${tag}.`);
}
function handleChange(event: ChangeEvent<HTMLInputElement>) {
onChange?.(event);
const text = event.currentTarget.value;
// A comma, typed or pasted, ends a tag. This also covers mobile keyboards,
// which often send no usable key for it.
if (text.includes(",")) {
const parts = text.split(",");
const tail = parts.pop() ?? "";
add(parts, tags);
setDraft(tail.trimStart());
} else {
setDraft(text);
}
}
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
onKeyDown?.(event);
if (event.defaultPrevented || locked || event.nativeEvent.isComposing) return;
if (event.key === "Enter") {
// Never submit the surrounding form from here; Enter belongs to the tag.
event.preventDefault();
const rejected = add([draft], tags);
if (!rejected) setDraft("");
} else if (event.key === "Backspace" && draft === "" && tags.length > 0) {
event.preventDefault();
remove(tags[tags.length - 1]);
}
}
return (
<div className={["eq-tag-input", className].filter(Boolean).join(" ")} style={style}>
<label className="eq-tag-input-label" htmlFor={inputId}>
{label}
</label>
<div
className="eq-tag-input-control"
data-disabled={disabled || undefined}
onMouseDown={(event) => {
// A press on the box itself, between tags, puts the caret in the field.
if (event.target === event.currentTarget && !disabled) {
event.preventDefault();
inputRef.current?.focus();
}
}}
>
{tags.length > 0 && (
<ul className="eq-tag-input-list" aria-label="Added">
{tags.map((tag) => (
<li key={tag} className="eq-tag-input-tag">
<span className="eq-tag-input-tag-label">{tag}</span>
{!locked && (
<button
type="button"
className="eq-tag-input-remove"
aria-label={`Remove ${tag}`}
onClick={() => {
remove(tag);
// The button is about to disappear; keep focus in the field.
inputRef.current?.focus();
}}
>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<path d="M4.5 4.5l7 7M11.5 4.5l-7 7" />
</svg>
</button>
)}
{name && <input type="hidden" name={name} value={tag} disabled={disabled} />}
</li>
))}
</ul>
)}
<input
autoComplete="off"
enterKeyHint="enter"
{...rest}
ref={mergedRef}
id={inputId}
className="eq-tag-input-input"
type="text"
aria-describedby={
[describedBy, present(hint) ? hintId : null, present(error) ? errorId : null].filter(Boolean).join(" ") ||
undefined
}
aria-invalid={present(error) ? true : ariaInvalid}
disabled={disabled}
readOnly={readOnly}
value={draft}
onChange={handleChange}
onKeyDown={handleKeyDown}
/>
</div>
{present(hint) && (
<p className="eq-tag-input-hint" id={hintId}>
{hint}
</p>
)}
{present(error) && (
<p className="eq-tag-input-error" id={errorId}>
{error}
</p>
)}
<div className="eq-tag-input-status" role="status" aria-live="polite">
{message}
</div>
</div>
);
}components/equip/tag-input.css199 lines
@layer equip {
.eq-tag-input {
--_chip: 20px;
display: grid;
gap: var(--eq-space-1);
min-width: 0;
}
.eq-tag-input-label {
justify-self: start;
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-s) / 1.3 var(--eq-font);
}
.eq-tag-input-hint,
.eq-tag-input-error {
margin: 0;
font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
}
.eq-tag-input-hint {
color: var(--eq-ink-2);
}
.eq-tag-input-error {
color: var(--eq-danger);
}
/* The bordered box is the control the eye sees. It grows a row at a time as tags wrap. */
.eq-tag-input-control {
box-sizing: border-box;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--eq-space-1);
min-height: var(--eq-h-m);
padding-block: calc((var(--eq-h-m) - 2px - var(--_chip)) / 2);
padding-inline: var(--eq-space-3);
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
color: var(--eq-ink);
cursor: text;
transition: border-color var(--eq-dur) var(--eq-ease);
}
/* With tags in it, the first chip sits as far from the side as from the top. */
.eq-tag-input-control:has(.eq-tag-input-list) {
padding-inline-start: calc((var(--eq-h-m) - 2px - var(--_chip)) / 2);
}
@media (hover: hover) {
/* :where keeps this below the invalid rule, so a red border stays red on hover. */
.eq-tag-input-control:hover:where(:not([data-disabled])) {
border-color: color-mix(in srgb, var(--eq-line-strong), var(--eq-ink) 30%);
}
}
.eq-tag-input-control:has(.eq-tag-input-input:focus-visible) {
outline: 2px solid var(--eq-focus);
outline-offset: -1px;
}
.eq-tag-input-control:has(.eq-tag-input-input:user-invalid, .eq-tag-input-input[aria-invalid="true"]) {
border-color: var(--eq-danger);
}
/* The list keeps its semantics but lends its items to the control's wrapping row. */
.eq-tag-input-list {
display: contents;
margin: 0;
padding: 0;
list-style: none;
}
.eq-tag-input-tag {
box-sizing: border-box;
display: inline-flex;
flex: none;
align-items: center;
gap: 2px;
max-width: 100%;
height: var(--_chip);
padding-inline: var(--eq-space-2) 2px;
border-radius: var(--eq-radius-s);
background: var(--eq-tint-strong);
color: var(--eq-ink);
font: var(--eq-weight-strong) var(--eq-text-xs) / 1 var(--eq-font);
cursor: default;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring);
}
/* A tag added by typing pops in. Only while the person is in the box, so tags that are
already there when the page loads simply show. */
@starting-style {
.eq-tag-input-control:focus-within .eq-tag-input-tag {
opacity: 0;
scale: 0.8;
}
}
.eq-tag-input-tag-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eq-tag-input-remove {
position: relative;
display: grid;
flex: none;
place-items: center;
width: 16px;
height: 16px;
margin: 0;
padding: 0;
border: 0;
border-radius: var(--eq-radius-s);
background: transparent;
color: var(--eq-ink-2);
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
/* A 16px target, so it gives a little more than a button. */
.eq-tag-input-remove:active:not(:disabled) {
scale: calc(var(--eq-press) - 0.04);
}
/* The chip is 20px tall; this invisible margin gives the button a 24px target. */
.eq-tag-input-remove::before {
content: "";
position: absolute;
inset: -4px;
}
.eq-tag-input-remove > svg {
width: 12px;
height: 12px;
}
@media (hover: hover) {
.eq-tag-input-remove:hover {
background: var(--eq-tint-strong);
color: var(--eq-ink);
}
}
.eq-tag-input-remove:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-tag-input-input {
flex: 1 1 8ch;
min-width: 8ch;
height: var(--_chip);
margin: 0;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: var(--eq-weight) var(--eq-text-m) / 1 var(--eq-font);
}
.eq-tag-input-input:focus-visible {
outline: none;
}
.eq-tag-input-input::placeholder {
color: var(--eq-ink-3);
opacity: 1;
}
.eq-tag-input-control[data-disabled] {
opacity: 0.45;
cursor: not-allowed;
}
.eq-tag-input-control[data-disabled] * {
cursor: not-allowed;
}
.eq-tag-input-status {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}