Morphing Search
Search field or compact icon that morphs into a glass results surface, whether opened by click or keyboard shortcut.
Preview
"use client";
import { Blocks, BookOpen, Bot, FolderOpen, Palette } from "lucide-react";
import {
MorphingSearch,
type MorphingSearchItem,
} from "@/components/motion/morphing-search";
const ITEMS: MorphingSearchItem[] = [
{
id: "project-folder",
title: "Project Folder",
description: "Block · Files and previews",
keywords: ["files", "overlay"],
icon: FolderOpen,
},
{
id: "motion-components",
title: "Motion components",
description: "Collection · Interaction primitives",
keywords: ["animation", "components"],
icon: Blocks,
},
{
id: "agent-interfaces",
title: "Agent interfaces",
description: "Collection · AI building blocks",
keywords: ["ai", "chat"],
icon: Bot,
},
{
id: "installation",
title: "Installation guide",
description: "Documentation · Add your first component",
keywords: ["setup", "shadcn"],
icon: BookOpen,
},
{
id: "design-tokens",
title: "Design tokens",
description: "Documentation · Color, type, and motion",
keywords: ["theme", "styles"],
icon: Palette,
},
];
export function MorphingSearchPreview() {
return (
<div className="flex w-full max-w-[22rem] items-center gap-3">
<MorphingSearch items={ITEMS} placeholder="Find components" />
<MorphingSearch
items={ITEMS}
placeholder="Find components"
shortcut=""
iconOnly
/>
</div>
);
}
"use client";
// beui.dev/components/blocks/morphing-search
import { type LucideIcon, Search } from "lucide-react";
import {
AnimatePresence,
LayoutGroup,
motion,
type Transition,
useReducedMotion,
} from "motion/react";
import {
type KeyboardEvent as ReactKeyboardEvent,
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { useOnOpen } from "@/lib/hooks/use-on-open";
import { useRowCursor } from "@/lib/hooks/use-row-cursor";
import { cn } from "@/lib/utils";
// Keeps the Wallet Card feel with a little more time to read the morph.
const SEARCH_MORPH: Transition = {
type: "spring",
duration: 0.58,
bounce: 0.22,
};
// Keep the spring on the shell, but unfold complex clip-path values with the
// same progressive tween as Morph Popover so the content never snaps ahead.
const SEARCH_CLIP_TRANSITION: Transition = {
duration: 0.32,
ease: EASE_OUT,
};
export type MorphingSearchItem = {
id: string;
title: string;
description?: string;
keywords?: string[];
icon?: LucideIcon;
onSelect?: () => void;
};
export interface MorphingSearchProps {
items: MorphingSearchItem[];
placeholder?: string;
shortcut?: string;
/** Render the closed trigger as a compact search icon. */
iconOnly?: boolean;
emptyMessage?: string;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
onQueryChange?: (query: string) => void;
onSelect?: (item: MorphingSearchItem) => void;
className?: string;
}
type AnchorRect = {
top: number;
left: number;
width: number;
};
function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
return (
target.isContentEditable ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
);
}
export function MorphingSearch({
items,
placeholder = "Search",
shortcut = "f",
iconOnly = false,
emptyMessage = "No results found.",
open: controlledOpen,
defaultOpen = false,
onOpenChange,
onQueryChange,
onSelect,
className,
}: MorphingSearchProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const [query, setQuery] = useState("");
const [mounted, setMounted] = useState(false);
const [backgroundScrollLocked, setBackgroundScrollLocked] =
useState(defaultOpen);
const [anchorRect, setAnchorRect] = useState<AnchorRect>({
top: 16,
left: 16,
width: 288,
});
const open = controlledOpen ?? internalOpen;
const controlled = controlledOpen !== undefined;
const reduce = useReducedMotion();
const uid = useId();
const anchorRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const wasOpenRef = useRef(open);
const transition: Transition = reduce ? { duration: 0 } : SPRING_LAYOUT;
const morphTransition: Transition = reduce ? { duration: 0 } : SEARCH_MORPH;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternalOpen(next);
onOpenChange?.(next);
},
[controlled, onOpenChange],
);
const measureAnchor = useCallback(() => {
const rect = anchorRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return;
setAnchorRect({ top: rect.top, left: rect.left, width: rect.width });
}, []);
const openSearch = useCallback(() => {
measureAnchor();
setBackgroundScrollLocked(true);
previousFocusRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
setOpen(true);
}, [measureAnchor, setOpen]);
const filteredItems = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return items;
return items.filter((item) =>
[item.title, item.description ?? "", ...(item.keywords ?? [])]
.join(" ")
.toLowerCase()
.includes(needle),
);
}, [items, query]);
const { activeIndex, moveTo, moveActive } = useRowCursor(filteredItems, query);
// The cursor is stamped with the query, so changing it drops the highlight
// without this having to say so.
const updateQuery = useCallback(
(next: string) => {
setQuery(next);
onQueryChange?.(next);
},
[onQueryChange],
);
const closeSearch = useCallback(() => {
updateQuery("");
setOpen(false);
}, [setOpen, updateQuery]);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (open) setBackgroundScrollLocked(true);
}, [open]);
useEffect(() => {
measureAnchor();
const anchor = anchorRef.current;
const observer =
anchor && typeof ResizeObserver !== "undefined"
? new ResizeObserver(measureAnchor)
: null;
if (anchor) observer?.observe(anchor);
window.addEventListener("resize", measureAnchor);
document.addEventListener("scroll", measureAnchor, true);
window.visualViewport?.addEventListener("resize", measureAnchor);
window.visualViewport?.addEventListener("scroll", measureAnchor);
return () => {
observer?.disconnect();
window.removeEventListener("resize", measureAnchor);
document.removeEventListener("scroll", measureAnchor, true);
window.visualViewport?.removeEventListener("resize", measureAnchor);
window.visualViewport?.removeEventListener("scroll", measureAnchor);
};
}, [measureAnchor]);
useEffect(() => {
if (!backgroundScrollLocked) return;
const preventBackgroundWheel = (event: WheelEvent) => {
const target = event.target;
if (target instanceof Node && listRef.current?.contains(target)) return;
event.preventDefault();
};
const preventBackgroundTouch = (event: TouchEvent) => {
const target = event.target;
if (target instanceof Node && listRef.current?.contains(target)) return;
event.preventDefault();
};
document.addEventListener("wheel", preventBackgroundWheel, {
passive: false,
});
document.addEventListener("touchmove", preventBackgroundTouch, {
passive: false,
});
return () => {
document.removeEventListener("wheel", preventBackgroundWheel);
document.removeEventListener("touchmove", preventBackgroundTouch);
};
}, [backgroundScrollLocked]);
useEffect(() => {
const handleShortcut = (event: KeyboardEvent) => {
if (event.key === "Escape" && open) {
event.preventDefault();
closeSearch();
return;
}
if (
!open &&
shortcut &&
event.key.toLowerCase() === shortcut.toLowerCase() &&
!event.repeat &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
!isEditableTarget(event.target)
) {
event.preventDefault();
openSearch();
}
};
window.addEventListener("keydown", handleShortcut);
return () => window.removeEventListener("keydown", handleShortcut);
}, [closeSearch, open, openSearch, shortcut]);
// Only this component's own state. Telling the consumer the query changed is
// a side effect, so it waits for the effect below.
useOnOpen(open, () => {
setQuery("");
moveTo(null);
});
// Keyed to `open` alone. `onQueryChange` is read through a ref because an
// inline one changes identity on every keystroke, and this effect clearing
// the field on every keystroke is exactly what that costs. Written after
// commit for the reason `lib/hooks/use-row-cursor.ts` gives at length.
const notifyQuery = useRef(onQueryChange);
useLayoutEffect(() => {
notifyQuery.current = onQueryChange;
});
useEffect(() => {
if (open) {
notifyQuery.current?.("");
const frame = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(frame);
}
if (wasOpenRef.current) {
const frame = requestAnimationFrame(() => {
const previousFocus = previousFocusRef.current;
const focusTarget = previousFocus?.isConnected
? previousFocus
: triggerRef.current;
focusTarget?.focus();
});
return () => cancelAnimationFrame(frame);
}
}, [open]);
useEffect(() => {
wasOpenRef.current = open;
}, [open]);
useEffect(() => {
if (!open) return;
listRef.current
?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`)
?.scrollIntoView({ block: "nearest" });
}, [activeIndex, open]);
const selectItem = useCallback(
(item: MorphingSearchItem) => {
item.onSelect?.();
onSelect?.(item);
closeSearch();
},
[closeSearch, onSelect],
);
const handleDialogKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowDown") {
event.preventDefault();
moveActive(1);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
moveActive(-1);
return;
}
if (event.key === "Enter") {
event.preventDefault();
const item = filteredItems[activeIndex];
if (item) selectItem(item);
return;
}
if (event.key !== "Tab" || !dialogRef.current) return;
const focusable = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(
'input, button:not([disabled]), [tabindex]:not([tabindex="-1"])',
),
);
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
const shellLayoutId = `${uid}-shell`;
const listboxId = `${uid}-results`;
const panelWidth = mounted
? Math.max(
anchorRect.width,
Math.min(448, window.innerWidth - anchorRect.left - 16),
)
: anchorRect.width;
const resultsHeight = mounted
? Math.max(96, Math.min(288, window.innerHeight - anchorRect.top - 80))
: 288;
const collapsedContentClip = `inset(0px ${Math.max(
0,
panelWidth - anchorRect.width,
)}px ${resultsHeight}px 0px round 12px)`;
const expandedContentClip = "inset(0px 0px 0px 0px round 12px)";
// Neither grouping layer carries a box: they only hold `inert`/`aria-hidden`,
// the z-index and the presence key, and every child below is `fixed` and
// resolves against the viewport itself. The click catcher spans the viewport
// edges but has no children and filters nothing, so it is not a sampling
// layer either. See tests/fixed-overlay-edge-sampling.test.tsx.
const overlay = mounted
? createPortal(
<div
aria-hidden={!open}
inert={!open}
className="pointer-events-none fixed left-0 top-0 z-50 size-0"
>
<AnimatePresence
initial={false}
mode="popLayout"
onExitComplete={() => setBackgroundScrollLocked(false)}
>
{open ? (
<motion.div
key="morphing-search-overlay"
className="fixed left-0 top-0 size-0"
>
<button
type="button"
aria-label="Close search"
className="pointer-events-auto fixed inset-0 cursor-default bg-transparent"
onClick={closeSearch}
/>
<motion.div
layoutId={shellLayoutId}
aria-hidden="true"
className="fixed z-10 rounded-xl bg-background/90 backdrop-blur-xl"
style={{
top: anchorRect.top,
left: anchorRect.left,
width: panelWidth,
height: 48 + resultsHeight,
boxShadow: "inset 0 0 0 1px var(--color-border)",
}}
transition={morphTransition}
/>
<motion.div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Search"
onKeyDown={handleDialogKeyDown}
initial={
reduce
? false
: { opacity: 0, clipPath: collapsedContentClip }
}
animate={{ opacity: 1, clipPath: expandedContentClip }}
exit={{
opacity: 0,
clipPath: collapsedContentClip,
transition: reduce
? { duration: 0 }
: {
clipPath: SEARCH_CLIP_TRANSITION,
opacity: SEARCH_MORPH,
},
}}
transition={
reduce
? { duration: 0 }
: {
clipPath: SEARCH_CLIP_TRANSITION,
opacity: SEARCH_MORPH,
}
}
className="pointer-events-auto fixed z-20 overflow-hidden rounded-xl"
style={{
top: anchorRect.top,
left: anchorRect.left,
width: panelWidth,
}}
>
<div
className={cn(
"flex h-12 items-center gap-2.5 border-b border-border",
iconOnly ? "px-4" : "px-3.5",
)}
>
<span className="shrink-0">
<Search className="size-4 text-muted-foreground" />
</span>
<div className="flex h-10 min-w-0 flex-1 items-center">
<input
ref={inputRef}
value={query}
onChange={(event) => updateQuery(event.target.value)}
role="combobox"
aria-label={placeholder}
aria-expanded="true"
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={
filteredItems.length > 0
? `${uid}-option-${activeIndex}`
: undefined
}
placeholder={placeholder}
className="size-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
</div>
<kbd className="flex h-7 shrink-0 items-center rounded-md border border-border px-2 text-xs text-muted-foreground">
Esc
</kbd>
</div>
<motion.div
ref={listRef}
id={listboxId}
role="listbox"
aria-label="Search results"
transition={reduce ? { duration: 0 } : undefined}
variants={
reduce
? undefined
: {
closed: {
opacity: 0,
transform: "translateY(6px)",
transition: {
duration: 0.16,
delay: 0.18,
ease: EASE_OUT,
},
},
open: {
opacity: 1,
transform: "translateY(0px)",
transition: {
duration: 0.16,
ease: EASE_OUT,
},
},
}
}
initial={
reduce
? { opacity: 1, transform: "translateY(0px)" }
: "closed"
}
animate={
reduce
? { opacity: 1, transform: "translateY(0px)" }
: "open"
}
exit={reduce ? undefined : "closed"}
className="overscroll-contain overflow-y-auto p-2"
style={{
maxHeight: resultsHeight,
}}
>
{filteredItems.length > 0 ? (
filteredItems.map((item, index) => {
const Icon = item.icon;
const active = index === activeIndex;
return (
<button
key={item.id}
id={`${uid}-option-${index}`}
type="button"
role="option"
aria-selected={active}
data-index={index}
onMouseMove={() => moveTo(item.id)}
onFocus={() => moveTo(item.id)}
onClick={() => selectItem(item)}
className="relative flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
{active ? (
<motion.span
layoutId={`${uid}-active-result`}
className="absolute inset-0 rounded-lg bg-foreground/5"
transition={transition}
/>
) : null}
{Icon ? (
<Icon className="relative size-4 shrink-0 text-muted-foreground" />
) : null}
<span className="relative min-w-0">
<span className="block truncate text-sm font-medium text-foreground">
{item.title}
</span>
{item.description ? (
<span className="block truncate text-xs text-muted-foreground">
{item.description}
</span>
) : null}
</span>
</button>
);
})
) : (
<p className="px-3 py-8 text-center text-sm text-muted-foreground">
{emptyMessage}
</p>
)}
</motion.div>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
</div>,
document.body,
)
: null;
return (
<LayoutGroup id={uid}>
<div
ref={anchorRef}
className={cn(
"relative",
iconOnly ? "size-12" : "h-12 w-72 max-w-full",
className,
)}
>
{!open ? (
<motion.button
ref={triggerRef}
key="morphing-search-trigger"
layoutId={shellLayoutId}
type="button"
aria-haspopup="dialog"
aria-expanded="false"
aria-label={placeholder}
onClick={openSearch}
transition={morphTransition}
style={{
boxShadow: "inset 0 0 0 1px var(--search-trigger-stroke)",
}}
className={cn(
"flex size-full items-center rounded-xl bg-background/60 text-left backdrop-blur-md outline-none [--search-trigger-stroke:var(--color-border)] hover:[--search-trigger-stroke:var(--color-border-strong)] focus-visible:ring-2 focus-visible:ring-ring",
iconOnly ? "cursor-pointer justify-center" : "cursor-text px-3.5",
)}
></motion.button>
) : null}
<motion.div
aria-hidden="true"
initial={false}
animate={{ opacity: open ? 0 : 1 }}
transition={
reduce
? { duration: 0 }
: {
duration: 0.1,
delay: open ? 0.1 : 0.12,
ease: EASE_OUT,
}
}
className={cn(
"pointer-events-none absolute inset-0 flex items-center",
backgroundScrollLocked && "z-[60]",
iconOnly ? "justify-center" : "gap-2.5 px-3.5",
)}
>
<Search className="size-4 shrink-0 text-muted-foreground" />
{iconOnly ? null : (
<>
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
{placeholder}
</span>
{shortcut ? (
<kbd className="flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md border border-border px-2 text-xs text-muted-foreground">
{shortcut.toUpperCase()}
</kbd>
) : null}
</>
)}
</motion.div>
</div>
{overlay}
</LayoutGroup>
);
}
Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
// 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;
"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();
}
}
"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 };
}
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/blocks/morphing-search
import { type LucideIcon, Search } from "lucide-react";
import {
AnimatePresence,
LayoutGroup,
motion,
type Transition,
useReducedMotion,
} from "motion/react";
import {
type KeyboardEvent as ReactKeyboardEvent,
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom";
import { EASE_OUT, SPRING_LAYOUT } from "@/lib/ease";
import { useOnOpen } from "@/lib/hooks/use-on-open";
import { useRowCursor } from "@/lib/hooks/use-row-cursor";
import { cn } from "@/lib/utils";
// Keeps the Wallet Card feel with a little more time to read the morph.
const SEARCH_MORPH: Transition = {
type: "spring",
duration: 0.58,
bounce: 0.22,
};
// Keep the spring on the shell, but unfold complex clip-path values with the
// same progressive tween as Morph Popover so the content never snaps ahead.
const SEARCH_CLIP_TRANSITION: Transition = {
duration: 0.32,
ease: EASE_OUT,
};
export type MorphingSearchItem = {
id: string;
title: string;
description?: string;
keywords?: string[];
icon?: LucideIcon;
onSelect?: () => void;
};
export interface MorphingSearchProps {
items: MorphingSearchItem[];
placeholder?: string;
shortcut?: string;
/** Render the closed trigger as a compact search icon. */
iconOnly?: boolean;
emptyMessage?: string;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
onQueryChange?: (query: string) => void;
onSelect?: (item: MorphingSearchItem) => void;
className?: string;
}
type AnchorRect = {
top: number;
left: number;
width: number;
};
function isEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
return (
target.isContentEditable ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
);
}
export function MorphingSearch({
items,
placeholder = "Search",
shortcut = "f",
iconOnly = false,
emptyMessage = "No results found.",
open: controlledOpen,
defaultOpen = false,
onOpenChange,
onQueryChange,
onSelect,
className,
}: MorphingSearchProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const [query, setQuery] = useState("");
const [mounted, setMounted] = useState(false);
const [backgroundScrollLocked, setBackgroundScrollLocked] =
useState(defaultOpen);
const [anchorRect, setAnchorRect] = useState<AnchorRect>({
top: 16,
left: 16,
width: 288,
});
const open = controlledOpen ?? internalOpen;
const controlled = controlledOpen !== undefined;
const reduce = useReducedMotion();
const uid = useId();
const anchorRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const dialogRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const wasOpenRef = useRef(open);
const transition: Transition = reduce ? { duration: 0 } : SPRING_LAYOUT;
const morphTransition: Transition = reduce ? { duration: 0 } : SEARCH_MORPH;
const setOpen = useCallback(
(next: boolean) => {
if (!controlled) setInternalOpen(next);
onOpenChange?.(next);
},
[controlled, onOpenChange],
);
const measureAnchor = useCallback(() => {
const rect = anchorRef.current?.getBoundingClientRect();
if (!rect || rect.width === 0) return;
setAnchorRect({ top: rect.top, left: rect.left, width: rect.width });
}, []);
const openSearch = useCallback(() => {
measureAnchor();
setBackgroundScrollLocked(true);
previousFocusRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
setOpen(true);
}, [measureAnchor, setOpen]);
const filteredItems = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return items;
return items.filter((item) =>
[item.title, item.description ?? "", ...(item.keywords ?? [])]
.join(" ")
.toLowerCase()
.includes(needle),
);
}, [items, query]);
const { activeIndex, moveTo, moveActive } = useRowCursor(filteredItems, query);
// The cursor is stamped with the query, so changing it drops the highlight
// without this having to say so.
const updateQuery = useCallback(
(next: string) => {
setQuery(next);
onQueryChange?.(next);
},
[onQueryChange],
);
const closeSearch = useCallback(() => {
updateQuery("");
setOpen(false);
}, [setOpen, updateQuery]);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (open) setBackgroundScrollLocked(true);
}, [open]);
useEffect(() => {
measureAnchor();
const anchor = anchorRef.current;
const observer =
anchor && typeof ResizeObserver !== "undefined"
? new ResizeObserver(measureAnchor)
: null;
if (anchor) observer?.observe(anchor);
window.addEventListener("resize", measureAnchor);
document.addEventListener("scroll", measureAnchor, true);
window.visualViewport?.addEventListener("resize", measureAnchor);
window.visualViewport?.addEventListener("scroll", measureAnchor);
return () => {
observer?.disconnect();
window.removeEventListener("resize", measureAnchor);
document.removeEventListener("scroll", measureAnchor, true);
window.visualViewport?.removeEventListener("resize", measureAnchor);
window.visualViewport?.removeEventListener("scroll", measureAnchor);
};
}, [measureAnchor]);
useEffect(() => {
if (!backgroundScrollLocked) return;
const preventBackgroundWheel = (event: WheelEvent) => {
const target = event.target;
if (target instanceof Node && listRef.current?.contains(target)) return;
event.preventDefault();
};
const preventBackgroundTouch = (event: TouchEvent) => {
const target = event.target;
if (target instanceof Node && listRef.current?.contains(target)) return;
event.preventDefault();
};
document.addEventListener("wheel", preventBackgroundWheel, {
passive: false,
});
document.addEventListener("touchmove", preventBackgroundTouch, {
passive: false,
});
return () => {
document.removeEventListener("wheel", preventBackgroundWheel);
document.removeEventListener("touchmove", preventBackgroundTouch);
};
}, [backgroundScrollLocked]);
useEffect(() => {
const handleShortcut = (event: KeyboardEvent) => {
if (event.key === "Escape" && open) {
event.preventDefault();
closeSearch();
return;
}
if (
!open &&
shortcut &&
event.key.toLowerCase() === shortcut.toLowerCase() &&
!event.repeat &&
!event.metaKey &&
!event.ctrlKey &&
!event.altKey &&
!event.shiftKey &&
!isEditableTarget(event.target)
) {
event.preventDefault();
openSearch();
}
};
window.addEventListener("keydown", handleShortcut);
return () => window.removeEventListener("keydown", handleShortcut);
}, [closeSearch, open, openSearch, shortcut]);
// Only this component's own state. Telling the consumer the query changed is
// a side effect, so it waits for the effect below.
useOnOpen(open, () => {
setQuery("");
moveTo(null);
});
// Keyed to `open` alone. `onQueryChange` is read through a ref because an
// inline one changes identity on every keystroke, and this effect clearing
// the field on every keystroke is exactly what that costs. Written after
// commit for the reason `lib/hooks/use-row-cursor.ts` gives at length.
const notifyQuery = useRef(onQueryChange);
useLayoutEffect(() => {
notifyQuery.current = onQueryChange;
});
useEffect(() => {
if (open) {
notifyQuery.current?.("");
const frame = requestAnimationFrame(() => inputRef.current?.focus());
return () => cancelAnimationFrame(frame);
}
if (wasOpenRef.current) {
const frame = requestAnimationFrame(() => {
const previousFocus = previousFocusRef.current;
const focusTarget = previousFocus?.isConnected
? previousFocus
: triggerRef.current;
focusTarget?.focus();
});
return () => cancelAnimationFrame(frame);
}
}, [open]);
useEffect(() => {
wasOpenRef.current = open;
}, [open]);
useEffect(() => {
if (!open) return;
listRef.current
?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`)
?.scrollIntoView({ block: "nearest" });
}, [activeIndex, open]);
const selectItem = useCallback(
(item: MorphingSearchItem) => {
item.onSelect?.();
onSelect?.(item);
closeSearch();
},
[closeSearch, onSelect],
);
const handleDialogKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowDown") {
event.preventDefault();
moveActive(1);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
moveActive(-1);
return;
}
if (event.key === "Enter") {
event.preventDefault();
const item = filteredItems[activeIndex];
if (item) selectItem(item);
return;
}
if (event.key !== "Tab" || !dialogRef.current) return;
const focusable = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(
'input, button:not([disabled]), [tabindex]:not([tabindex="-1"])',
),
);
const first = focusable[0];
const last = focusable.at(-1);
if (!first || !last) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
const shellLayoutId = `${uid}-shell`;
const listboxId = `${uid}-results`;
const panelWidth = mounted
? Math.max(
anchorRect.width,
Math.min(448, window.innerWidth - anchorRect.left - 16),
)
: anchorRect.width;
const resultsHeight = mounted
? Math.max(96, Math.min(288, window.innerHeight - anchorRect.top - 80))
: 288;
const collapsedContentClip = `inset(0px ${Math.max(
0,
panelWidth - anchorRect.width,
)}px ${resultsHeight}px 0px round 12px)`;
const expandedContentClip = "inset(0px 0px 0px 0px round 12px)";
// Neither grouping layer carries a box: they only hold `inert`/`aria-hidden`,
// the z-index and the presence key, and every child below is `fixed` and
// resolves against the viewport itself. The click catcher spans the viewport
// edges but has no children and filters nothing, so it is not a sampling
// layer either. See tests/fixed-overlay-edge-sampling.test.tsx.
const overlay = mounted
? createPortal(
<div
aria-hidden={!open}
inert={!open}
className="pointer-events-none fixed left-0 top-0 z-50 size-0"
>
<AnimatePresence
initial={false}
mode="popLayout"
onExitComplete={() => setBackgroundScrollLocked(false)}
>
{open ? (
<motion.div
key="morphing-search-overlay"
className="fixed left-0 top-0 size-0"
>
<button
type="button"
aria-label="Close search"
className="pointer-events-auto fixed inset-0 cursor-default bg-transparent"
onClick={closeSearch}
/>
<motion.div
layoutId={shellLayoutId}
aria-hidden="true"
className="fixed z-10 rounded-xl bg-background/90 backdrop-blur-xl"
style={{
top: anchorRect.top,
left: anchorRect.left,
width: panelWidth,
height: 48 + resultsHeight,
boxShadow: "inset 0 0 0 1px var(--color-border)",
}}
transition={morphTransition}
/>
<motion.div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Search"
onKeyDown={handleDialogKeyDown}
initial={
reduce
? false
: { opacity: 0, clipPath: collapsedContentClip }
}
animate={{ opacity: 1, clipPath: expandedContentClip }}
exit={{
opacity: 0,
clipPath: collapsedContentClip,
transition: reduce
? { duration: 0 }
: {
clipPath: SEARCH_CLIP_TRANSITION,
opacity: SEARCH_MORPH,
},
}}
transition={
reduce
? { duration: 0 }
: {
clipPath: SEARCH_CLIP_TRANSITION,
opacity: SEARCH_MORPH,
}
}
className="pointer-events-auto fixed z-20 overflow-hidden rounded-xl"
style={{
top: anchorRect.top,
left: anchorRect.left,
width: panelWidth,
}}
>
<div
className={cn(
"flex h-12 items-center gap-2.5 border-b border-border",
iconOnly ? "px-4" : "px-3.5",
)}
>
<span className="shrink-0">
<Search className="size-4 text-muted-foreground" />
</span>
<div className="flex h-10 min-w-0 flex-1 items-center">
<input
ref={inputRef}
value={query}
onChange={(event) => updateQuery(event.target.value)}
role="combobox"
aria-label={placeholder}
aria-expanded="true"
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={
filteredItems.length > 0
? `${uid}-option-${activeIndex}`
: undefined
}
placeholder={placeholder}
className="size-full bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
</div>
<kbd className="flex h-7 shrink-0 items-center rounded-md border border-border px-2 text-xs text-muted-foreground">
Esc
</kbd>
</div>
<motion.div
ref={listRef}
id={listboxId}
role="listbox"
aria-label="Search results"
transition={reduce ? { duration: 0 } : undefined}
variants={
reduce
? undefined
: {
closed: {
opacity: 0,
transform: "translateY(6px)",
transition: {
duration: 0.16,
delay: 0.18,
ease: EASE_OUT,
},
},
open: {
opacity: 1,
transform: "translateY(0px)",
transition: {
duration: 0.16,
ease: EASE_OUT,
},
},
}
}
initial={
reduce
? { opacity: 1, transform: "translateY(0px)" }
: "closed"
}
animate={
reduce
? { opacity: 1, transform: "translateY(0px)" }
: "open"
}
exit={reduce ? undefined : "closed"}
className="overscroll-contain overflow-y-auto p-2"
style={{
maxHeight: resultsHeight,
}}
>
{filteredItems.length > 0 ? (
filteredItems.map((item, index) => {
const Icon = item.icon;
const active = index === activeIndex;
return (
<button
key={item.id}
id={`${uid}-option-${index}`}
type="button"
role="option"
aria-selected={active}
data-index={index}
onMouseMove={() => moveTo(item.id)}
onFocus={() => moveTo(item.id)}
onClick={() => selectItem(item)}
className="relative flex w-full items-center gap-2.5 rounded-lg px-3 py-2.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
{active ? (
<motion.span
layoutId={`${uid}-active-result`}
className="absolute inset-0 rounded-lg bg-foreground/5"
transition={transition}
/>
) : null}
{Icon ? (
<Icon className="relative size-4 shrink-0 text-muted-foreground" />
) : null}
<span className="relative min-w-0">
<span className="block truncate text-sm font-medium text-foreground">
{item.title}
</span>
{item.description ? (
<span className="block truncate text-xs text-muted-foreground">
{item.description}
</span>
) : null}
</span>
</button>
);
})
) : (
<p className="px-3 py-8 text-center text-sm text-muted-foreground">
{emptyMessage}
</p>
)}
</motion.div>
</motion.div>
</motion.div>
) : null}
</AnimatePresence>
</div>,
document.body,
)
: null;
return (
<LayoutGroup id={uid}>
<div
ref={anchorRef}
className={cn(
"relative",
iconOnly ? "size-12" : "h-12 w-72 max-w-full",
className,
)}
>
{!open ? (
<motion.button
ref={triggerRef}
key="morphing-search-trigger"
layoutId={shellLayoutId}
type="button"
aria-haspopup="dialog"
aria-expanded="false"
aria-label={placeholder}
onClick={openSearch}
transition={morphTransition}
style={{
boxShadow: "inset 0 0 0 1px var(--search-trigger-stroke)",
}}
className={cn(
"flex size-full items-center rounded-xl bg-background/60 text-left backdrop-blur-md outline-none [--search-trigger-stroke:var(--color-border)] hover:[--search-trigger-stroke:var(--color-border-strong)] focus-visible:ring-2 focus-visible:ring-ring",
iconOnly ? "cursor-pointer justify-center" : "cursor-text px-3.5",
)}
></motion.button>
) : null}
<motion.div
aria-hidden="true"
initial={false}
animate={{ opacity: open ? 0 : 1 }}
transition={
reduce
? { duration: 0 }
: {
duration: 0.1,
delay: open ? 0.1 : 0.12,
ease: EASE_OUT,
}
}
className={cn(
"pointer-events-none absolute inset-0 flex items-center",
backgroundScrollLocked && "z-[60]",
iconOnly ? "justify-center" : "gap-2.5 px-3.5",
)}
>
<Search className="size-4 shrink-0 text-muted-foreground" />
{iconOnly ? null : (
<>
<span className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
{placeholder}
</span>
{shortcut ? (
<kbd className="flex h-7 min-w-7 shrink-0 items-center justify-center rounded-md border border-border px-2 text-xs text-muted-foreground">
{shortcut.toUpperCase()}
</kbd>
) : null}
</>
)}
</motion.div>
</div>
{overlay}
</LayoutGroup>
);
}
API Reference
itemsMorphingSearchItem[]—placeholder?stringSearchshortcut?stringficonOnly?booleanRender the closed trigger as a compact search icon.
falseemptyMessage?stringNo results found.open?boolean—defaultOpen?booleanfalseonOpenChange?((open: boolean) => void)—onQueryChange?((query: string) => void)—onSelect?((item: MorphingSearchItem) => void)—className?string—Related components
Expandable Action Bar
Compact icon actions that expand into labeled controls on hover or focus with shared layout motion.
Expandable Tabs
Icon tab bar where the active tab expands to a labelled pill, with a panel above that morphs height and slides content direction-aware on switch.
Morphing Tabs
Reorderable tabs whose selected item grows into a white content surface, with the active shape gliding as tabs move and a shared morph between rooms.
Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
Updated