Skip to content
Component

Expanding search

A search icon that expands into an input and lists matching results.

  • #arc
  • #component
  • #search
  • #input
  • #expand
  • #suggestions
  • #animation

Install

One command installs the files and packages this item needs.

Terminalbash
npx shadcn@latest add https://dsui.vercel.app/r/expanding-search.json

expanding-search.module.csscss
/* The root is the lane the field may grow into. Only the shell and the panel take pointer events, so an idle lane never blocks what sits under it. */
.root { position: relative; width: 100%; min-width: var(--control-height-md); height: var(--control-height-md); pointer-events: none; }
/* One shell is both the button and the field. Its width rides a spring and its radius follows the width, from a circle to the control radius. */
.shell { position: absolute; top: 0; right: 0; height: 100%; overflow: hidden; border: 1px solid var(--border-strong); border-radius: calc(var(--control-height-md) / 2 - (var(--control-height-md) / 2 - var(--radius-control)) * var(--es-open, 0)); background: var(--surface); color: var(--text-secondary); pointer-events: auto; transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }
.root[data-anchor="start"] .shell { right: auto; left: 0; }
.shell[data-expanded] { border-color: var(--border-strong); color: var(--foreground); }
/* Focus fills the shell with the accent like Input, whether it is still the trigger button or the open field; no ring, no size change. */
.shell:has(> .trigger:focus-visible), .shell:has(> .input:focus-visible) { border-color: var(--accent); background: var(--accent); color: var(--accent-foreground); }
.shell:has(> .input:focus-visible) .input { color: var(--accent-foreground); caret-color: var(--accent-foreground); }
.shell:has(> .input:focus-visible) .input::placeholder { color: var(--accent-foreground-muted); }
.shell:has(> .input:focus-visible) .clear { color: var(--accent-foreground); }
@media (hover: hover) and (pointer: fine) {
  .shell:not([data-expanded]):hover:not(:has(> .trigger:focus-visible)) { background: var(--surface-muted); color: var(--foreground); }
  .shell[data-expanded]:hover:not(:has(> .input:focus-visible)) { border-color: var(--foreground); }
  .clear:hover { background: var(--surface-muted); color: var(--foreground); }
  .shell:has(> .input:focus-visible) .clear:hover { background: color-mix(in oklab, var(--accent-foreground) 16%, transparent); color: var(--accent-foreground); }
}
/* A compact press while it is still a button; the morph takes over on release. */
.shell:not([data-expanded]):has(> .trigger:active) { transform: scale(.97); transition-duration: var(--duration-fast), var(--duration-fast), var(--duration-fast), var(--duration-standard), var(--duration-instant); }
.trigger { position: absolute; z-index: 1; inset: 0; margin: 0; padding: 0; border: 0; border-radius: inherit; background: transparent; cursor: pointer; -webkit-tap-highlight-color: transparent; }
/* The global keyboard outline is !important, so it is hidden through its variable; the filled shell marks the focus. */
.trigger:focus-visible { outline: none; --focus-outline: transparent; }
/* The glass sits at the leading edge, so it is centered in the circle and rides that edge to its place in the open field. */
.icon { position: absolute; top: calc(50% - 9px); left: calc(var(--control-height-md) / 2 - 10px); pointer-events: none; }
.input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; padding: 0 40px 0 38px; border: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); letter-spacing: var(--tracking-body); text-overflow: ellipsis; outline: none; }
.input:focus-visible { outline: none; }
.input::placeholder { color: var(--text-muted); opacity: 1; }
.input[inert] { pointer-events: none; }
.clear { position: absolute; z-index: 1; top: calc(50% - 14px); right: 7px; display: grid; width: 28px; height: 28px; place-items: center; margin: 0; padding: 0; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-muted); cursor: pointer; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); -webkit-tap-highlight-color: transparent; }

/* A floating layer: its width is the field's, so it unfolds from the field on first open; its content keeps the final width and rides the leading edge. */
.panel { position: absolute; z-index: 40; top: calc(100% + 8px); right: 0; overflow: hidden; border: 1px solid var(--float-edge, var(--surface-raised)); border-radius: var(--radius-panel); background: var(--surface-raised); box-shadow: var(--shadow-floating); pointer-events: auto; }
.root[data-anchor="start"] .panel { right: auto; left: 0; }
.body { position: relative; width: calc(var(--es-width, 360px) - 2px); max-height: min(22rem, 60vh); overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; padding: 6px; scrollbar-width: thin; }
.view { position: relative; }
/* A flex gap, not margins: a group popping out while it leaves never lets the next one's margin collapse through the list. */
.listbox { position: relative; isolation: isolate; display: flex; flex-direction: column; gap: 4px; }
.group { position: relative; }
.groupLabel { padding: 8px 12px 4px; color: var(--text-muted); font-size: var(--text-xs); font-weight: 500; line-height: var(--leading-body); }
/* Nested corners step down from the panel by its inset. The highlight paints under every row's content, so text never hides while it travels. */
.option { position: relative; display: flex; min-height: 48px; align-items: center; gap: 12px; padding: 6px 12px; border-radius: calc(var(--radius-panel) - 6px); color: var(--foreground); cursor: pointer; user-select: none; -webkit-tap-highlight-color: transparent; }
.highlight { position: absolute; z-index: -1; inset: 0; border-radius: inherit; background: color-mix(in oklab, var(--foreground) 6%, transparent); pointer-events: none; }
.optionIcon { display: grid; flex: none; width: 18px; place-items: center; color: var(--text-muted); transition: color var(--duration-fast) var(--ease-standard); }
.option[data-active] .optionIcon { color: var(--foreground); }
.optionText { display: grid; min-width: 0; }
.optionTitle, .optionMeta { overflow: hidden; line-height: var(--leading-body); text-overflow: ellipsis; white-space: nowrap; }
.optionTitle { color: var(--foreground); font-size: var(--text-sm); }
.optionMeta { color: var(--text-muted); font-size: var(--text-xs); }
.mark { background: none; color: var(--foreground); font-weight: 500; }
.empty { display: grid; gap: 2px; padding: 12px 12px 14px; }
.emptyTitle { margin: 0; overflow-wrap: anywhere; color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }
.emptyHint { margin: 0; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
  .shell, .clear, .optionIcon { transition: none; }
  .shell:not([data-expanded]):has(> .trigger:active) { transform: none; }
}
expanding-search.tsxtsx
"use client";

import { useCallback, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { KeyboardEvent, ReactNode, RefObject } from "react";
import { flushSync } from "react-dom";
import { AnimatePresence, animate, motion, useMotionValue, useMotionValueEvent } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { MotionValue } from "motion/react";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./expanding-search.module.css";
import { UiIcon } from "../theme/ui-icon";
import { playUiSound } from "../sound/play";

export interface ExpandingSearchItem {
  id: string;
  title: string;
  /** One short line under the title, such as the type, owner, or last edit. */
  meta?: string;
  /** Results gather under this heading, in the order groups first appear in `items`. */
  group?: string;
  icon?: ReactNode;
  /** Extra words that should also find this item. */
  keywords?: string[];
}

/**
 * A search that waits as an icon button until it is needed. Use it in headers and toolbars where a full field would crowd the layout.
 * The button morphs into the field, results unfold beneath it, and Escape, choosing a result, or leaving the field empty folds it back into the icon.
 * Place it in the space the field may grow into; it fills that space's width and keeps the button on the `anchor` edge.
 */
export interface ExpandingSearchProps {
  /** Names the button, the field, and the results, 예: "프로젝트와 문서 검색". */
  label: string;
  items: ExpandingSearchItem[];
  /** Shown before anything is typed, such as recent searches. */
  suggestions?: ExpandingSearchItem[];
  suggestionsLabel?: string;
  placeholder?: string;
  onSelect?: (item: ExpandingSearchItem) => void;
  onExpandedChange?: (expanded: boolean) => void;
  /** The widest the field grows. It never grows past its container. */
  expandedWidth?: number;
  maxResults?: number;
  /** The edge the button sits on; the field grows away from it. */
  anchor?: "start" | "end";
  /** A short tip under the empty state. */
  emptyHint?: string;
  className?: string;
}

type Group = { name: string; items: ExpandingSearchItem[] };
type Mode = "suggestions" | "results" | "empty";

const enter = [...motionTokens.ease.enter] as [number, number, number, number];
const standard = [...motionTokens.ease.standard] as [number, number, number, number];
const FALLBACK_SIZE = 44;

/** Title starts beat word starts, which beat matches inside a word, which beat keyword and meta matches. Ties keep the original order. */
function rank(items: ExpandingSearchItem[], query: string, limit: number) {
  const q = query.toLocaleLowerCase();
  const scored: { item: ExpandingSearchItem; score: number; order: number }[] = [];
  items.forEach((item, order) => {
    const title = item.title.toLocaleLowerCase();
    const at = title.indexOf(q);
    const extra = [item.meta ?? "", ...(item.keywords ?? [])].some(word => word.toLocaleLowerCase().includes(q));
    const score = at === 0 ? 0 : at > 0 && /[\s\-/]/.test(title[at - 1]!) ? 1 : at > 0 ? 2 : extra ? 3 : -1;
    if (score >= 0) scored.push({ item, score, order });
  });
  return scored.sort((a, b) => a.score - b.score || a.order - b.order).slice(0, limit).map(entry => entry.item);
}

function groupBy(list: ExpandingSearchItem[], source: ExpandingSearchItem[]): Group[] {
  const order = new Map<string, number>();
  source.forEach((item, index) => { const name = item.group ?? ""; if (!order.has(name)) order.set(name, index); });
  const groups = new Map<string, ExpandingSearchItem[]>();
  list.forEach(item => { const name = item.group ?? ""; groups.set(name, [...(groups.get(name) ?? []), item]); });
  return [...groups].sort((a, b) => (order.get(a[0]) ?? 0) - (order.get(b[0]) ?? 0)).map(([name, items]) => ({ name, items }));
}

function Match({ text, query }: { text: string; query: string }) {
  const at = query ? text.toLocaleLowerCase().indexOf(query.toLocaleLowerCase()) : -1;
  if (at < 0) return text;
  return <>{text.slice(0, at)}<mark className={styles.mark}>{text.slice(at, at + query.length)}</mark>{text.slice(at + query.length)}</>;
}

/** The panel shares the field's width value, so on first open it unfolds out of the field; afterwards only its height follows the content. */
function Panel({ width, reduced, children }: { width: MotionValue<number>; reduced: boolean; children: ReactNode }) {
  const panelRef = useRef<HTMLDivElement>(null);
  const bodyRef = useRef<HTMLDivElement>(null);
  const height = useMotionValue(0);
  useLayoutEffect(() => {
    const panel = panelRef.current, body = bodyRef.current;
    if (!panel || !body || typeof ResizeObserver === "undefined") return;
    const observer = new ResizeObserver(() => {
      const next = body.offsetHeight + panel.offsetHeight - panel.clientHeight;
      if (reduced) height.jump(next);
      else animate(height, next, motionTokens.spring.smooth);
    });
    observer.observe(body);
    return () => observer.disconnect();
  }, [height, reduced]);
  // Reduced motion skips the unfold: the panel is its content's height from the first frame and only fades.
  return <motion.div ref={panelRef} className={styles.panel} style={{ width, height: reduced ? "auto" : height }}
    initial={{ opacity: 0 }}
    animate={{ opacity: 1, transition: { duration: reduced ? motionTokens.duration.fast : motionTokens.duration.fast, ease: enter } }}
    exit={{ opacity: 0, transition: { duration: reduced ? motionTokens.duration.instant : motionTokens.duration.instant, ease: standard } }}>
    <div ref={bodyRef} className={styles.body}>{children}</div>
  </motion.div>;
}

/** Offsets ignore transforms, so they describe where rows sit in the list, not where a gliding row happens to be mid-flight. */
function offsetWithin(node: HTMLElement, container: HTMLElement) {
  let top = 0;
  let current: HTMLElement | null = node;
  while (current && current !== container) { top += current.offsetTop; current = current.offsetParent as HTMLElement | null; }
  return top;
}

interface ListboxProps { id: string; label: string; groups: Group[]; query: string; activeIndex: number; optionId: (item: ExpandingSearchItem) => string; reduced: boolean; keyboard: RefObject<boolean>; onHover: (index: number) => void; onChoose: (item: ExpandingSearchItem) => void }

function Listbox({ id, label, groups, query, activeIndex, optionId, reduced, keyboard, onHover, onChoose }: ListboxProps) {
  const listRef = useRef<HTMLDivElement>(null);
  const flat = groups.flatMap(group => group.items);
  const layoutKey = flat.map(item => item.id).join("|");
  const activeItem = flat[activeIndex];
  const activeId = activeItem ? optionId(activeItem) : "";
  /** The highlight lives inside the active row, so it rides that row wherever it glides. Moving to another row, it starts where the old one was and springs home. */
  const travel = useMotionValue(0);
  const fade = useMotionValue(1);
  const last = useRef<{ id: string; set: string; top: number } | null>(null);

  useLayoutEffect(() => {
    const list = listRef.current;
    const row = activeId ? document.getElementById(activeId) : null;
    if (!list || !row) { last.current = null; return; }
    const top = offsetWithin(row, list);
    const previous = last.current;
    last.current = { id: activeId, set: layoutKey, top };
    // Same row: its highlight is already in place, and any travel still in flight stays relative to it.
    if (previous?.id === activeId) return;
    if (previous && previous.set === layoutKey && !reduced) {
      const velocity = travel.getVelocity();
      travel.jump(previous.top + travel.get() - top);
      animate(travel, 0, { ...motionTokens.spring.snappy, velocity });
    } else {
      // Nothing was highlighted, or new results arrived: it fades in on its row, without travel.
      travel.jump(0);
      if (reduced) fade.jump(1); else { fade.jump(0); animate(fade, 1, { duration: motionTokens.duration.instant, ease: enter }); }
    }
    if (keyboard.current) row.scrollIntoView({ block: "nearest" });
  }, [activeId, layoutKey, reduced, travel, fade, keyboard]);

  return <div ref={listRef} id={id} role="listbox" aria-label={label} className={styles.listbox}>
    <AnimatePresence mode="popLayout" initial={false}>
      {groups.map(group => {
        const headingId = `${id}-${group.name || "results"}`.replace(/\s+/g, "-");
        return <motion.div key={group.name} role="group" aria-labelledby={group.name ? headingId : undefined} className={styles.group}
          layout={reduced ? false : "position"} layoutDependency={layoutKey}
          initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: motionTokens.duration.micro } }}
          transition={{ opacity: { duration: motionTokens.duration.fast, ease: enter }, layout: motionTokens.spring.smooth }}>
          {group.name && <div id={headingId} className={styles.groupLabel} role="presentation">{group.name}</div>}
          <AnimatePresence mode="popLayout" initial={false}>
            {group.items.map(item => {
              const position = flat.indexOf(item);
              const active = position === activeIndex;
              return <motion.div key={item.id} id={optionId(item)} role="option" aria-selected={active} data-active={active || undefined} className={styles.option}
                layout={reduced ? false : "position"} layoutDependency={layoutKey}
                initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, transition: { duration: motionTokens.duration.micro } }}
                transition={{ opacity: { duration: motionTokens.duration.fast, ease: enter }, layout: motionTokens.spring.smooth }}
                onPointerMove={() => { if (!active) { keyboard.current = false; onHover(position); } }}
                onMouseDown={event => event.preventDefault()}
                onClick={() => onChoose(item)}>
                {active && <motion.span className={styles.highlight} style={{ y: travel, opacity: fade }} aria-hidden="true" />}
                {item.icon && <span className={styles.optionIcon} aria-hidden="true">{item.icon}</span>}
                <span className={styles.optionText}>
                  <span className={styles.optionTitle}><Match text={item.title} query={query} /></span>
                  {item.meta && <span className={styles.optionMeta}>{item.meta}</span>}
                </span>
              </motion.div>;
            })}
          </AnimatePresence>
        </motion.div>;
      })}
    </AnimatePresence>
  </div>;
}

export function ExpandingSearch({ label, items, suggestions = [], suggestionsLabel = "최근 검색", placeholder, onSelect, onExpandedChange, expandedWidth = 360, maxResults = 6, anchor = "end", emptyHint = "더 짧은 단어로 찾거나 맞춤법을 확인해 볼까요?", className }: ExpandingSearchProps) {
  const id = useId();
  const reduced = useReducedMotion() ?? false;
  const rootRef = useRef<HTMLDivElement>(null);
  const shellRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const buttonRef = useRef<HTMLButtonElement>(null);
  const [expanded, setExpanded] = useState(false);
  const [focused, setFocused] = useState(false);
  const [query, setQuery] = useState("");
  const [active, setActive] = useState(-1);
  const open = useRef(false);
  const size = useRef({ collapsed: FALLBACK_SIZE, expanded: expandedWidth });
  const width = useMotionValue(FALLBACK_SIZE);
  /** True while the last highlight move came from the keyboard, so only keyboard moves scroll the list. */
  const keyboard = useRef(false);

  // The corner radius follows the width: a circle while collapsed, the control radius once open. It rides the same spring, so the two never drift apart.
  const syncShape = useCallback((value: number) => {
    const { collapsed, expanded: full } = size.current;
    shellRef.current?.style.setProperty("--es-open", String(Math.min(1, Math.max(0, (value - collapsed) / Math.max(1, full - collapsed)))));
  }, []);
  useMotionValueEvent(width, "change", syncShape);

  // The field grows to its own limit or its container, whichever is smaller. Container resizes are followed at once; they are not the person's action.
  useLayoutEffect(() => {
    const root = rootRef.current, shell = shellRef.current;
    if (!root || !shell) return;
    const measure = () => {
      const collapsed = shell.offsetHeight || FALLBACK_SIZE;
      const full = Math.max(collapsed, Math.min(expandedWidth, root.clientWidth));
      size.current = { collapsed, expanded: full };
      root.style.setProperty("--es-width", `${full}px`);
      const goal = open.current ? full : collapsed;
      if (width.isAnimating()) animate(width, goal, motionTokens.spring.morph);
      else width.jump(goal);
      syncShape(width.get());
    };
    measure();
    if (typeof ResizeObserver === "undefined") return;
    const observer = new ResizeObserver(measure);
    observer.observe(root);
    return () => observer.disconnect();
  }, [expandedWidth, width, syncShape]);

  // Opening grows on the morph spring with a hint of overshoot; folding back settles without one, so the circle never dips below its own size.
  const morphTo = (goal: number) => { if (reduced) width.jump(goal); else animate(width, goal, goal >= width.get() ? motionTokens.spring.morph : motionTokens.spring.smooth); };

  // Focus moves inside the same click, so touch keyboards open; the field is inert until this flush makes it focusable.
  function expand() {
    if (open.current) return;
    playUiSound("open");
    open.current = true;
    flushSync(() => { setExpanded(true); setFocused(true); });
    inputRef.current?.focus({ preventScroll: true });
    morphTo(size.current.expanded);
    onExpandedChange?.(true);
  }

  function collapse(returnFocus: boolean, quiet = false) {
    if (!open.current) return;
    if (!quiet) playUiSound("close");
    open.current = false;
    flushSync(() => { setExpanded(false); setFocused(false); setQuery(""); setActive(-1); });
    if (returnFocus) buttonRef.current?.focus({ preventScroll: true });
    morphTo(size.current.collapsed);
    onExpandedChange?.(false);
  }

  const trimmed = query.trim();
  const results = useMemo(() => trimmed ? rank(items, trimmed, maxResults) : [], [items, trimmed, maxResults]);
  const mode: Mode | null = !trimmed ? (suggestions.length ? "suggestions" : null) : results.length ? "results" : "empty";
  const groups = useMemo<Group[]>(() => mode === "suggestions" ? [{ name: suggestionsLabel, items: suggestions }] : mode === "results" ? groupBy(results, items) : [], [mode, suggestions, suggestionsLabel, results, items]);
  const flat = groups.flatMap(group => group.items);
  const activeIndex = Math.min(active, flat.length - 1);
  const activeItem = flat[activeIndex];
  const optionId = (item: ExpandingSearchItem) => `${id}-${mode}-${item.id}`;
  const listboxId = `${id}-${mode}-listbox`;
  const panelOpen = expanded && focused && mode !== null;
  const announcement = !panelOpen ? "" : mode === "empty" ? `${trimmed} 검색 결과가 없어요` : mode === "results" ? `검색 결과 ${flat.length}개` : `${suggestionsLabel} ${flat.length}개`;

  function choose(item: ExpandingSearchItem) {
    playUiSound("select");
    collapse(true, true);
    onSelect?.(item);
  }

  function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
    if (event.nativeEvent.isComposing || event.keyCode === 229) return;
    if (event.key === "Escape") { event.preventDefault(); collapse(true); return; }
    if (!panelOpen || !flat.length) return;
    if (event.key === "ArrowDown" || event.key === "ArrowUp") {
      event.preventDefault();
      const step = event.key === "ArrowDown" ? 1 : -1;
      keyboard.current = true;
      setActive(activeIndex < 0 ? (step > 0 ? 0 : flat.length - 1) : (activeIndex + step + flat.length) % flat.length);
    } else if (event.key === "Enter" && activeItem) {
      event.preventDefault();
      choose(activeItem);
    }
  }

  const clearHidden = reduced ? { opacity: 0 } : { opacity: 0, scale: .6, filter: `blur(${motionTokens.blur.subtle}px)` };

  return <div ref={rootRef} className={[styles.root, className].filter(Boolean).join(" ")} data-anchor={anchor}>
    <motion.div ref={shellRef} className={styles.shell} data-expanded={expanded || undefined} style={{ width }}>
      <button ref={buttonRef} type="button" className={styles.trigger} aria-label={label} hidden={expanded} onClick={expand} />
      <UiIcon iconRole="search" className={styles.icon} width={18} height={18} strokeWidth={1.75} aria-hidden="true" />
      {/* The placeholder waits for the shape to start moving, then fades in; on close it leaves first. */}
      <motion.input ref={inputRef} className={styles.input} type="text" role="combobox" inert={!expanded} value={query} placeholder={placeholder ?? label}
        aria-label={label} aria-expanded={panelOpen} aria-controls={panelOpen && mode !== "empty" ? listboxId : undefined} aria-autocomplete="list"
        aria-activedescendant={panelOpen && activeItem ? optionId(activeItem) : undefined}
        autoComplete="off" autoCorrect="off" spellCheck={false} enterKeyHint="search"
        initial={false} animate={{ opacity: expanded ? 1 : 0 }}
        transition={expanded ? { duration: reduced ? motionTokens.duration.fast : motionTokens.duration.standard, ease: enter, delay: reduced ? 0 : .08 } : { duration: reduced ? motionTokens.duration.instant : .08, ease: standard }}
        onChange={event => { const next = event.target.value; setQuery(next); setActive(next.trim() ? 0 : -1); keyboard.current = false; }}
        onFocus={() => setFocused(true)}
        onBlur={() => {
          // Switching windows keeps the search as it was; the field refocuses on return.
          if (typeof document !== "undefined" && !document.hasFocus()) return;
          setFocused(false);
          if (open.current && !inputRef.current?.value.trim()) collapse(false);
        }}
        onKeyDown={handleKeyDown} />
      <AnimatePresence initial={false}>
        {expanded && query && <motion.button key="clear" type="button" tabIndex={-1} className={styles.clear} aria-label="검색어 지우기"
          onMouseDown={event => event.preventDefault()}
          onClick={() => { setQuery(""); setActive(-1); inputRef.current?.focus({ preventScroll: true }); }}
          initial={clearHidden} animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
          exit={{ ...clearHidden, transition: { duration: reduced ? motionTokens.duration.instant : motionTokens.duration.instant, ease: standard } }}
          transition={reduced ? { duration: motionTokens.duration.fast } : { ...motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast }, filter: { duration: motionTokens.duration.fast } }}>
          <UiIcon iconRole="close" width={16} height={16} strokeWidth={1.75} aria-hidden="true" />
        </motion.button>}
      </AnimatePresence>
    </motion.div>
    <AnimatePresence>
      {panelOpen && <Panel key="panel" width={width} reduced={reduced}>
        <AnimatePresence mode="popLayout" initial={false}>
            <motion.div key={mode} className={styles.view}
              initial={reduced ? { opacity: 0 } : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)` }}
              animate={{ opacity: 1, filter: "blur(0px)" }}
              exit={{ opacity: 0, transition: { duration: motionTokens.duration.instant, ease: standard } }}
              transition={{ duration: reduced ? motionTokens.duration.fast : motionTokens.duration.fast, ease: enter }}>
              {mode === "empty"
                ? <div className={styles.empty}><p className={styles.emptyTitle}>“{trimmed}” 검색 결과가 없어요</p><p className={styles.emptyHint}>{emptyHint}</p></div>
                : <Listbox id={listboxId} label={mode === "suggestions" ? suggestionsLabel : `${trimmed} 검색 결과`} groups={groups} query={mode === "results" ? trimmed : ""} activeIndex={activeIndex} optionId={optionId} reduced={reduced} keyboard={keyboard} onHover={setActive} onChoose={choose} />}
            </motion.div>
        </AnimatePresence>
      </Panel>}
    </AnimatePresence>
    <span className={styles.srOnly} role="status" aria-live="polite">{announcement}</span>
  </div>;
}

export default ExpandingSearch;