All patterns

Tag Input Add

Committed text lands as a chip and the caret slides along to the space it took.

formsfriendlyenergeticinteraction · finite · intermediate · ~0.3s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

301 lines · react + motion only
import { useId, useRef, useState, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Tag Input Add
 *
 * Committed text leaves the caret and lands as a chip, and the caret
 * slides along to the space the chip just took. Backspace on an empty
 * field picks the last chip back up.
 *
 * Self-contained: depends only on `react` and `motion`. Surfaces are
 * mixed from the inherited text color, so the field reads correctly on a
 * light page and on a dark one.
 * Works with zero props; tune via `variant`, `label`, `defaultTags`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type TagInputAddProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Field label. Also the input's accessible name. */
  label?: string;
  /** Placeholder shown while the field is empty. */
  placeholder?: string;
  /** Chips present on first render. */
  defaultTags?: string[];
  /** Field width. */
  width?: number | string;
  /** Chip and focus color. */
  accent?: string;
  /** Fires with the full list after every add or remove. */
  onTagsChange?: (tags: string[]) => void;
};

type VariantConfig = {
  /** Moves the caret and the neighbouring chips to their new places. */
  reflow: { type: "spring"; stiffness: number; damping: number };
  /** How far a new chip drops in from, in pixels. */
  drop: number;
  /** Seconds for a chip to arrive. */
  enter: number;
  /** Seconds for a removed chip to go. Shorter — leaving is not news. */
  exit: number;
};

// Quality rule: a chip is a box full of text, so it moves with
// `layout="position"` — the position is interpolated but the box is
// never scaled, which is what keeps the glyphs from smearing during the
// reflow. The reflow spring is critically damped for the same reason:
// text must not bounce. Variants differ in drop distance and tempo.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Chips appear more than they arrive. For a field that collects a
  // dozen of them.
  subtle: {
    reflow: { type: "spring", stiffness: 810, damping: 56 },
    drop: 2,
    enter: 0.1,
    exit: 0.09,
  },
  // The chip drops into the row and the caret follows. All-purpose.
  default: {
    reflow: { type: "spring", stiffness: 560, damping: 46 },
    drop: 6,
    enter: 0.18,
    exit: 0.12,
  },
  // A longer drop, for a field where each chip is a decision.
  playful: {
    reflow: { type: "spring", stiffness: 350, damping: 37 },
    drop: 13,
    enter: 0.26,
    exit: 0.15,
  },
};

/** Theme-adaptive neutral: `currentColor` is the text color this component
 *  inherits — near-black on a light page, near-white on a dark one — so
 *  mixing it with `transparent` yields a surface or border that is
 *  correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

/** Off-screen but in the accessibility tree — the announcement channel
 *  for changes the eye can see and a screen reader otherwise cannot. */
const SR_ONLY = {
  position: "absolute" as const,
  width: 1,
  height: 1,
  margin: -1,
  padding: 0,
  overflow: "hidden",
  clipPath: "inset(50%)",
  whiteSpace: "nowrap" as const,
};

export default function TagInputAdd({
  variant = "default",
  label = "Labels",
  placeholder = "Add a label",
  defaultTags = ["Roadmap", "Billing"],
  width = 300,
  accent = "#5B5BD6",
  onTagsChange,
}: TagInputAddProps) {
  const [tags, setTags] = useState(defaultTags);
  const [draft, setDraft] = useState("");
  const [focused, setFocused] = useState(false);
  const [announcement, setAnnouncement] = useState("");
  const inputRef = useRef<HTMLInputElement>(null);
  const baseId = useId();
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];

  const enter = reduceMotion
    ? { duration: 0.1 }
    : { duration: cfg.enter, ease: "easeOut" as const };

  const add = () => {
    const next = draft.trim();
    if (!next) return;
    // Silently ignoring a duplicate is better than an error here: the
    // user's intent is already satisfied.
    if (!tags.some((tag) => tag.toLowerCase() === next.toLowerCase())) {
      const updated = [...tags, next];
      setTags(updated);
      onTagsChange?.(updated);
      setAnnouncement(`${next} added`);
    }
    setDraft("");
  };

  const drop = (tag: string, note: string) => {
    const updated = tags.filter((item) => item !== tag);
    setTags(updated);
    onTagsChange?.(updated);
    setAnnouncement(`${tag} ${note}`);
    // Removing a chip destroys the button that was focused, so focus has
    // to be handed somewhere deliberate before the browser sends it to
    // the top of the document.
    inputRef.current?.focus();
  };

  const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
    if (event.key === "Enter" || event.key === ",") {
      event.preventDefault();
      add();
    } else if (event.key === "Backspace" && draft === "" && tags.length > 0) {
      // Backspace at the start of an empty field picks the last chip
      // back up rather than deleting it outright, so a slip is undoable.
      event.preventDefault();
      const last = tags[tags.length - 1];
      setDraft(last);
      drop(last, "back in the field");
    }
  };

  return (
    <div style={{ width, color: "inherit" }}>
      <label
        htmlFor={`${baseId}-input`}
        style={{
          display: "block",
          fontSize: 12.5,
          fontWeight: 600,
          opacity: 0.6,
          marginBottom: 7,
        }}
      >
        {label}
      </label>

      <div
        // Clicking the padding puts the caret where the user expects it.
        onPointerDown={(event) => {
          if (event.target === event.currentTarget) inputRef.current?.focus();
        }}
        style={{
          display: "flex",
          flexWrap: "wrap",
          alignItems: "center",
          gap: 6,
          minHeight: 42,
          padding: "7px 8px",
          borderRadius: 11,
          background: tone(6),
          border: `1px solid ${focused ? accent : tone(14)}`,
          boxShadow: focused ? `0 0 0 3px ${accent}33` : "none",
          transition: "border-color 160ms ease-out, box-shadow 160ms ease-out",
          cursor: "text",
        }}
      >
        {/* popLayout takes an exiting chip out of the flow immediately, so
            the chips after it start closing the gap on the same frame
            rather than waiting for the fade to finish. */}
        <AnimatePresence mode="popLayout" initial={false}>
          {tags.map((tag) => (
            <motion.span
              key={tag}
              // Position only: the box is never scaled, so the text
              // inside it cannot smear while the row reflows.
              layout="position"
              initial={{ opacity: 0, y: reduceMotion ? 0 : -cfg.drop }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: reduceMotion ? 0 : cfg.drop * 0.6 }}
              transition={{
                ...enter,
                layout: reduceMotion ? { duration: 0 } : cfg.reflow,
                opacity: { duration: reduceMotion ? 0.1 : cfg.exit },
              }}
              style={{
                display: "inline-flex",
                alignItems: "center",
                gap: 5,
                padding: "4px 5px 4px 9px",
                borderRadius: 7,
                fontSize: 12.5,
                fontWeight: 600,
                lineHeight: "17px",
                color: accent,
                background: `${accent}1f`,
                border: `1px solid ${accent}3d`,
              }}
            >
              {tag}
              <button
                type="button"
                aria-label={`Remove ${tag}`}
                onClick={() => drop(tag, "removed")}
                style={{
                  display: "flex",
                  alignItems: "center",
                  justifyContent: "center",
                  width: 16,
                  height: 16,
                  padding: 0,
                  borderRadius: 5,
                  border: "none",
                  background: "transparent",
                  color: "inherit",
                  opacity: 0.65,
                  cursor: "pointer",
                }}
              >
                <svg viewBox="0 0 12 12" width={9} height={9} fill="none" aria-hidden>
                  <path
                    d="M2.5 2.5 L9.5 9.5 M9.5 2.5 L2.5 9.5"
                    stroke="currentColor"
                    strokeWidth={1.8}
                    strokeLinecap="round"
                  />
                </svg>
              </button>
            </motion.span>
          ))}
        </AnimatePresence>

        {/* The caret rides the same reflow spring as the chips, which is
            what makes a committed chip look like it pushed the field
            along rather than replaced it. */}
        <motion.input
          ref={inputRef}
          id={`${baseId}-input`}
          layout="position"
          transition={reduceMotion ? { duration: 0 } : cfg.reflow}
          value={draft}
          placeholder={tags.length === 0 ? placeholder : ""}
          aria-describedby={`${baseId}-hint`}
          onChange={(event) => setDraft(event.target.value)}
          onKeyDown={onKeyDown}
          onFocus={() => setFocused(true)}
          onBlur={() => {
            setFocused(false);
            add();
          }}
          style={{
            flex: 1,
            minWidth: 92,
            height: 26,
            padding: "0 2px",
            fontSize: 13,
            fontFamily: "inherit",
            // An input inherits neither the page's text color nor its
            // font: both have to be asked for by name.
            color: "inherit",
            background: "transparent",
            border: "none",
            outline: "none",
          }}
        />
      </div>

      <div id={`${baseId}-hint`} style={{ fontSize: 11.5, opacity: 0.45, marginTop: 7 }}>
        Press Enter or comma to add. Backspace edits the last one.
      </div>

      <div role="status" aria-live="polite" style={SR_ONLY}>
        {announcement}
      </div>
    </div>
  );
}

About this pattern

The commit is the moment worth animating: text that was in the caret a frame ago is now an object with edges, and the field it was typed into moves over to make room. Chips and caret share one critically damped reflow spring, so the row resettles as a single body — and both move with position-only layout, because a chip is a box full of text and interpolating its box would smear the glyphs inside it. Removal is faster than addition, out-of-flow immediately, so the gap closes while the chip is still fading. Backspace on an empty field puts the last chip back in the caret rather than deleting it.

Document labelsRecipient fieldSkill or interest pickerFilter builder

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Add a supplierTwo fields now, the rest later
    Legal name
    Ridgeline Supply Co.
    Country
    Sweden
    VAT number
    SE556031820101
    Save supplier
    Form

    Text committing into an object the caret then moves past.

Related patterns