Tags inputDesign system
ComponentProduct

Tags input

Input that converts typed or pasted values into tags and marks invalid entries.

npx context-ds add tags-input
In the product

Usage

Use it for

Lists of short values in a form (domains, emails, URLs, tags).

Avoid

  • Long free text
  • A single value

Dependencies: lucide-react

Example

examples/tags-input.tsx
"use client";import { useState } from "react";import { Field } from "@/components/ui/field";import { TagsInput } from "@/components/ui/tags-input";export function Product() {  const [domains, setDomains] = useState(["acme.com", "globex.io", "not a domain"]);  return (    <div className="w-full max-w-md">      <Field label="Domains" htmlFor="domains" help="Press Enter or type a comma after each one.">        <TagsInput id="domains" tags={domains} onTagsChange={setDomains} placeholder="acme.com" isTagInvalid={(tag) => !/^[\w-]+(\.[\w-]+)+$/.test(tag)} />      </Field>    </div>  );}

Source

components/ui/tags-input.tsx
"use client";import * as React from "react";import { X } from "lucide-react";import { cn } from "@/lib/utils";// The dashboard's tags field: each tag a small blue chip (red when invalid) with its own remove button, typed with Enter// or a comma, removed with Backspace, and a pasted list split into tags. Borders like an input.function TagsInput({  id,  tags,  onTagsChange,  placeholder,  isTagInvalid,  bare = false,  className,  ...inputProps}: {  id?: string;  tags: string[];  onTagsChange: (tags: string[]) => void;  placeholder?: string;  isTagInvalid?: (tag: string) => boolean;  /** Drops the field's own border, for a TagsInput set inside a container that draws one. */  bare?: boolean;  className?: string;} & Pick<React.ComponentProps<"input">, "aria-describedby" | "aria-invalid" | "required" | "disabled">) {  const [draft, setDraft] = React.useState("");  function commit(value: string) {    const tag = value.trim().replace(/,$/, "");    if (tag && !tags.includes(tag)) onTagsChange([...tags, tag]);    setDraft("");  }  return (    <div      data-slot="tags-input"      className={cn(        "flex min-h-10 w-full flex-wrap items-center gap-1.5 px-2 py-1.5",        !bare && "rounded-control border border-line bg-surface transition-[border-color,box-shadow] duration-150 ease-out focus-within:border-brand hover:border-line-strong motion-reduce:transition-none",        className,      )}    >      {tags.map((tag) => (        <span          key={tag}          className={cn("flex h-6 items-center gap-1 rounded-[calc(var(--ds-radius-control)-0.125rem)] pr-1 pl-2.5 text-caption", isTagInvalid?.(tag) ? "bg-danger/8 text-danger" : "bg-blue-10 text-fg-brand")}        >          {tag}          <button            type="button"            aria-label={`Remove ${tag}`}            onClick={() => onTagsChange(tags.filter((existing) => existing !== tag))}            className="grid size-4 place-items-center rounded-icon outline-none transition-colors duration-150 ease-out hover:bg-brand hover:text-on-brand focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-solid focus-visible:outline-focus-ring"          >            <X className="size-3" strokeWidth={1.75} />          </button>        </span>      ))}      <input        {...inputProps}        id={id}        aria-invalid={inputProps["aria-invalid"] ?? (isTagInvalid && tags.some(isTagInvalid) ? true : undefined)}        value={draft}        onChange={(event) => {          if (!(event.nativeEvent as InputEvent).isComposing && event.target.value.endsWith(",")) commit(event.target.value);          else setDraft(event.target.value);        }}        onKeyDown={(event) => {          if (event.nativeEvent.isComposing || event.keyCode === 229) return;          if (event.key === "Enter") {            event.preventDefault();            commit(draft);          }          if (event.key === "Backspace" && !draft && tags.length > 0) onTagsChange(tags.slice(0, -1));        }}        onPaste={(event) => {          const pasted = event.clipboardData            .getData("text")            .split(/[,\s]+/)            .map((part) => part.trim())            .filter(Boolean);          if (pasted.length < 2) return;          event.preventDefault();          onTagsChange(Array.from(new Set([...tags, ...pasted])));          setDraft("");        }}        onBlur={() => draft && commit(draft)}        placeholder={tags.length === 0 ? placeholder : ""}        className="h-7 min-w-24 flex-1 bg-transparent px-1.5 text-body-sm text-fg outline-none placeholder:text-fg-subtle"      />    </div>  );}export { TagsInput };

Reference: context-dashboard c0ddf7a src/components/ui/form.tsx.