Skip to content
Plinth

Forms

Tag Input

Chips you add with Enter, remove with backspace, and paste in as a comma-separated list.

#form#tags#chips#input#keyboard

Install

npx shadcn@latest add https://plinthui.com/r/tag-input.json
Open in v0
'use client';

import { useId, useRef, useState } from 'react';

const css = `
.tags { width: min(100%, 400px); }

.tags__label {
  display: block;
  margin-bottom: 8px;
  font: 500 12px/1 ui-sans-serif, system-ui, sans-serif;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.42);
}

.tags__box {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  padding: 8px;
  border: 1px solid rgba(255, 255, 255, 0.16);
  border-radius: 10px;
  background: rgba(255, 255, 255, 0.04);
  cursor: text;
  transition: border-color 160ms ease;
}
/* :focus-within, so the box lights up when the inner field takes focus — the
   border belongs to the box but the focus belongs to the input. */
.tags__box:focus-within { border-color: #ffca7a; }

.tags__list {
  display: contents;
  margin: 0;
  padding: 0;
  list-style: none;
}

.tags__tag {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 3px 4px 3px 9px;
  border-radius: 999px;
  background: rgba(255, 202, 122, 0.16);
  color: #ffca7a;
  font: 500 12.5px/1.5 ui-sans-serif, system-ui, sans-serif;
  animation: tag-in 180ms cubic-bezier(0.22, 1, 0.36, 1);
}

.tags__remove {
  display: grid;
  place-items: center;
  width: 16px;
  height: 16px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: rgba(255, 202, 122, 0.2);
  color: inherit;
  font: 600 11px/1 ui-sans-serif, system-ui, sans-serif;
  cursor: pointer;
}
.tags__remove:hover { background: rgba(255, 202, 122, 0.36); }
.tags__remove:focus-visible { outline: 2px solid #ffca7a; outline-offset: 2px; }

.tags__field {
  flex: 1;
  min-width: 110px;
  border: 0;
  background: none;
  color: #f3f0e9;
  font: 400 13.5px/1.6 ui-sans-serif, system-ui, sans-serif;
  outline: none;
}
.tags__field::placeholder { color: rgba(255, 255, 255, 0.3); }

.tags__help {
  margin: 8px 0 0;
  font: 400 11.5px/1.4 ui-sans-serif, system-ui, sans-serif;
  color: rgba(255, 255, 255, 0.34);
}

.tags__live {
  position: absolute;
  width: 1px; height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@keyframes tag-in {
  from { opacity: 0; transform: scale(0.86); }
  to   { opacity: 1; transform: scale(1); }
}

@media (prefers-reduced-motion: reduce) {
  .tags__tag { animation: none; }
}
`;

export function TagInput({
  defaultTags = ['glass', 'hover'],
  onChange,
}: {
  defaultTags?: string[];
  onChange?: (tags: string[]) => void;
}) {
  const [tags, setTags] = useState(defaultTags);
  const [draft, setDraft] = useState('');
  const [announcement, setAnnouncement] = useState('');
  const fieldRef = useRef<HTMLInputElement>(null);
  const id = useId();

  function commit(next: string[], message: string) {
    setTags(next);
    setAnnouncement(message);
    onChange?.(next);
  }

  function add(raw: string, from = tags) {
    const value = raw.trim().replace(/^#/, '').toLowerCase();
    if (!value) return from;
    if (from.includes(value)) {
      setAnnouncement(`${value} is already added`);
      return from;
    }
    return [...from, value];
  }

  return (
    <>
      <style>{css}</style>
      <div className="tags">
        <label className="tags__label" htmlFor={`${id}-field`}>
          Tags
        </label>
        {/* The box looks like the input; the real field is borderless inside
            it, so clicking anywhere in the box focuses it. */}
        <div
          className="tags__box"
          onClick={e => {
            if (e.target === e.currentTarget) fieldRef.current?.focus();
          }}
        >
          <ul className="tags__list">
            {tags.map((tag, i) => (
              <li className="tags__tag" key={tag}>
                {tag}
                <button
                  type="button"
                  className="tags__remove"
                  aria-label={`Remove ${tag}`}
                  onClick={() => {
                    commit(
                      tags.filter((_, n) => n !== i),
                      `${tag} removed, ${tags.length - 1} tags`,
                    );
                    fieldRef.current?.focus();
                  }}
                >
                  ×
                </button>
              </li>
            ))}
          </ul>
          <input
            ref={fieldRef}
            className="tags__field"
            id={`${id}-field`}
            type="text"
            placeholder="Add a tag…"
            aria-describedby={`${id}-help`}
            value={draft}
            onChange={e => setDraft(e.target.value)}
            onKeyDown={e => {
              if (e.key === 'Enter' || e.key === ',') {
                e.preventDefault();
                const next = add(draft);
                if (next !== tags) commit(next, `${draft.trim()} added, ${next.length} tags`);
                setDraft('');
              }
              // Only on an empty field, so backspace still edits text normally.
              if (e.key === 'Backspace' && !draft && tags.length) {
                e.preventDefault();
                const last = tags[tags.length - 1];
                commit(tags.slice(0, -1), `${last} removed, ${tags.length - 1} tags`);
              }
            }}
            onPaste={e => {
              // A comma or newline separated list arrives as many tags at once.
              const text = e.clipboardData.getData('text');
              if (!/[,\n]/.test(text)) return;
              e.preventDefault();
              let next = tags;
              for (const part of text.split(/[,\n]/)) next = add(part, next);
              commit(next, `${next.length} tags`);
              setDraft('');
            }}
          />
        </div>
        <p className="tags__help" id={`${id}-help`}>
          Enter or comma to add · Backspace to remove the last
        </p>
        <p className="tags__live" role="status" aria-live="polite">
          {announcement}
        </p>
      </div>
    </>
  );
}

More in Forms

Similar tags