# Morphing Search Chip Search

> Docked search bar that springs wider on focus and unfolds a recent-searches panel out of its own edge. Terms become removable chips, the bar grows with them, and suggestions filter as you type.

A shadcn/ui block from the [@beui](https://beui.dev) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/beui/morphing-search-chips).

- Page, with a live preview: https://shadcn.exchange/r/beui/morphing-search-chips
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/beui/morphing-search-chips-light-00d47ea5905f14cc.png
- Registry item: https://beui.dev/r/morphing-search-chips.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://beui.dev/r/morphing-search-chips.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `clsx`, `lucide-react`, `motion`, `tailwind-merge`

## Files

### `components/motion/morphing-search-chips.tsx`

```tsx
"use client";
// beui.dev/components/blocks/morphing-search

import { Clock, LoaderCircle, Search, X } from "lucide-react";
import {
  AnimatePresence,
  motion,
  type Transition,
  useReducedMotion,
} from "motion/react";
import {
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
  type ReactNode,
  useCallback,
  useEffect,
  useId,
  useLayoutEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { EASE_OUT, SPRING_LAYOUT, SPRING_SWAP } from "@/lib/ease";
import { useDismiss } from "@/lib/hooks/use-dismiss";
import { useOnOpen } from "@/lib/hooks/use-on-open";
import { useRowCursor } from "@/lib/hooks/use-row-cursor";
import { PresenceGate } from "@/lib/presence-gate";
import { cn } from "@/lib/utils";

// The panel's glass grows out of the bar on a spring with a little give: the
// shared tokens are critically damped, and this one is meant to land with a
// soft overshoot, so it stays local. It folds back faster than it grew.
const SPRING_MORPH = { type: "spring", stiffness: 380, damping: 28, mass: 0.8 } as const;
const MORPH_ENTER: Transition = { ...SPRING_MORPH, opacity: { duration: 0.1 } };
const MORPH_EXIT: Transition = {
  type: "spring",
  stiffness: 520,
  damping: 40,
  mass: 0.6,
  opacity: { duration: 0.12, delay: 0.1 },
};
// The pill the panel starts from and returns to: centred, and tucked halfway
// behind the bar (across the 8px gap) so it emerges from the bar's edge.
const SEED = { width: 112, height: 40 };
const SEED_TUCK = 28;
// Content fades in once the shape has mostly arrived and leaves before it goes.
const CONTENT_IN: Transition = { duration: 0.18, ease: EASE_OUT, delay: 0.12 };
const CONTENT_OUT: Transition = { duration: 0.08, ease: EASE_OUT };
// Glass: a thin tint of the page colour over a saturated blur, a hairline edge,
// a top highlight and a soft drop. Written inline so the install carries it
// without the site's stylesheet.
const GLASS =
  "border border-foreground/[0.08] bg-background/55 backdrop-blur-[20px] backdrop-saturate-[1.6] shadow-[inset_0_1px_0_0_rgb(255_255_255/0.06),0_24px_60px_-24px_rgb(0_0_0/0.45)]";
// Chips arrive on a soft spring with a touch of blur and leave on a short tween,
// faster than they came.
const CHIP_HIDDEN = { opacity: 0, scale: 0.6, filter: "blur(4px)" };
const CHIP_SHOWN = { opacity: 1, scale: 1, filter: "blur(0px)" };
const CHIP_ENTER: Transition = {
  ...SPRING_SWAP,
  opacity: { duration: 0.16 },
  filter: { duration: 0.2 },
};
const CHIP_EXIT: Transition = { duration: 0.16, ease: EASE_OUT };
// Half the 56px bar: a pill, and the panel shares its corners.
const SURFACE_RADIUS = 28;
// Room the panel wants above the bar before "auto" sends it below instead.
const PANEL_ROOM = 320;

type Row = { id: string; label: string; kind: "query" | "recent" | "suggestion" };
type Side = "top" | "bottom";

export interface MorphingSearchChipsProps {
  /** Active filters shown as chips inside the bar. */
  chips?: string[];
  defaultChips?: string[];
  onChipsChange?: (chips: string[]) => void;
  /** Text in the field. */
  query?: string;
  defaultQuery?: string;
  onQueryChange?: (query: string) => void;
  /** Recent searches, newest first. */
  recents?: string[];
  defaultRecents?: string[];
  onRecentsChange?: (recents: string[]) => void;
  /** Persist uncontrolled recents in localStorage under this key. */
  storageKey?: string;
  /** How many recent searches to keep. Default 5. */
  maxRecents?: number;
  /** Terms offered while typing. */
  suggestions?: string[];
  /**
   * How suggestions match the query. Default: case-insensitive substring.
   * Pass `false` when `suggestions` already arrive filtered, e.g. from a server.
   */
  filter?: ((term: string, query: string) => boolean) | false;
  /** How many matches to list under the typed query. Default 6. */
  maxSuggestions?: number;
  /** Suggestions are being fetched. */
  loading?: boolean;
  /** Most chips allowed at once. */
  maxChips?: number;
  /** Clean a term before it becomes a chip; return null to reject it. */
  normalize?: (term: string) => string | null;
  /** Shown when the field is empty and there are no recent searches. */
  emptyState?: ReactNode;
  /** Which way the surface grows. "auto" grows up unless there is no room. Default "top". */
  side?: "top" | "bottom" | "auto";
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  disabled?: boolean;
  /** Submit each chip with a form under this name. */
  name?: string;
  placeholder?: string;
  /** Accessible name for the search field. */
  label?: string;
  /** Trailing controls inside the bar, such as filter buttons. */
  actions?: ReactNode;
  className?: string;
}

function useControllable<T>(value: T | undefined, initial: T, onChange?: (next: T) => void) {
  const [internal, setInternal] = useState(initial);
  const current = value ?? internal;
  const set = useCallback(
    (next: T) => {
      if (value === undefined) setInternal(next);
      onChange?.(next);
    },
    [value, onChange],
  );
  return [current, set, setInternal] as const;
}

const sameTerm = (a: string, b: string) => a.trim().toLowerCase() === b.trim().toLowerCase();
const contains = (term: string, query: string) => term.toLowerCase().includes(query.toLowerCase());

function Highlight({ text, query }: { text: string; query: string }) {
  const at = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1;
  if (at < 0) return text;
  return (
    <>
      {text.slice(0, at)}
      <span className="font-semibold">{text.slice(at, at + query.length)}</span>
      {text.slice(at + query.length)}
    </>
  );
}

export function MorphingSearchChips({
  chips: chipsProp,
  defaultChips = [],
  onChipsChange,
  query: queryProp,
  defaultQuery = "",
  onQueryChange,
  recents: recentsProp,
  defaultRecents = [],
  onRecentsChange,
  storageKey,
  maxRecents = 5,
  suggestions = [],
  filter,
  maxSuggestions = 6,
  loading = false,
  maxChips,
  normalize,
  emptyState,
  side = "top",
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  disabled = false,
  name,
  placeholder = "Search",
  label = "Search",
  actions,
  className,
}: MorphingSearchChipsProps) {
  const reduce = useReducedMotion();
  const [chips, setChips] = useControllable(chipsProp, defaultChips, onChipsChange);
  const [query, setQuery] = useControllable(queryProp, defaultQuery, onQueryChange);
  const [recents, setRecentsState, setStoredRecents] = useControllable(
    recentsProp,
    defaultRecents,
    onRecentsChange,
  );
  const [openState, setOpen] = useControllable(openProp, defaultOpen, onOpenChange);
  const open = openState && !disabled;
  const [announcement, setAnnouncement] = useState({ text: "", count: 0 });
  const [markedChip, setMarkedChip] = useState<string | null>(null);
  const [placed, setPlaced] = useState<Side>(side === "bottom" ? "bottom" : "top");
  const [rtl, setRtl] = useState(false);
  const rootRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const chipsRef = useRef<HTMLDivElement>(null);
  const id = useId();
  const listId = `${id}-list`;
  const hintId = `${id}-hint`;
  const optionId = (index: number) => `${id}-option-${index}`;

  // The same message twice is still news; the count makes each one a change.
  const announce = useCallback(
    (text: string) => setAnnouncement((current) => ({ text, count: current.count + 1 })),
    [],
  );

  // A chip the keyboard has marked for removal, held by identity. Cleared rather
  // than ignored once it leaves, so the same term added back is not born marked.
  const marked = markedChip !== null && chips.includes(markedChip) ? markedChip : null;
  if (markedChip !== null && marked === null) setMarkedChip(null);

  // The glass grows toward the measured size of the panel's content.
  const [panelSize, setPanelSize] = useState({ width: 0, height: 0 });
  const panelObserver = useRef<ResizeObserver | null>(null);
  const measurePanel = useCallback((node: HTMLDivElement | null) => {
    panelObserver.current?.disconnect();
    panelObserver.current = null;
    if (!node) return;
    const read = () => setPanelSize({ width: node.offsetWidth, height: node.offsetHeight });
    read();
    panelObserver.current = new ResizeObserver(read);
    panelObserver.current.observe(node);
  }, []);

  const chipsLatest = useRef(chips);
  useLayoutEffect(() => {
    chipsLatest.current = chips;
  });

  // Direction decides which way chips unroll and which arrow walks back into them.
  useLayoutEffect(() => {
    if (rootRef.current) setRtl(getComputedStyle(rootRef.current).direction === "rtl");
  }, []);

  // Recents persist only when the component owns them.
  const persist = storageKey && recentsProp === undefined ? storageKey : null;
  useEffect(() => {
    if (!persist) return;
    try {
      const stored = JSON.parse(window.localStorage.getItem(persist) ?? "null");
      if (Array.isArray(stored)) {
        setStoredRecents(
          stored.filter((term): term is string => typeof term === "string").slice(0, maxRecents),
        );
      }
    } catch {
      // Storage can be blocked or hold something else; recents simply start empty.
    }
  }, [persist, maxRecents, setStoredRecents]);

  const setRecents = (next: string[]) => {
    setRecentsState(next);
    if (!persist) return;
    try {
      window.localStorage.setItem(persist, JSON.stringify(next));
    } catch {
      // A full or blocked store keeps this session's recents in memory only.
    }
  };

  const trimmed = query.trim();
  const rows = useMemo<Row[]>(() => {
    if (!trimmed) {
      return recents.map((term) => ({ id: `recent:${term}`, label: term, kind: "recent" }));
    }
    const seen = new Set([trimmed.toLowerCase(), ...chips.map((chip) => chip.toLowerCase())]);
    const matches: Row[] = [];
    const pools = [
      ["recent", recents, contains],
      ["suggestion", suggestions, filter === false ? () => true : (filter ?? contains)],
    ] as const;
    for (const [kind, pool, matchFn] of pools) {
      for (const term of pool) {
        const key = term.toLowerCase();
        if (seen.has(key) || !matchFn(term, trimmed)) continue;
        seen.add(key);
        matches.push({ id: `${kind}:${term}`, label: term, kind });
      }
    }
    return [
      { id: `query:${trimmed}`, label: trimmed, kind: "query" },
      ...matches.slice(0, maxSuggestions),
    ];
  }, [trimmed, recents, suggestions, chips, filter, maxSuggestions]);

  const { activeIndex, moveTo, moveActive } = useRowCursor(rows, trimmed);
  const active = rows[activeIndex];
  const showList = rows.length > 0;
  // Nothing to say means nothing to grow for: the bar still widens on focus.
  const hasPanel = showList || loading || (!trimmed && emptyState != null);
  const panelOpen = open && hasPanel;

  // A fresh session each time the panel opens: the highlight starts at the top.
  useOnOpen(open, () => moveTo(null));

  // The tallest the panel has been since it opened. Typing can grow the card but
  // never shrink it; each new session starts from its own content again.
  const [panelFloor, setPanelFloor] = useState(0);
  useOnOpen(panelOpen, () => setPanelFloor(0));
  const rowsObserver = useRef<ResizeObserver | null>(null);
  const measureRows = useCallback((node: HTMLDivElement | null) => {
    rowsObserver.current?.disconnect();
    rowsObserver.current = null;
    if (!node) return;
    // The wrapper's p-2 adds 16px around the rows.
    const read = () => setPanelFloor((floor) => Math.max(floor, node.offsetHeight + 16));
    read();
    rowsObserver.current = new ResizeObserver(read);
    rowsObserver.current.observe(node);
  }, []);

  // "auto" picks a side as the panel opens and keeps it until it closes.
  useLayoutEffect(() => {
    if (!panelOpen) return;
    if (side !== "auto") {
      setPlaced(side);
      return;
    }
    const rect = rootRef.current?.getBoundingClientRect();
    if (!rect) return;
    const above = rect.top;
    const below = window.innerHeight - rect.bottom;
    setPlaced(above < PANEL_ROOM && below > above ? "bottom" : "top");
  }, [panelOpen, side]);

  // Arrow keys can walk past the visible rows of a capped list.
  const activeOptionId = panelOpen && showList ? optionId(activeIndex) : undefined;
  useEffect(() => {
    if (activeOptionId) document.getElementById(activeOptionId)?.scrollIntoView({ block: "nearest" });
  }, [activeOptionId]);

  const close = useCallback(() => setOpen(false), [setOpen]);
  useDismiss(open, close, rootRef);

  // A chip added past the visible edge of a crowded row scrolls into view.
  const chipCount = useRef(chips.length);
  useEffect(() => {
    const row = chipsRef.current;
    const added = chips.length > chipCount.current;
    chipCount.current = chips.length;
    if (!row || !added || row.scrollWidth <= row.clientWidth) return;
    row.scrollTo({ left: rtl ? -row.scrollWidth : row.scrollWidth, behavior: reduce ? "auto" : "smooth" });
  }, [chips.length, reduce, rtl]);

  const full = maxChips !== undefined && chips.length >= maxChips;

  const addChip = (term: string) => {
    const value = normalize ? normalize(term) : term.trim();
    if (!value) return;
    const exists = chips.some((chip) => sameTerm(chip, value));
    if (!exists && full) {
      announce(`Up to ${maxChips} filters`);
      return;
    }
    if (!exists) {
      setChips([...chips, value]);
      announce(`Added filter ${value}`);
    }
    setRecents([value, ...recents.filter((recent) => !sameTerm(recent, value))].slice(0, maxRecents));
    setQuery("");
  };

  const removeChip = (term: string) => {
    setChips(chips.filter((chip) => chip !== term));
    announce(`Removed filter ${term}`);
    inputRef.current?.focus();
  };

  const removeRecent = (term: string) => {
    setRecents(recents.filter((recent) => recent !== term));
    announce(`Removed ${term} from recent searches`);
  };

  // Steps the marked chip from the one it is on now, inside the update, so two
  // presses in one batch move two chips.
  const stepMark = (direction: 1 | -1) =>
    setMarkedChip((current) => {
      const list = chipsLatest.current;
      if (list.length === 0) return null;
      const at = current === null ? list.length : list.indexOf(current);
      const next = at + direction;
      if (next >= list.length) return null;
      return list[Math.max(next, 0)];
    });

  const onKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
    // An input method is still composing; Enter and arrows belong to it.
    if (event.nativeEvent.isComposing || event.keyCode === 229) return;
    const field = event.currentTarget;
    const atStart = field.selectionStart === 0 && field.selectionEnd === 0;
    const back = rtl ? "ArrowRight" : "ArrowLeft";
    const forward = rtl ? "ArrowLeft" : "ArrowRight";

    if (marked !== null) {
      if (event.key === "Backspace" || event.key === "Delete") {
        event.preventDefault();
        setMarkedChip(null);
        removeChip(marked);
        return;
      }
      if (event.key === back || event.key === forward) {
        event.preventDefault();
        stepMark(event.key === back ? -1 : 1);
        return;
      }
      if (event.key === "Escape") {
        event.preventDefault();
        event.stopPropagation();
        setMarkedChip(null);
        return;
      }
      setMarkedChip(null);
    }

    if (event.key === "Escape" && query !== "") {
      // Clear before closing: one press undoes typing, the next leaves.
      event.preventDefault();
      event.stopPropagation();
      setQuery("");
      return;
    }
    if (event.key === "ArrowDown" || event.key === "ArrowUp") {
      event.preventDefault();
      if (!open) setOpen(true);
      else moveActive(event.key === "ArrowDown" ? 1 : -1);
      return;
    }
    if (event.key === "Enter") {
      if (!panelOpen || !active) return;
      event.preventDefault();
      addChip(active.label);
      return;
    }
    if (event.key === "Delete" && panelOpen && active?.kind === "recent") {
      event.preventDefault();
      removeRecent(active.label);
      return;
    }
    if (event.key === "Backspace" && atStart && chips.length > 0) {
      // Mark first, remove on the second press: one stray key never loses a filter.
      event.preventDefault();
      const last = chips[chips.length - 1];
      setMarkedChip(last);
      announce(`${last} selected. Press Backspace again to remove it.`);
      return;
    }
    if (event.key === back && atStart && chips.length > 0) {
      event.preventDefault();
      stepMark(-1);
    }
  };

  // A press anywhere on the bar that is not its own control lands in the field.
  const focusField = (event: ReactPointerEvent) => {
    if (disabled || (event.target as Element).closest("button, input, a")) return;
    event.preventDefault();
    inputRef.current?.focus();
    setOpen(true);
  };

  const layout = reduce ? { duration: 0 } : SPRING_LAYOUT;
  const below = placed === "bottom";
  const seed = { ...SEED, y: below ? -SEED_TUCK : SEED_TUCK };

  const panel = (
    <>
      <div className="flex h-9 items-center justify-between px-3">
        <span className="text-xs font-medium text-muted-foreground">
          {trimmed ? "Suggestions" : "Recent"}
        </span>
        {!trimmed && recents.length > 0 ? (
          <button
            type="button"
            onClick={() => {
              setRecents([]);
              announce("Cleared recent searches");
              inputRef.current?.focus();
            }}
            className="rounded-md px-1.5 py-0.5 text-xs text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
          >
            Clear
          </button>
        ) : null}
      </div>
      {showList ? (
        <div
          id={listId}
          role="listbox"
          aria-label={trimmed ? "Suggestions" : "Recent searches"}
          aria-busy={loading || undefined}
          className="max-h-[min(20rem,45vh)] overflow-y-auto overscroll-contain [scrollbar-width:thin]"
        >
          {rows.map((row, index) => {
            const selected = index === activeIndex;
            return (
              // Options are driven from the combobox field (arrows, Enter) and
              // never take focus themselves, as aria-activedescendant expects.
              <motion.div
                key={row.id}
                id={optionId(index)}
                role="option"
                aria-selected={selected}
                onPointerDown={(event) => event.preventDefault()}
                onPointerMove={() => moveTo(row.id)}
                onClick={() => addChip(row.label)}
                className="relative flex h-11 cursor-pointer items-center gap-3 rounded-2xl px-3 text-sm text-foreground"
              >
                {selected ? (
                  <motion.span
                    layoutId={`${id}-active`}
                    transition={layout}
                    className="absolute inset-0 rounded-2xl bg-foreground/[0.06]"
                  />
                ) : null}
                {row.kind === "recent" ? (
                  <Clock aria-hidden="true" className="relative size-4 shrink-0 text-muted-foreground" />
                ) : (
                  <Search aria-hidden="true" className="relative size-4 shrink-0 text-muted-foreground" />
                )}
                <span className="relative min-w-0 flex-1 truncate">
                  {row.kind === "query" ? row.label : <Highlight text={row.label} query={trimmed} />}
                </span>
                {row.kind === "recent" ? (
                  // Pointer shortcut only: the option cannot hold a second control,
                  // and Delete removes the highlighted recent from the keyboard.
                  <span
                    aria-hidden="true"
                    onClick={(event) => {
                      event.stopPropagation();
                      removeRecent(row.label);
                    }}
                    className="relative grid size-7 shrink-0 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-foreground/10 hover:text-foreground"
                  >
                    <X className="size-3.5" />
                  </span>
                ) : null}
              </motion.div>
            );
          })}
        </div>
      ) : !trimmed && emptyState != null ? (
        <div className="px-3 pb-2 pt-1 text-sm text-muted-foreground">{emptyState}</div>
      ) : null}
      {loading ? (
        <div className="flex h-11 items-center gap-3 px-3 text-sm text-muted-foreground">
          <LoaderCircle aria-hidden="true" className="size-4 motion-safe:animate-spin" />
          Searching…
        </div>
      ) : null}
    </>
  );

  return (
    // The root keeps the bar's footprint; the panel floats over the page beside it.
    // biome-ignore lint/a11y/noStaticElementInteractions: Observes focus leaving the whole widget; the field and buttons own interaction.
    <div
      ref={rootRef}
      className={cn(
        "relative flex h-14 w-full max-w-xl justify-center",
        disabled && "opacity-60",
        className,
      )}
      onBlur={(event) => {
        if (rootRef.current?.contains(event.relatedTarget as Node | null)) return;
        setMarkedChip(null);
        close();
      }}
    >
      <AnimatePresence>
        {panelOpen ? (
          <PresenceGate key="panel">
            {({ gate }) => (
              <div
                {...gate}
                className={cn(
                  "absolute inset-x-0 z-10",
                  below ? "top-full mt-2" : "bottom-full mb-2",
                )}
              >
                {/* The card's glass starts as a small pill on the bar's edge and
                    springs out to the size of what it holds, then back on close.
                    Width and height animate rather than scale so the corners stay
                    true; the layer is absolute and decorative, so nothing reflows. */}
                <motion.div
                  aria-hidden="true"
                  initial={reduce ? { opacity: 0 } : { ...seed, opacity: 0 }}
                  animate={{
                    width: panelSize.width || SEED.width,
                    height: panelSize.height || SEED.height,
                    y: 0,
                    opacity: 1,
                  }}
                  exit={
                    reduce
                      ? { opacity: 0, transition: { duration: 0.12 } }
                      : { ...seed, opacity: 0, transition: MORPH_EXIT }
                  }
                  transition={reduce ? { duration: 0 } : MORPH_ENTER}
                  style={{ borderRadius: SURFACE_RADIUS, x: "-50%" }}
                  className={cn("absolute left-1/2", below ? "top-0" : "bottom-0", GLASS)}
                />
                {/* Content waits for the shape to arrive and leaves before it goes. */}
                <motion.div
                  initial={{ opacity: 0, y: reduce ? 0 : below ? -6 : 6 }}
                  animate={{ opacity: 1, y: 0, transition: reduce ? { duration: 0.15 } : CONTENT_IN }}
                  exit={{ opacity: 0, transition: CONTENT_OUT }}
                  ref={measurePanel}
                  // Never shorter than this session's tallest list, so narrowing a
                  // search leaves empty glass instead of pulling the card down.
                  style={{ minHeight: panelFloor || undefined }}
                  className="relative p-2"
                >
                  <div ref={measureRows}>{panel}</div>
                </motion.div>
              </div>
            )}
          </PresenceGate>
        ) : null}
      </AnimatePresence>

      <motion.div
        layout
        transition={layout}
        onPointerDown={focusField}
        style={{ borderRadius: SURFACE_RADIUS }}
        className={cn(
          // Raised over the panel so the growing shape emerges from behind it.
          "relative z-20 flex h-14 min-w-0 items-center gap-2 ps-4",
          GLASS,
          disabled ? "cursor-not-allowed" : "cursor-text",
          // Trailing buttons carry their own inset; bare text needs the full one.
          actions ? "pe-2" : "pe-4",
          open ? "w-full" : "w-fit max-w-full",
        )}
      >
        <Search aria-hidden="true" className="size-5 shrink-0 text-muted-foreground" />
            <div
              ref={chipsRef}
              // The padding at each end is where a scrolled chip fades out; with
              // nothing scrolled under it the padding is empty, so the fade is
              // invisible. Relative, so a leaving chip popped out of flow stays
              // inside the scrolling row.
              className={cn(
                "relative flex min-w-0 items-center gap-1.5 overflow-x-auto [scrollbar-width:none]",
                chips.length > 0 &&
                  "-mx-1.5 px-3 [mask-image:linear-gradient(to_right,transparent,#000_12px,#000_calc(100%-12px),transparent)]",
              )}
            >
              <AnimatePresence initial={false} mode="popLayout">
                {chips.map((chip) => (
                  <motion.span
                    key={chip}
                    layout={reduce ? false : "position"}
                    initial={reduce ? { opacity: 0 } : CHIP_HIDDEN}
                    animate={reduce ? { opacity: 1 } : CHIP_SHOWN}
                    exit={
                      reduce
                        ? { opacity: 0, transition: { duration: 0.1 } }
                        : { ...CHIP_HIDDEN, transition: CHIP_EXIT }
                    }
                    transition={reduce ? { duration: 0.12 } : CHIP_ENTER}
                    // Grown from and folded back into the edge it is attached to, so a
                    // chip reads as unrolling out of the one before it.
                    style={{ transformOrigin: rtl ? "right center" : "left center" }}
                    className={cn(
                      "flex h-9 shrink-0 items-center gap-1 rounded-full ps-3 pe-1 text-sm text-foreground transition-[background-color,box-shadow] duration-150",
                      marked === chip
                        ? // Inset: the chip fills its scrolling row, which clips anything drawn outside it.
                        "bg-foreground/[0.12] ring-2 ring-inset ring-foreground/40"
                        : "bg-foreground/[0.07]",
                    )}
                  >
                    <span className="max-w-40 truncate" title={chip}>
                      {chip}
                    </span>
                    <button
                      type="button"
                      disabled={disabled}
                      aria-label={`Remove filter ${chip}`}
                      onClick={() => removeChip(chip)}
                      className="grid size-7 shrink-0 place-items-center rounded-full text-muted-foreground outline-none transition-colors hover:bg-foreground/10 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none"
                    >
                      <X aria-hidden="true" className="size-3.5" />
                    </button>
                  </motion.span>
                ))}
              </AnimatePresence>
            </div>
            <motion.input
              ref={inputRef}
              // Glides aside as chips arrive and back as they leave.
              layout={reduce ? false : "position"}
              transition={layout}
              type="text"
              role="combobox"
              aria-label={label}
              aria-expanded={panelOpen}
              aria-controls={panelOpen && showList ? listId : undefined}
              aria-autocomplete="list"
              aria-activedescendant={activeOptionId}
              aria-describedby={hintId}
              autoComplete="off"
              spellCheck={false}
              enterKeyHint="search"
              disabled={disabled}
              value={query}
              placeholder={placeholder}
              onChange={(event) => {
                setQuery(event.target.value);
                if (!open) setOpen(true);
              }}
              onFocus={() => setOpen(true)}
              // Escape closes the panel but leaves focus here; a click brings it back.
              onClick={() => setOpen(true)}
              onKeyDown={onKeyDown}
              // At rest the field is as wide as its placeholder, so the bar hugs its content.
              style={open ? undefined : { width: `${Math.max(placeholder.length, 6) + 2}ch` }}
              className={cn(
                // On a narrow screen the chips scroll first; the field keeps room to type.
                "h-9 min-w-24 text-ellipsis bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed",
                open && "flex-1",
              )}
            />
            {actions ? <div className="flex shrink-0 items-center gap-1">{actions}</div> : null}
      </motion.div>

      {name
        ? chips.map((chip) => <input key={chip} type="hidden" name={name} value={chip} />)
        : null}
      <span id={hintId} className="sr-only">
        Enter adds a filter. Backspace at the start of the field selects the last filter, and
        again removes it. Delete removes the highlighted recent search.
      </span>
      <span aria-live="polite" className="sr-only">
        {announcement.text}
        {/* Alternates an invisible character so a repeated message still changes. */}
        {announcement.count % 2 ? " " : ""}
      </span>
    </div>
  );
}
```

### `lib/ease.ts`

```ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.

export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;

/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";

/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
  type: "spring",
  stiffness: 500,
  damping: 30,
  mass: 0.6,
} as const;

/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
  type: "spring",
  stiffness: 460,
  damping: 30,
  mass: 0.55,
} as const;

/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
  type: "spring",
  stiffness: 420,
  damping: 40,
  mass: 0.5,
} as const;

/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
  type: "spring",
  stiffness: 360,
  damping: 32,
  mass: 0.6,
} as const;

/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
  stiffness: 200,
  damping: 15,
  mass: 0.3,
} as const;

/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
 * so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
  stiffness: 700,
  damping: 50,
  mass: 0.5,
} as const;
```

### `lib/hooks/use-dismiss.ts`

```ts
"use client";

import { type RefObject, useEffect } from "react";

/**
 * What the dismissing gesture does to the control it landed on.
 *
 * `"pass-through"` is the platform norm (native popover light-dismiss): the
 * tap closes the overlay *and* activates whatever was under it. Use
 * `"consume"` where the open overlay sits over or beside controls that would
 * be costly to trigger by accident — the dismissal then swallows the
 * activation too, so the gesture only closes.
 */
export type DismissBehavior = "pass-through" | "consume";

export interface DismissOptions {
  /** Default `"pass-through"`. */
  behavior?: DismissBehavior;
  /** Dismiss on Escape as well. Default true. */
  escape?: boolean;
  /** Return true for an outside target that should *not* dismiss. Must be stable. */
  ignore?: (target: Element) => boolean;
}

/**
 * What every currently open dismiss scope counts as inside itself. A consumed
 * dismissal reads this to tell a stray gesture from one that belongs to an
 * overlay in front of it: overlays have no shared z-order to consult, but the
 * one the gesture landed in has said as much by registering it.
 */
const openScopes = new Set<(target: Element) => boolean>();

function claimedByAnotherScope(
  self: (target: Element) => boolean,
  target: Element,
) {
  for (const scope of openScopes) {
    if (scope !== self && scope(target)) return true;
  }
  return false;
}

// preventDefault on pointerdown does not suppress the click that follows, so
// consuming a gesture means swallowing that click itself. The swallower
// deliberately outlives the effect that installed it — the dismissal it
// belongs to has already unmounted or re-rendered by the time the click lands.
// It releases on that click, or on the next gesture if the pointer is dragged
// away and no click ever arrives, so it can never eat a later one. A keydown
// releases it too: a gesture that ends with neither a click nor a cancel would
// otherwise leave it armed, and the click Enter synthesizes on some focused
// control is not the one this dismissal was owed.
function consumeActivation(source: Event) {
  const swallow = (event: MouseEvent) => {
    event.preventDefault();
    event.stopPropagation();
    release();
  };
  const restart = (event: Event) => {
    if (event !== source) release();
  };
  const release = () => {
    window.removeEventListener("click", swallow, true);
    window.removeEventListener("pointerdown", restart, true);
    window.removeEventListener("pointercancel", restart, true);
    window.removeEventListener("keydown", release, true);
  };
  window.addEventListener("click", swallow, true);
  window.addEventListener("pointerdown", restart, true);
  window.addEventListener("pointercancel", restart, true);
  window.addEventListener("keydown", release, true);
}

/**
 * Close an open overlay on Escape or a pointerdown outside `ref`. Pass `null`
 * for `ref` when what counts as inside isn't one element, and say so with
 * `ignore` instead.
 *
 * The pointerdown listener is capture-phase: a bubble-phase one is blinded by
 * any handler in between that stops propagation, and an overlay cannot know
 * what it is layered over. `onDismiss` and `ignore` must be stable (wrap in
 * useCallback) so the listeners aren't re-bound every render while open.
 */
export function useDismiss(
  open: boolean,
  onDismiss: () => void,
  ref: RefObject<HTMLElement | SVGElement | null> | null,
  {
    behavior = "pass-through",
    escape: dismissOnEscape = true,
    ignore,
  }: DismissOptions = {},
) {
  useEffect(() => {
    if (!open) return;
    const inside = (target: Element) =>
      Boolean(ref?.current?.contains(target)) || Boolean(ignore?.(target));
    const onKey = (event: KeyboardEvent) => {
      if (dismissOnEscape && event.key === "Escape") onDismiss();
    };
    const onPointer = (event: PointerEvent) => {
      const target = event.target as Element | null;
      if (!target || inside(target)) return;
      // Outside this overlay, but inside one that is also open: the gesture is
      // that overlay's to answer, and swallowing its click from behind would
      // cost the user the control they actually aimed at.
      if (behavior === "consume" && !claimedByAnotherScope(inside, target)) {
        consumeActivation(event);
      }
      onDismiss();
    };
    openScopes.add(inside);
    window.addEventListener("keydown", onKey);
    window.addEventListener("pointerdown", onPointer, true);
    return () => {
      openScopes.delete(inside);
      window.removeEventListener("keydown", onKey);
      window.removeEventListener("pointerdown", onPointer, true);
    };
  }, [open, onDismiss, ref, behavior, dismissOnEscape, ignore]);
}
```

### `lib/hooks/use-on-open.ts`

```ts
"use client";

import { useState } from "react";

/**
 * Runs `start` during the render in which `open` becomes true.
 *
 * Opening a list starts a fresh session — an empty query, the highlight back at
 * the top — and that is a resolution, not a side effect: a passive effect runs
 * after the commit, so the new session would carry the last one's state for a
 * window in which a key press can land.
 *
 * `start` may only set state belonging to the calling component. Anything that
 * reaches outside it — a consumer's callback, a DOM write, moving focus — is a
 * side effect, and React rejects a callback that sets another component's state
 * during this one's render. Put those in an effect keyed to `open`.
 */
export function useOnOpen(open: boolean, start: () => void) {
  const [wasOpen, setWasOpen] = useState(open);
  if (open !== wasOpen) {
    setWasOpen(open);
    if (open) start();
  }
}
```

### `lib/hooks/use-row-cursor.ts`

```ts
"use client";

import { useCallback, useLayoutEffect, useRef, useState } from "react";

/**
 * Where the keyboard or the pointer last moved to: the row's id, stamped with
 * the query it was placed under.
 */
type RowCursor = { id: string; query: string };

/** The cursor's row, or -1 once the query has moved on or the row has left. */
function indexOfCursor(
  rows: readonly { id: string }[],
  query: string,
  cursor: RowCursor | null,
) {
  if (cursor === null || cursor.query !== query) return -1;
  return rows.findIndex((row) => row.id === cursor.id);
}

/**
 * The highlighted row of a list whose rows can change under it.
 *
 * Which row is highlighted is resolved during render, never in a passive
 * effect: a passive effect runs after the commit, so a list that had just
 * changed would carry an `aria-activedescendant` naming a row that has left it,
 * and a key pressed in that window would commit the wrong row or nothing at
 * all.
 *
 * The cursor holds the row's id, not its position. A position alone cannot tell
 * a list that shrank from one that swapped its rows for a different set of the
 * same length, and the second case is the one that silently hands Enter to a
 * row the user never chose. A cursor whose row has left the list returns the
 * highlight to the first row rather than to the nearest surviving one: the row
 * the user aimed at is gone, and the first row is where a new query already
 * puts the highlight.
 *
 * Pass the query the list is filtered by. The cursor is stamped with it and
 * dropped when it changes, so a caller cannot forget to clear it — including a
 * caller whose query arrives as a prop and so never runs its own handler. Rows
 * that come back under a query that has moved on cannot revive it either.
 * `moveTo(null)` is for deliberate resets, such as reopening the list.
 *
 * Both callbacks keep one identity for the life of the component, and read the
 * rows and the query through a ref to do it. A caller will put them in an
 * effect's dependencies — the exhaustive-deps rule makes it — and a `moveTo`
 * rebuilt on every keystroke would re-run that effect on every keystroke.
 */
export function useRowCursor(rows: readonly { id: string }[], query: string) {
  const [cursor, setCursor] = useState<RowCursor | null>(null);
  // Written after commit, not during render: a render React discards or has not
  // finished still runs the component body, and an event handler that read this
  // in that window would stamp the cursor with a query the committed tree does
  // not have.
  const latest = useRef({ rows, query });
  useLayoutEffect(() => {
    latest.current = { rows, query };
  });

  const cursorRow = indexOfCursor(rows, query, cursor);
  // Cleared rather than ignored: React re-runs this render with the cursor
  // already gone, so rows that come back cannot revive a highlight the user has
  // stopped aiming at.
  if (cursor !== null && cursorRow < 0) setCursor(null);

  const moveTo = useCallback(
    (id: string | null) =>
      setCursor(id === null ? null : { id, query: latest.current.query }),
    [],
  );

  const moveActive = useCallback((direction: 1 | -1) => {
    const { rows: live, query: liveQuery } = latest.current;
    const last = live.length - 1;
    if (last < 0) return;
    // Steps from the row the cursor is really on, inside the update, so that
    // two keys landing in one batch move two rows rather than one.
    setCursor((current) => {
      const at = Math.max(indexOfCursor(live, liveQuery, current), 0);
      const next = Math.min(Math.max(at + direction, 0), last);
      return { id: live[next].id, query: liveQuery };
    });
  }, []);

  return { activeIndex: cursorRow < 0 ? 0 : cursorRow, moveTo, moveActive };
}
```

### `lib/presence-gate.tsx`

```tsx
"use client";

import { useIsPresent } from "motion/react";
import type { ReactNode } from "react";

export interface PresenceGateRenderProps {
  /**
   * False from the render that starts the exit animation onward. An overlay
   * kept in the tree by `AnimatePresence` is still the topmost thing on the
   * page, so anything it decides from `open` alone stays true for the whole
   * exit — this is the boolean that already knows the overlay is leaving.
   */
  isPresent: boolean;
  /**
   * Spread onto every layer that takes pointer events while the overlay is
   * open. Interaction releases in the same commit that starts the exit while
   * the visual exit keeps playing: pointer events stop landing, and `inert`
   * drops the subtree from focus order, from tab order and from the
   * accessibility tree — an exiting dialog is not a dialog you can still type
   * into. A layer that never takes pointer events (a wrapper that only centres
   * the panel) takes `inert={!isPresent}` alone, so its own
   * `pointer-events-none` is not overwritten.
   */
  gate: {
    inert: boolean;
    style: { pointerEvents: "auto" | "none" };
  };
}

export interface PresenceGateProps {
  children: (props: PresenceGateRenderProps) => ReactNode;
}

/**
 * Reads the presence of the subtree it renders and hands it down.
 *
 * `useIsPresent` only answers inside the `AnimatePresence` subtree, and the
 * components that own an overlay render the `AnimatePresence` themselves, so
 * the boolean has to be read one component further down: this is that
 * component, and the render prop is how it reaches the layers.
 */
export function PresenceGate({ children }: PresenceGateProps) {
  const isPresent = useIsPresent();

  return children({
    isPresent,
    gate: {
      inert: !isPresent,
      style: { pointerEvents: isPresent ? "auto" : "none" },
    },
  });
}
```

### `lib/utils.ts`

```ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
```
