컴포넌트
펼쳐지는 검색
아이콘이 눌리면 입력창으로 넓어지고 결과를 바로 보여 주는 검색이에요.
- #arc
- #component
- #search
- #input
- #expand
- #suggestions
- #animation
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/expanding-search.json파일
components/arc/expanding-search/expanding-search.module.csscomponents/arc/expanding-search/expanding-search.tsx
npm 패키지
motion@^14.2.0
함께 오는 항목
expanding-search.module.cssexpanding-search.module.cssexpanding-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.tsxexpanding-search.tsxexpanding-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;