All patterns

Mention Autocomplete

Typing @ raises a people list that narrows with every keystroke.

socialminimalfriendlyinteraction · finite · advanced · ~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.

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

/**
 * Vibary · Mention Autocomplete
 *
 * Typing @ raises a people list that narrows with every keystroke.
 * The list is being read while it changes, so rows that stop matching
 * are popped out of flow and the survivors slide up into the gap — and
 * the highlight is one shared element travelling between rows, not two
 * rectangles blinking.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * The panel floats over the page, so it uses the CSS system colors
 * `Canvas`/`CanvasText` and lands opaque in a light app and in a dark one.
 * Works with zero props; tune via `variant`, `people`, `defaultValue`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type Person = {
  handle: string;
  name: string;
  initials: string;
  /** Disc color behind the initials — stands in for a photo. */
  tint: string;
  role: string;
};

export type MentionAutocompleteProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Text the composer starts with. End it with @ to open the list. */
  defaultValue?: string;
  /** Directory to search. Falls back to a sample team. */
  people?: Person[];
  /** Fires with the handle that was inserted. */
  onMention?: (handle: string) => void;
};

type VariantConfig = {
  /** px the panel rises through. */
  rise: number;
  spring: { type: "spring"; stiffness: number; damping: number };
  /** How long the list takes to close a gap. */
  reflowSeconds: number;
};

// Damping ratios (damping / 2√stiffness) stay at or above 0.8. A panel
// that overshoots while someone is typing into it feels like the list
// is arguing with the keyboard.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Almost instant. For a composer used all day.
  subtle: {
    rise: 2,
    spring: { type: "spring", stiffness: 620, damping: 46 },
    reflowSeconds: 0.14,
  },
  // Enough rise to read as anchored to the field. All-purpose.
  default: {
    rise: 8,
    spring: { type: "spring", stiffness: 440, damping: 36 },
    reflowSeconds: 0.22,
  },
  // A longer rise and a slower reflow, for a roomy composer.
  playful: {
    rise: 14,
    spring: { type: "spring", stiffness: 360, damping: 31 },
    reflowSeconds: 0.3,
  },
};

const ACCENT = "#7C7CF0";

const PEOPLE: Person[] = [
  { handle: "@amara", name: "Amara Osei", initials: "AO", tint: "#E08A3C", role: "Design" },
  { handle: "@theo", name: "Theo Lang", initials: "TL", tint: "#5B8DEF", role: "Engineering" },
  { handle: "@ana", name: "Ana Duarte", initials: "AD", tint: "#4AA3B8", role: "Research" },
  { handle: "@maya", name: "Maya Kwon", initials: "MK", tint: "#7C7CF0", role: "Design" },
  { handle: "@jonas", name: "Jonas Vik", initials: "JV", tint: "#3FA98B", role: "Data" },
];

const TRAILING_MENTION = /@([\w.]*)$/;

/** Theme-adaptive neutral: mixing the text color in scope with
 *  `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

export default function MentionAutocomplete({
  variant = "default",
  defaultValue = "Handover notes are up — worth a read @",
  people = PEOPLE,
  onMention,
}: MentionAutocompleteProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const listId = useId();
  const [value, setValue] = useState(defaultValue);
  const [dismissed, setDismissed] = useState(false);
  const [cursor, setCursor] = useState(0);

  const match = TRAILING_MENTION.exec(value);
  const query = match ? match[1].toLowerCase() : null;
  const matches =
    query === null
      ? []
      : people.filter(
          (person) =>
            person.handle.slice(1).toLowerCase().startsWith(query) ||
            person.name.toLowerCase().includes(query)
        );
  const open = !dismissed && matches.length > 0;
  // Clamped rather than reset: the highlight should survive a keystroke
  // that only removes rows below it.
  const active = Math.min(cursor, matches.length - 1);

  const insert = (person: Person) => {
    setValue(value.replace(TRAILING_MENTION, `${person.handle} `));
    setCursor(0);
    onMention?.(person.handle);
  };

  const onKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
    if (!open) return;
    if (event.key === "ArrowDown") {
      event.preventDefault();
      setCursor(Math.min(active + 1, matches.length - 1));
    } else if (event.key === "ArrowUp") {
      event.preventDefault();
      setCursor(Math.max(active - 1, 0));
    } else if (event.key === "Enter" || event.key === "Tab") {
      event.preventDefault();
      insert(matches[active]);
    } else if (event.key === "Escape") {
      setDismissed(true);
    }
  };

  // Reduced motion: the list still filters and the highlight still
  // moves — they simply arrive rather than travel.
  const reflow = {
    duration: reduceMotion ? 0 : cfg.reflowSeconds,
    ease: [0.32, 0.72, 0, 1] as const,
  };

  return (
    <div style={{ position: "relative", width: 320, fontSize: 13.5 }}>
      <AnimatePresence>
        {open && (
          <motion.div
            key="panel"
            // The panel resizes around the list; rows below carry
            // `layout="position"` so their text is never stretched by
            // that resize.
            layout
            id={listId}
            role="listbox"
            aria-label="People"
            initial={{ opacity: 0, y: reduceMotion ? 0 : cfg.rise }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: reduceMotion ? 0 : 4, transition: { duration: 0.12 } }}
            transition={reduceMotion ? { duration: 0.14, ease: "easeOut" } : cfg.spring}
            style={{
              position: "absolute",
              left: 0,
              right: 0,
              bottom: "calc(100% + 8px)",
              padding: 5,
              borderRadius: 14,
              // Floating over the page, so it cannot be translucent.
              // `Canvas`/`CanvasText` are the CSS system colors for page
              // background and page text, and follow the host app's
              // color scheme.
              background: "Canvas",
              color: "CanvasText",
              border: `1px solid ${tone(14)}`,
              boxShadow: "0 14px 34px rgba(0,0,0,0.22)",
              overflow: "hidden",
            }}
          >
            <AnimatePresence initial={false} mode="popLayout">
              {matches.map((person, index) => (
                <motion.div
                  key={person.handle}
                  layout="position"
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                  exit={{ opacity: 0, transition: { duration: 0.1 } }}
                  transition={reflow}
                  style={{ position: "relative" }}
                >
                  {index === active && (
                    // One shared rectangle for the whole list: arrowing
                    // down moves an object instead of blinking two.
                    <motion.span
                      // Scoped to this instance: a hard-coded layoutId
                      // would make two pickers on one page trade
                      // highlights with each other.
                      layoutId={`${listId}-highlight`}
                      transition={reflow}
                      style={{
                        position: "absolute",
                        inset: 0,
                        borderRadius: 10,
                        background: tone(8),
                        border: `1px solid ${tone(12)}`,
                      }}
                    />
                  )}
                  <button
                    type="button"
                    id={`${listId}-${index}`}
                    role="option"
                    aria-selected={index === active}
                    onMouseDown={(event) => event.preventDefault()}
                    onMouseEnter={() => setCursor(index)}
                    onClick={() => insert(person)}
                    style={{
                      position: "relative",
                      display: "flex",
                      alignItems: "center",
                      gap: 9,
                      width: "100%",
                      padding: "7px 9px",
                      border: 0,
                      borderRadius: 10,
                      background: "none",
                      color: "inherit",
                      fontFamily: "inherit",
                      fontSize: 13,
                      textAlign: "left",
                      cursor: "pointer",
                    }}
                  >
                    <span
                      aria-hidden
                      style={{
                        flexShrink: 0,
                        display: "grid",
                        placeItems: "center",
                        width: 26,
                        height: 26,
                        borderRadius: "50%",
                        background: person.tint,
                        color: "#ffffff",
                        fontSize: 10.5,
                        fontWeight: 650,
                      }}
                    >
                      {person.initials}
                    </span>
                    <span style={{ flex: 1, minWidth: 0 }}>
                      <span style={{ fontWeight: 600 }}>{person.name}</span>
                      <span style={{ opacity: 0.5 }}> {person.handle}</span>
                    </span>
                    <span style={{ fontSize: 11, opacity: 0.45 }}>{person.role}</span>
                  </button>
                </motion.div>
              ))}
            </AnimatePresence>
          </motion.div>
        )}
      </AnimatePresence>

      <label
        style={{
          display: "block",
          fontSize: 11.5,
          fontWeight: 600,
          letterSpacing: 0.3,
          textTransform: "uppercase",
          opacity: 0.45,
          marginBottom: 6,
        }}
      >
        Add a comment
        <input
          value={value}
          onChange={(event) => {
            setValue(event.target.value);
            setDismissed(false);
          }}
          onKeyDown={onKeyDown}
          // A text field that owns a list of options is a combobox, and
          // the role is what lets `aria-expanded` and the active row be
          // announced at all.
          role="combobox"
          aria-autocomplete="list"
          aria-expanded={open}
          aria-controls={listId}
          aria-activedescendant={open ? `${listId}-${active}` : undefined}
          style={{
            display: "block",
            width: "100%",
            marginTop: 6,
            padding: "11px 13px",
            borderRadius: 12,
            border: `1px solid ${open ? ACCENT : tone(13)}`,
            background: tone(5),
            color: "inherit",
            fontFamily: "inherit",
            fontSize: 13.5,
            fontWeight: 400,
            letterSpacing: "normal",
            textTransform: "none",
            outline: "none",
            boxSizing: "border-box",
          }}
        />
      </label>
    </div>
  );
}

About this pattern

The list is being read while it changes, which makes removal the delicate part: rows that no longer match are popped out of flow and the survivors slide up into the gap rather than the whole list re-rendering in place. The highlight is a single shared element that travels between rows, so arrowing down reads as one object moving instead of two rectangles blinking. Text keeps a constant size throughout — the panel resizes around it, never with it.

Mentioning a person in a commentTagging a teammate in a noteAssigning from a text fieldInline channel or user lookup

Where it shows up

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

  • 10:15
    Dana Whitfieldonline
    Morning — did the venue confirm?
    They did, contract came back signed.10:14
    Are we still on for Thursday?
    Yes — booked the room for 2pm.10:14
    Perfect. I'll bring the printouts.
    See you then.10:14
    Message
    Chat thread

    A people list above the composer that filters down as the name is typed.

Related patterns