Docs / Getting started
Introduction
What Glide is, and isn't.
Press / to search.
A search button that stretches into a search field, with results unfolding underneath as one shape, and shrinks back into the button when it closes.
Uses motion, clsx, tailwind-merge
Pages
Plans for every team.
"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { MorphSearch, type MorphSearchItem } from "@/components/ui/morph-search";
const glyph = (d: string) => (
<svg aria-hidden viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round">
<path d={d} />
</svg>
);
const pages: MorphSearchItem[] = [
{ id: "pricing", group: "Pages", label: "Pricing", description: "Plans for every team", icon: glyph("M20.6 13.4l-7.2 7.2a2 2 0 0 1-2.8 0L3 13V3h10l7.6 7.6a2 2 0 0 1 0 2.8ZM7.5 7.5h.01"), keywords: ["plans", "cost"] },
{ id: "changelog", group: "Pages", label: "Changelog", description: "What shipped this week", icon: glyph("M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9Z"), keywords: ["updates", "release"] },
{ id: "customers", group: "Pages", label: "Customers", description: "Stories from teams like yours", icon: glyph("M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8M22 21v-2a4 4 0 0 0-3-3.9M16 3.1a4 4 0 0 1 0 7.8") },
{ id: "careers", group: "Pages", label: "Careers", description: "We're hiring", icon: glyph("M3 7h18v13H3zM8 7V4h8v3M3 13h18"), keywords: ["jobs"] },
{ id: "analytics", group: "Products", label: "Analytics", description: "Every event, in real time", icon: glyph("M3 3v18h18M7 14l4-4 3 3 6-6") },
{ id: "payments", group: "Products", label: "Payments", description: "Cards, wallets and payouts", icon: glyph("M2 6h20v12H2zM2 10h20M6 15h4"), keywords: ["billing", "checkout"] },
{ id: "identity", group: "Products", label: "Identity", description: "Sign-in that just works", icon: glyph("M12 3l8 3v6c0 5-3.5 8-8 9-4.5-1-8-4-8-9V6z"), keywords: ["auth", "login", "sso"] },
{ id: "storage", group: "Products", label: "Storage", description: "Files at the edge", icon: glyph("M4 6c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3ZM4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3") },
{ id: "sales", group: "Help", label: "Contact sales", description: "Talk to a human", icon: glyph("M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"), keywords: ["enterprise"] },
{ id: "status", group: "Help", label: "System status", description: "All systems normal", icon: glyph("M22 12h-4l-3 9L9 3l-3 9H2"), keywords: ["uptime", "outage"] },
];
const popular = ["pricing", "analytics", "changelog", "sales"].map((id) => ({
...pages.find((page) => page.id === id)!,
group: "Popular",
}));
export function MorphSearchDemo() {
const [page, setPage] = React.useState(pages[0]);
const reduceMotion = useReducedMotion();
return (
<div className="w-full max-w-2xl overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<header className="flex h-14 items-center gap-6 border-b border-zinc-200/80 px-4 dark:border-zinc-800/80">
<span className="flex items-center gap-2 text-sm font-semibold text-zinc-900 dark:text-zinc-50">
<span aria-hidden className="size-5 rounded-md bg-zinc-900 dark:bg-zinc-100" />
Northwind
</span>
<nav className="hidden gap-5 text-sm text-zinc-500 sm:flex">
{["Products", "Pricing", "Customers", "Docs"].map((link) => (
<span key={link}>{link}</span>
))}
</nav>
<div className="ml-auto flex items-center gap-3">
<MorphSearch
items={pages}
suggestions={popular}
placeholder="Search Northwind…"
onSelect={(item) => setPage(item)}
/>
<span aria-hidden className="size-8 rounded-full bg-linear-to-br from-amber-300 to-rose-400" />
</div>
</header>
<motion.main
key={page.id}
initial={{ opacity: 0, y: reduceMotion ? 0 : 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="px-6 pt-12 pb-16"
>
<p className="font-mono text-xs uppercase tracking-widest text-zinc-500">{page.group}</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{page.label}
</h2>
<p className="mt-2 max-w-sm text-sm leading-relaxed text-zinc-500">{page.description}.</p>
<div className="mt-8 grid grid-cols-3 gap-3">
{[0, 1, 2].map((block) => (
<span key={block} className="h-16 rounded-lg bg-zinc-100 dark:bg-zinc-900" />
))}
</div>
</motion.main>
</div>
);
}
Add the component on its own with the CLI, or copy the file in by hand.
The CLI writes components/ui/morph-search.tsx and installs motion, clsx and tailwind-merge. It needs the @keyframeui registry in components.json; add it once per project.
npx shadcn@latest add @keyframeui/morph-searchnpm i motion clsx tailwind-mergeSkip this if you already use shadcn/ui.
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
"use client";
import * as React from "react";
import { animate, motion, useMotionValue, useTransform } from "motion/react";
import { cn } from "@/lib/utils";
/* ─────────────────────────────────────────────────────────
* MORPH SEARCH STORYBOARD
*
* open the trigger stretches into a search field, one
* shape the whole way: its own look fades into the
* field's, the magnifier slides to the field's left
* edge, and the caret is already in the input
* unfold ~90ms later the field's bottom edge keeps growing
* into the results; rows ripple in top to bottom
* type results filter as you type; the panel's height
* follows on a spring and each match lights up
* move ↑ ↓ (or the pointer) slides one highlight between
* rows
* close Esc, a click outside, or picking a result: the
* results fold up, then the field shrinks back into
* the trigger, exactly where it started. Grab it on
* the way back to open it again
* palette instead of growing in place, the field travels to
* the middle of the screen over a dimmed page, and
* flies back to the trigger on close
* ───────────────────────────────────────────────────────── */
const MORPH = {
open: { type: "spring", visualDuration: 0.36, bounce: 0.16 },
travel: { type: "spring", visualDuration: 0.42, bounce: 0.08 }, // palette: a longer trip, less overshoot
close: { type: "spring", visualDuration: 0.3, bounce: 0, delay: 0.05 },
unfold: { type: "spring", visualDuration: 0.34, bounce: 0.1, delay: 0.09 },
resize: { type: "spring", visualDuration: 0.26, bounce: 0.06 },
fold: { type: "spring", visualDuration: 0.2, bounce: 0 },
highlight: { type: "spring", visualDuration: 0.18, bounce: 0.12 },
fade: { duration: 0.16, ease: "easeOut" },
stagger: 0.022, // s between rows as they ripple in
margin: 12, // px kept clear of the viewport edges
} as const;
const SIZES = {
// `pad` is the gap before the magnifier; `radius` rounds the field and
// eases to `panelRadius` once results open under it.
inline: { width: 360, height: 44, radius: 22, panelRadius: 18, glyph: 16, pad: 14, maxList: 340 },
palette: { width: 560, height: 56, radius: 18, panelRadius: 18, glyph: 18, pad: 18, maxList: 380 },
} as const;
const FOOTER_HEIGHT = 40;
export interface MorphSearchItem {
id: string;
label: string;
/** Muted text after the label. */
description?: string;
/** Results are grouped under this heading, in order of each group's best match. */
group?: string;
icon?: React.ReactNode;
/** Keys shown on the right, e.g. `["⌘", "N"]`. */
shortcut?: string[];
/** Extra words that should find this item. */
keywords?: string[];
}
export interface MorphSearchProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onSelect" | "children"> {
items: MorphSearchItem[];
/** Shown before anything is typed. Defaults to `items`. */
suggestions?: MorphSearchItem[];
onSelect?: (item: MorphSearchItem) => void;
onQueryChange?: (query: string) => void;
/** `inline` grows in place; `palette` travels to the middle of the screen. */
variant?: "inline" | "palette";
placeholder?: string;
/** Width of the open field, in px. */
width?: number;
/** Inline only: which edge of the trigger the field grows from. Defaults to the side with more room. */
align?: "start" | "end";
/** `"k"` for ⌘K / Ctrl+K, `"/"` for a bare slash. */
hotkey?: string;
/** Scores an item against the query; 0 hides it, higher ranks first. */
filter?: (item: MorphSearchItem, query: string) => number;
emptyMessage?: (query: string) => React.ReactNode;
/** Keyboard hints under the results. Defaults to on for the palette. */
footer?: boolean;
/** Classes for the open surface's background. */
surfaceClassName?: string;
/** Custom trigger content. Mark its magnifier with `MorphSearchIcon` so it slides into the field. */
children?: React.ReactNode;
}
type Rect = { x: number; y: number; width: number; height: number; radius: number };
type Geometry = {
from: Rect; // the trigger
to: Rect; // the open field
// The magnifier's center, relative to the shape's top-left, at each end.
glyphFrom: { x: number; y: number; scale: number };
glyphTo: { x: number; y: number };
glyphFades: boolean; // no magnifier in the trigger: fade it in instead
detached: boolean; // trigger off screen: fade in place instead of morphing
};
const NO_RECT: Rect = { x: 0, y: 0, width: 0, height: 0, radius: 0 };
const NO_GEOMETRY: Geometry = {
from: NO_RECT,
to: NO_RECT,
glyphFrom: { x: 0, y: 0, scale: 1 },
glyphTo: { x: 0, y: 0 },
glyphFades: true,
detached: true,
};
export function MorphSearch({
items,
suggestions,
onSelect,
onQueryChange,
variant = "inline",
placeholder = "Search…",
width,
align,
hotkey,
filter = defaultFilter,
emptyMessage = defaultEmptyMessage,
footer = variant === "palette",
surfaceClassName,
className,
children,
onClick,
...props
}: MorphSearchProps) {
const size = SIZES[variant];
const id = React.useId();
const listId = `${id}-list`;
const optionId = (index: number) => `${id}-option-${index}`;
const triggerRef = React.useRef<HTMLButtonElement>(null);
const layerRef = React.useRef<HTMLElement | null>(null);
const surfaceRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const panelRef = React.useRef<HTMLDivElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
const highlightRef = React.useRef<HTMLDivElement>(null);
const setLayer = React.useCallback((element: HTMLElement | null) => {
layerRef.current = element;
}, []);
const [mounted, setMounted] = React.useState(false);
const [query, setQuery] = React.useState("");
const [active, setActive] = React.useState(0);
const [maxList, setMaxList] = React.useState<number>(size.maxList);
// True for the first frames of each opening, so rows ripple in once.
const [revealing, setRevealing] = React.useState(true);
const [reduceMotion, setReduceMotion] = React.useState(false);
const live = React.useRef({
phase: "closed" as "closed" | "open" | "closing",
generation: 0,
panelTarget: 0,
reduceMotion: false,
query: "",
returnFocus: null as HTMLElement | null,
});
// 0 = the trigger, 1 = the open field. Everything about the shape follows it.
const progress = useMotionValue(0);
const panel = useMotionValue(0); // height of the results under the field
const veil = useMotionValue(1); // fades the whole thing when it can't morph
const geometry = useMotionValue<Geometry>(NO_GEOMETRY);
const highlightY = useMotionValue(0);
const highlightHeight = useMotionValue(0);
const at = (key: "x" | "y" | "width" | "height" | "radius") =>
lerp(geometry.get().from[key], geometry.get().to[key], progress.get());
const x = useTransform(() => at("x"));
const y = useTransform(() => at("y"));
const shapeWidth = useTransform(() => at("width"));
const fieldHeight = useTransform(() => at("height"));
const shapeHeight = useTransform(() => fieldHeight.get() + panel.get());
const radius = useTransform(() => {
const extra = (geometry.get().to.radius - size.panelRadius) * clamp(panel.get() / 48, 0, 1);
return Math.max(at("radius") - extra, 0);
});
// The trigger's look hands over to the surface's over the first third.
const look = useTransform(() => clamp(progress.get() / 0.35, 0, 1) * veil.get());
const skin = useTransform(() =>
geometry.get().detached ? 0 : (1 - clamp(progress.get() / 0.35, 0, 1)) * veil.get(),
);
const content = useTransform(() => clamp((progress.get() - 0.3) / 0.45, 0, 1) * veil.get());
const glyphX = useTransform(() => {
const g = geometry.get();
return lerp(g.glyphFrom.x, g.glyphTo.x, progress.get()) - size.glyph / 2;
});
const glyphY = useTransform(() => {
const g = geometry.get();
return lerp(g.glyphFrom.y, g.glyphTo.y, progress.get()) - size.glyph / 2;
});
const glyphScale = useTransform(() => lerp(geometry.get().glyphFrom.scale, 1, progress.get()));
const glyphOpacity = useTransform(() => (geometry.get().glyphFades ? content.get() : veil.get()));
const backdrop = useTransform(() => clamp(progress.get(), 0, 1) * veil.get());
const divider = useTransform(() => clamp(panel.get() / 12, 0, 1));
// ── Results ─────────────────────────────────────────────
const trimmed = query.trim();
const groups = React.useMemo(() => {
const ranked = trimmed
? items
.map((item, index) => ({ item, index, score: filter(item, trimmed) }))
.filter((entry) => entry.score > 0)
.sort((a, b) => b.score - a.score || a.index - b.index)
.map((entry) => entry.item)
: (suggestions ?? items);
return groupItems(ranked);
}, [trimmed, items, suggestions, filter]);
const ordered = React.useMemo(() => groups.flatMap((group) => group.items), [groups]);
const activeIndex = Math.min(active, Math.max(ordered.length - 1, 0));
const showPanel = ordered.length > 0 || trimmed !== "";
// ── Open and close ──────────────────────────────────────
const open = () => {
const state = live.current;
if (state.phase === "open") return;
if (state.phase === "closing") {
// Caught on its way back: grow out again from wherever it is.
state.phase = "open";
state.generation++;
animate(progress, 1, state.reduceMotion ? { duration: 0 } : MORPH.resize);
animate(panel, state.panelTarget, state.reduceMotion ? { duration: 0 } : MORPH.resize);
animate(veil, 1, MORPH.fade);
inputRef.current?.focus({ preventScroll: true });
return;
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const g = measure(triggerRef.current, variant, width, align);
state.phase = "open";
state.reduceMotion = reduce;
state.returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
geometry.set(g);
progress.jump(reduce ? 1 : 0);
panel.jump(0);
veil.jump(reduce || g.detached ? 0 : 1);
setMaxList(listLimit(g.to, variant, footer));
setReduceMotion(reduce);
setMounted(true);
if (!reduce) animate(progress, 1, variant === "palette" ? MORPH.travel : MORPH.open);
if (reduce || g.detached) animate(veil, 1, MORPH.fade);
};
const close = () => {
const state = live.current;
if (state.phase !== "open") return;
state.phase = "closing";
const generation = ++state.generation;
const fadeOnly = state.reduceMotion || geometry.get().detached;
if (!fadeOnly) animate(panel, 0, MORPH.fold);
const done = fadeOnly ? animate(veil, 0, MORPH.fade) : animate(progress, 0, MORPH.close);
done.then(() => {
if (live.current.generation === generation) finish();
});
};
// Landed back on the trigger: show it, put the layer away and return focus,
// all in one task so no frame shows both or neither.
const finish = () => {
const state = live.current;
if (state.phase === "closed") return;
state.phase = "closed";
const trigger = triggerRef.current;
if (trigger) trigger.style.opacity = "";
const layer = layerRef.current;
const hadFocus = !!layer?.contains(document.activeElement);
if (layer instanceof HTMLDialogElement) layer.close();
else if (layer?.matches(":popover-open")) layer.hidePopover();
if (hadFocus || document.activeElement === document.body) {
const target = state.returnFocus?.isConnected ? state.returnFocus : trigger;
target?.focus({ preventScroll: true });
}
if (state.query) onQueryChange?.("");
state.query = "";
setMounted(false);
setQuery("");
setActive(0);
setRevealing(true);
};
const toggle = () => (live.current.phase === "open" ? close() : open());
const select = (item: MorphSearchItem) => {
if (live.current.phase !== "open") return;
onSelect?.(item);
close();
};
const changeQuery = (value: string) => {
live.current.query = value;
setQuery(value);
setActive(0);
onQueryChange?.(value);
};
// Global listeners call the latest versions of these.
const actions = React.useRef({ open, close, toggle });
React.useLayoutEffect(() => {
actions.current = { open, close, toggle };
});
// Mounted: lift the layer into the top layer, hide the trigger behind the
// shape, focus the input and unfold the results.
React.useLayoutEffect(() => {
const layer = layerRef.current;
if (!mounted || !layer) return;
const state = live.current;
if (layer instanceof HTMLDialogElement) {
if (!layer.open) layer.showModal();
} else if (!layer.matches(":popover-open")) {
layer.popover = "manual";
layer.showPopover();
}
const trigger = triggerRef.current;
if (trigger && !state.reduceMotion && !geometry.get().detached) trigger.style.opacity = "0";
inputRef.current?.focus({ preventScroll: true });
const height = panelRef.current?.offsetHeight ?? 0;
state.panelTarget = height;
animate(panel, height, state.reduceMotion ? { duration: 0 } : MORPH.unfold);
const timer = window.setTimeout(() => setRevealing(false), 500);
return () => window.clearTimeout(timer);
}, [mounted, panel, geometry]);
// The panel follows its content: more results, a taller panel.
React.useLayoutEffect(() => {
const element = panelRef.current;
if (!mounted || !element) return;
const observer = new ResizeObserver(() => {
const state = live.current;
const height = element.offsetHeight;
if (state.phase !== "open" || Math.abs(height - state.panelTarget) < 0.5) return;
state.panelTarget = height;
animate(panel, height, state.reduceMotion ? { duration: 0 } : MORPH.resize);
});
observer.observe(element);
return () => observer.disconnect();
}, [mounted, panel]);
// The highlight is drawn straight from its motion values rather than bound
// to a motion element, which would reset them when remounted (as StrictMode
// does in development).
const hasResults = ordered.length > 0;
React.useLayoutEffect(() => {
const element = highlightRef.current;
if (!mounted || !hasResults || !element) return;
const draw = () => {
element.style.transform = `translateY(${highlightY.get()}px)`;
element.style.height = `${highlightHeight.get()}px`;
};
draw();
const offY = highlightY.on("change", draw);
const offHeight = highlightHeight.on("change", draw);
return () => {
offY();
offHeight();
};
}, [mounted, hasResults, highlightY, highlightHeight]);
// Slide the highlight to the active row and keep that row in view.
React.useLayoutEffect(() => {
if (!mounted || !ordered.length) return;
const row = document.getElementById(`${id}-option-${activeIndex}`);
const list = listRef.current;
if (!row || !list) return;
const top = row.offsetTop;
const height = row.offsetHeight;
if (highlightHeight.get() === 0 || live.current.reduceMotion) {
highlightY.jump(top);
highlightHeight.jump(height);
} else {
animate(highlightY, top, MORPH.highlight);
animate(highlightHeight, height, MORPH.highlight);
}
if (top < list.scrollTop) list.scrollTop = top - 6;
else if (top + height > list.scrollTop + list.clientHeight) {
list.scrollTop = top + height - list.clientHeight + 6;
}
}, [mounted, ordered, activeIndex, id, highlightY, highlightHeight]);
React.useEffect(() => {
if (!mounted) highlightHeight.jump(0);
}, [mounted, highlightHeight]);
// Keep the shape on its trigger if the page scrolls or resizes.
React.useEffect(() => {
if (!mounted) return;
let frame = 0;
const update = (event: Event) => {
if (event.target instanceof Node && layerRef.current?.contains(event.target)) return;
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const next = measure(triggerRef.current, variant, width, align);
// A trigger that scrolled away stays where it was last seen.
if (!next.detached) geometry.set(next);
});
};
window.addEventListener("resize", update);
window.addEventListener("scroll", update, true);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", update);
window.removeEventListener("scroll", update, true);
};
}, [mounted, variant, width, align, geometry]);
// Inline: a click anywhere else closes it. The palette has its backdrop.
React.useEffect(() => {
if (!mounted || variant !== "inline") return;
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node;
if (surfaceRef.current?.contains(target) || triggerRef.current?.contains(target)) return;
actions.current.close();
};
document.addEventListener("pointerdown", onPointerDown, true);
return () => document.removeEventListener("pointerdown", onPointerDown, true);
}, [mounted, variant]);
// Palette: the wheel scrolls results, never the page behind.
React.useEffect(() => {
const layer = layerRef.current;
if (!mounted || variant !== "palette" || !layer) return;
const onWheel = (event: WheelEvent) => {
if (!listRef.current?.contains(event.target as Node)) event.preventDefault();
};
layer.addEventListener("wheel", onWheel, { passive: false });
return () => layer.removeEventListener("wheel", onWheel);
}, [mounted, variant]);
React.useEffect(() => {
if (!hotkey) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.repeat || event.isComposing) return;
const key = event.key.toLowerCase();
if (hotkey === "/") {
const target = event.target instanceof Element ? event.target : null;
const typing = target?.closest("input, textarea, select, [contenteditable]");
if (key !== "/" || typing || event.metaKey || event.ctrlKey || event.altKey) return;
event.preventDefault();
actions.current.open();
return;
}
if (key !== hotkey.toLowerCase() || !(event.metaKey || event.ctrlKey)) return;
if (event.altKey || event.shiftKey) return;
event.preventDefault();
actions.current.toggle();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [hotkey]);
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape") {
// First Esc clears what was typed; the next one closes.
event.preventDefault();
if (query) {
changeQuery("");
inputRef.current?.focus();
} else close();
return;
}
if (event.target !== inputRef.current || event.nativeEvent.isComposing) return;
const down = event.key === "ArrowDown" || (event.ctrlKey && event.key === "n");
const up = event.key === "ArrowUp" || (event.ctrlKey && event.key === "p");
if (down || up) {
event.preventDefault();
if (ordered.length) setActive((activeIndex + (down ? 1 : -1) + ordered.length) % ordered.length);
} else if (event.key === "Enter") {
event.preventDefault();
const item = ordered[activeIndex];
if (item) select(item);
}
};
// ── Render ──────────────────────────────────────────────
const iconOnly = children === undefined;
const triggerClassName = cn(
"relative shrink-0 cursor-pointer bg-background text-foreground/80 ring-1 ring-foreground/10 outline-none select-none [-webkit-tap-highlight-color:transparent]",
"transition-[background-color,scale] duration-150 ease-out hover:bg-foreground/[0.03] active:scale-[0.97] motion-reduce:active:scale-100",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground",
iconOnly
? "grid size-9 place-items-center rounded-full"
: "inline-flex h-9 items-center gap-2 rounded-full px-3 text-sm text-muted-foreground",
className,
);
const triggerContent = iconOnly ? <MorphSearchIcon /> : children;
const shape = (
<>
{variant === "palette" ? (
<motion.div
aria-hidden
className="absolute inset-0 bg-black/25 backdrop-blur-[2px] dark:bg-black/55"
style={{ opacity: backdrop }}
onPointerDown={() => close()}
/>
) : null}
{/* Ring and shadow sit behind the shape so they can fade in on their own. */}
<motion.div
aria-hidden
className="pointer-events-none absolute top-0 left-0 shadow-[0_18px_50px_-12px_rgb(0_0_0/0.22),0_4px_14px_-6px_rgb(0_0_0/0.1)] ring-1 ring-foreground/10 dark:shadow-[0_18px_50px_-12px_rgb(0_0_0/0.7)]"
style={{ x, y, width: shapeWidth, height: shapeHeight, borderRadius: radius, opacity: look }}
/>
<motion.div
ref={surfaceRef}
className="pointer-events-auto absolute top-0 left-0 overflow-hidden"
style={{ x, y, width: shapeWidth, height: shapeHeight, borderRadius: radius }}
// Caught on its way back to the trigger: open it again.
onPointerDown={() => {
if (live.current.phase === "closing") open();
}}
// Clicks anywhere but the input and buttons keep the caret in the input.
onMouseDown={(event) => {
const target = event.target as Element;
if (target !== inputRef.current && !target.closest("button")) event.preventDefault();
}}
onKeyDown={onKeyDown}
onBlur={(event) => {
// Inline: tabbing out closes it.
const next = event.relatedTarget;
if (variant === "inline" && next && !surfaceRef.current?.contains(next)) close();
}}
>
<motion.div
aria-hidden
className={cn("absolute inset-0 bg-background", surfaceClassName)}
style={{ opacity: look }}
/>
{/* A copy of the trigger that stretches with the shape as it fades. */}
<motion.div
aria-hidden
className={cn(triggerClassName, "pointer-events-none absolute top-0 left-0 [&_[data-morph-search-icon]]:invisible")}
style={{ width: "100%", height: fieldHeight, opacity: skin }}
>
{triggerContent}
</motion.div>
<motion.div className="absolute inset-x-0 top-0" style={{ height: fieldHeight }}>
<motion.span
aria-hidden
className="absolute top-0 left-0 text-muted-foreground"
style={{ x: glyphX, y: glyphY, scale: glyphScale, opacity: glyphOpacity, width: size.glyph, height: size.glyph }}
>
<SearchGlyph className="size-full" />
</motion.span>
<motion.div className="flex h-full items-center" style={{ opacity: content }}>
<input
ref={inputRef}
role="combobox"
aria-label={placeholder}
aria-expanded={showPanel}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={ordered.length ? optionId(activeIndex) : undefined}
value={query}
onChange={(event) => changeQuery(event.target.value)}
placeholder={placeholder}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
enterKeyHint="go"
className={cn(
"h-full min-w-0 flex-1 bg-transparent text-base text-foreground outline-none placeholder:text-muted-foreground",
variant === "inline" ? "pl-10 sm:text-sm" : "pl-12 sm:text-[15px]",
)}
/>
{query ? (
<button
type="button"
aria-label="Clear search"
className="mr-2.5 grid size-7 shrink-0 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-foreground/[0.06] hover:text-foreground focus-visible:outline-2 focus-visible:outline-foreground"
onClick={() => {
changeQuery("");
inputRef.current?.focus();
}}
>
<svg aria-hidden viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
) : (
<Kbd className={cn("shrink-0 pointer-coarse:hidden", variant === "inline" ? "mr-3" : "mr-4")}>esc</Kbd>
)}
</motion.div>
</motion.div>
<motion.div className="absolute inset-x-0" style={{ top: fieldHeight }}>
<div ref={panelRef}>
{showPanel ? (
<>
<motion.div className="h-px bg-foreground/[0.07]" style={{ opacity: divider }} />
<div
ref={listRef}
id={listId}
role="listbox"
aria-label="Results"
className="relative overflow-y-auto overscroll-contain p-1.5 [scrollbar-width:thin]"
style={{ maxHeight: maxList }}
>
{hasResults ? (
<motion.div
aria-hidden
initial={revealing && !reduceMotion ? { opacity: 0 } : false}
animate={{ opacity: 1 }}
transition={{ duration: 0.2, delay: 0.1 }}
className="pointer-events-none absolute inset-0"
>
<div
ref={highlightRef}
className="absolute inset-x-1.5 top-0 h-0 rounded-[10px] bg-foreground/[0.06]"
/>
</motion.div>
) : (
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
{emptyMessage(trimmed)}
</div>
)}
{groups.map((group) => (
<div key={group.name ?? "_"} role="group" aria-label={group.name}>
{group.name ? (
<div aria-hidden className="px-2.5 pt-2 pb-1 text-[11px] font-medium text-muted-foreground">
{group.name}
</div>
) : null}
{group.items.map((item, offset) => {
const index = group.start + offset;
const isActive = index === activeIndex;
return (
<motion.div
key={item.id}
id={optionId(index)}
role="option"
aria-selected={isActive}
initial={
reduceMotion
? false
: revealing
? { opacity: 0, y: 6 }
: { opacity: 0 }
}
animate={{ opacity: 1, y: 0 }}
transition={
revealing
? { duration: 0.3, ease: [0.22, 1, 0.36, 1], delay: 0.1 + Math.min(index, 10) * MORPH.stagger }
: { duration: 0.12 }
}
className={cn(
"relative flex cursor-default items-center gap-3 rounded-[10px] px-2.5 select-none",
variant === "inline" ? "h-10" : "h-11",
)}
onPointerMove={() => {
if (!isActive) setActive(index);
}}
onClick={() => select(item)}
>
{item.icon ? (
<span className="grid size-7 shrink-0 place-items-center rounded-lg bg-foreground/[0.05] text-foreground/70 ring-1 ring-foreground/[0.06] ring-inset [&_svg]:size-4">
{item.icon}
</span>
) : null}
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="truncate text-sm text-foreground">
<Match text={item.label} query={trimmed} />
</span>
{item.description ? (
<span className="truncate text-xs text-muted-foreground">{item.description}</span>
) : null}
</span>
{item.shortcut ? (
<span className="flex shrink-0 gap-1">
{item.shortcut.map((key) => (
<Kbd key={key}>{key}</Kbd>
))}
</span>
) : (
<Kbd
aria-hidden
className={cn(
"shrink-0 transition-opacity duration-150 pointer-coarse:hidden",
isActive ? "opacity-100" : "opacity-0",
)}
>
↵
</Kbd>
)}
</motion.div>
);
})}
</div>
))}
</div>
{footer ? (
<div
className="flex items-center justify-between border-t border-foreground/[0.07] px-3.5 text-[11px] text-muted-foreground pointer-coarse:hidden"
style={{ height: FOOTER_HEIGHT }}
>
<span className="flex items-center gap-3">
<span className="flex items-center gap-1.5">
<Kbd>↑</Kbd>
<Kbd>↓</Kbd>
to navigate
</span>
<span className="flex items-center gap-1.5">
<Kbd>↵</Kbd>
to select
</span>
</span>
<span className="flex items-center gap-1.5">
<Kbd>esc</Kbd>
to close
</span>
</div>
) : null}
</>
) : null}
</div>
</motion.div>
<p className="sr-only" aria-live="polite">
{trimmed ? `${ordered.length} ${ordered.length === 1 ? "result" : "results"}` : ""}
</p>
</motion.div>
</>
);
return (
<>
<button
ref={triggerRef}
type="button"
aria-label={iconOnly ? "Search" : undefined}
aria-haspopup={variant === "palette" ? "dialog" : "listbox"}
aria-expanded={mounted}
className={triggerClassName}
{...props}
onClick={(event) => {
onClick?.(event);
if (!event.defaultPrevented) toggle();
}}
>
{triggerContent}
</button>
{mounted ? (
variant === "palette" ? (
<dialog
ref={setLayer}
aria-label={placeholder}
onCancel={(event) => {
event.preventDefault();
close();
}}
onClose={finish}
className="fixed inset-0 m-0 size-full max-h-none max-w-none overflow-hidden border-0 bg-transparent p-0 text-foreground outline-none backdrop:bg-transparent"
>
{shape}
</dialog>
) : (
<div
ref={setLayer}
className="pointer-events-none fixed inset-0 m-0 size-full max-h-none max-w-none overflow-visible border-0 bg-transparent p-0 text-foreground"
>
{shape}
</div>
)
) : null}
</>
);
}
/** The magnifier. Put it in a custom trigger and it slides into the open field. */
export function MorphSearchIcon({ className, ...props }: React.SVGProps<SVGSVGElement>) {
return <SearchGlyph data-morph-search-icon="" className={cn("size-4 shrink-0", className)} {...props} />;
}
function SearchGlyph(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
aria-hidden
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<circle cx="11" cy="11" r="7" />
<path d="m20 20-3.5-3.5" />
</svg>
);
}
function Kbd({ className, ...props }: React.HTMLAttributes<HTMLElement>) {
return (
<kbd
className={cn(
"inline-grid h-5 min-w-5 place-items-center rounded-[5px] bg-foreground/[0.06] px-1 font-mono text-[10.5px] leading-none text-muted-foreground ring-1 ring-foreground/[0.06] ring-inset",
className,
)}
{...props}
/>
);
}
/** The label with the matched text in full strength and the rest dimmed. */
function Match({ text, query }: { text: string; query: string }) {
if (!query) return text;
const lower = text.toLowerCase();
const needle = [query, ...query.split(/\s+/)].find((part) => part && lower.includes(part.toLowerCase()));
if (!needle) return text;
const start = lower.indexOf(needle.toLowerCase());
const end = start + needle.length;
return (
<>
<span className="text-foreground/55">{text.slice(0, start)}</span>
{text.slice(start, end)}
<span className="text-foreground/55">{text.slice(end)}</span>
</>
);
}
function defaultEmptyMessage(query: string) {
return (
<>
No results for <span className="text-foreground">“{query}”</span>
</>
);
}
/**
* Every word must appear somewhere in the item. Labels that start with the
* query rank first, then labels with a word that does, then everything else.
*/
function defaultFilter(item: MorphSearchItem, query: string) {
const needle = query.toLowerCase();
const label = item.label.toLowerCase();
const haystack = [item.label, item.description, item.group, ...(item.keywords ?? [])]
.filter(Boolean)
.join(" ")
.toLowerCase();
const words = needle.split(/\s+/).filter(Boolean);
if (!words.every((word) => haystack.includes(word))) return 0;
if (label.startsWith(needle)) return 4;
if (label.split(/\s+/).some((word) => word.startsWith(words[0]))) return 3;
if (label.includes(needle)) return 2;
return 1;
}
/** Groups in order of each group's first (best) item, each with the index of its first row. */
function groupItems(items: MorphSearchItem[]) {
const groups: { name?: string; items: MorphSearchItem[]; start: number }[] = [];
for (const item of items) {
const group = groups.find((entry) => entry.name === item.group);
if (group) group.items.push(item);
else groups.push({ name: item.group, items: [item], start: 0 });
}
let start = 0;
for (const group of groups) {
group.start = start;
start += group.items.length;
}
return groups;
}
// ── Geometry ──────────────────────────────────────────────
function measure(
trigger: HTMLElement | null,
variant: "inline" | "palette",
width: number | undefined,
align: "start" | "end" | undefined,
): Geometry {
const size = SIZES[variant];
const margin = MORPH.margin;
const viewportWidth = document.documentElement.clientWidth;
const viewportHeight = document.documentElement.clientHeight;
const fieldWidth = Math.min(width ?? size.width, viewportWidth - margin * 2);
const rect = trigger?.getBoundingClientRect();
const visible =
!!rect &&
rect.width > 0 &&
rect.height > 0 &&
rect.bottom > 0 &&
rect.right > 0 &&
rect.top < viewportHeight &&
rect.left < viewportWidth;
let to: Rect;
if (variant === "palette" || !rect) {
to = {
x: (viewportWidth - fieldWidth) / 2,
y: clamp(viewportHeight * 0.14, margin, 120),
width: fieldWidth,
height: size.height,
radius: size.radius,
};
} else {
const side = align ?? (rect.left + rect.width / 2 > viewportWidth / 2 ? "end" : "start");
const left = side === "end" ? rect.right - fieldWidth : rect.left;
to = {
x: clamp(left, margin, viewportWidth - margin - fieldWidth),
y: Math.max(rect.top + rect.height / 2 - size.height / 2, margin),
width: fieldWidth,
height: size.height,
radius: size.radius,
};
}
const from: Rect =
visible && trigger
? {
x: rect.left,
y: rect.top,
width: rect.width,
height: rect.height,
radius: Math.min(
parseFloat(getComputedStyle(trigger).borderTopLeftRadius) || 0,
rect.width / 2,
rect.height / 2,
),
}
: { ...to, x: to.x + to.width * 0.02, width: to.width * 0.96 };
const glyphTo = { x: size.pad + size.glyph / 2, y: size.height / 2 };
const icon = visible ? trigger?.querySelector("[data-morph-search-icon]") : null;
const iconRect = icon?.getBoundingClientRect();
const glyphFrom = iconRect
? {
x: iconRect.left + iconRect.width / 2 - from.x,
y: iconRect.top + iconRect.height / 2 - from.y,
scale: iconRect.width / size.glyph,
}
: { ...glyphTo, scale: 1 };
return { from, to, glyphFrom, glyphTo, glyphFades: !iconRect, detached: !visible };
}
/** How tall the list may grow before it scrolls. */
function listLimit(field: Rect, variant: "inline" | "palette", footer: boolean) {
const viewportHeight = document.documentElement.clientHeight;
const room = viewportHeight - (field.y + field.height) - MORPH.margin - (footer ? FOOTER_HEIGHT : 0);
return Math.max(Math.min(SIZES[variant].maxList, room), 120);
}
function lerp(from: number, to: number, t: number) {
return from + (to - from) * t;
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
The file imports cn from @/lib/utils. Change the path if your project uses a different alias.
Finished sections built with Morph Search. Each one installs whole, with the component included.
An app's command menu. The trigger travels to the middle of the screen, with grouped commands and their shortcuts. Opens with ⌘K.
Acme /Projects
Marketing site
Updated today
Mobile app
Updated today
Docs
Updated today
Billing service
Updated today
Design system
Updated today
Status page
Updated today
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { MorphSearch, MorphSearchIcon, type MorphSearchItem } from "@/components/ui/morph-search";
const glyph = (d: string) => (
<svg aria-hidden viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round">
<path d={d} />
</svg>
);
const useIsMac = () =>
React.useSyncExternalStore(
() => () => {},
() => /Mac|iPhone|iPad/.test(navigator.platform),
() => true,
);
function commands(mod: string): MorphSearchItem[] {
return [
{ id: "new-project", group: "Suggestions", label: "Create new project", icon: glyph("M12 5v14M5 12h14"), shortcut: [mod, "N"] },
{ id: "invite", group: "Suggestions", label: "Invite teammate", icon: glyph("M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8M19 8v6M22 11h-6"), shortcut: [mod, "I"] },
{ id: "deploy", group: "Suggestions", label: "Deploy to production", icon: glyph("M13 2 3 14h9l-1 8 10-12h-9l1-8z"), keywords: ["ship", "release"] },
{ id: "dashboard", group: "Go to", label: "Dashboard", icon: glyph("M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z"), shortcut: ["G", "D"] },
{ id: "projects", group: "Go to", label: "Projects", icon: glyph("M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"), shortcut: ["G", "P"] },
{ id: "billing", group: "Go to", label: "Billing", icon: glyph("M2 6h20v12H2zM2 10h20M6 15h4"), shortcut: ["G", "B"], keywords: ["invoices", "plan"] },
{ id: "settings", group: "Go to", label: "Settings", icon: glyph("M4 6h9M17 6h3M4 12h3M11 12h9M4 18h11M19 18h1M15 4v4M9 10v4M17 16v4"), shortcut: [mod, ","], keywords: ["preferences"] },
{ id: "light", group: "Theme", label: "Light", icon: glyph("M12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8ZM12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"), keywords: ["appearance"] },
{ id: "dark", group: "Theme", label: "Dark", icon: glyph("M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"), keywords: ["appearance", "night"] },
{ id: "system", group: "Theme", label: "Match system", icon: glyph("M3 4h18v12H3zM8 20h8M12 16v4"), keywords: ["appearance", "auto"] },
{ id: "log-out", group: "Account", label: "Log out", icon: glyph("M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"), keywords: ["sign out"] },
];
}
export function MorphSearchCommandDemo() {
const mod = useIsMac() ? "⌘" : "Ctrl";
const items = React.useMemo(() => commands(mod), [mod]);
const [ran, setRan] = React.useState<{ label: string; at: number } | null>(null);
React.useEffect(() => {
if (!ran) return;
const timer = window.setTimeout(() => setRan(null), 2400);
return () => window.clearTimeout(timer);
}, [ran]);
return (
<div className="relative flex h-[26rem] w-full max-w-3xl overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<aside className="hidden w-48 shrink-0 flex-col gap-1 border-r border-zinc-200/80 p-3 sm:flex dark:border-zinc-800/80">
<span className="mb-3 flex items-center gap-2 px-2 text-sm font-semibold text-zinc-900 dark:text-zinc-50">
<span aria-hidden className="size-5 rounded-md bg-orange-500" />
Acme
</span>
{["Dashboard", "Projects", "Deployments", "Billing", "Settings"].map((link, i) => (
<span
key={link}
className={
i === 1
? "rounded-md bg-zinc-100 px-2 py-1.5 text-sm text-zinc-900 dark:bg-zinc-900 dark:text-zinc-50"
: "px-2 py-1.5 text-sm text-zinc-500"
}
>
{link}
</span>
))}
</aside>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 items-center justify-between gap-4 border-b border-zinc-200/80 px-4 dark:border-zinc-800/80">
<p className="truncate text-sm text-zinc-500">
Acme <span className="px-1 text-zinc-300 dark:text-zinc-700">/</span>
<span className="text-zinc-900 dark:text-zinc-50">Projects</span>
</p>
<MorphSearch
variant="palette"
hotkey="k"
items={items}
placeholder="Type a command or search…"
onSelect={(item) => setRan({ label: item.label, at: Date.now() })}
className="h-8 gap-2 rounded-lg px-2 text-[13px]"
>
<MorphSearchIcon className="size-3.5" />
<span>Commands</span>
<kbd className="ml-1 rounded-[5px] bg-foreground/[0.06] px-1.5 py-0.5 font-mono text-[10.5px]">
{mod === "⌘" ? "⌘K" : "Ctrl K"}
</kbd>
</MorphSearch>
</header>
<div className="grid flex-1 grid-cols-2 content-start gap-3 p-4 sm:grid-cols-3">
{["Marketing site", "Mobile app", "Docs", "Billing service", "Design system", "Status page"].map((name) => (
<div key={name} className="rounded-lg border border-zinc-200/80 p-3 dark:border-zinc-800/80">
<span aria-hidden className="block size-6 rounded-md bg-zinc-100 dark:bg-zinc-900" />
<p className="mt-6 truncate text-sm font-medium text-zinc-900 dark:text-zinc-50">{name}</p>
<p className="text-xs text-zinc-500">Updated today</p>
</div>
))}
</div>
</div>
<AnimatePresence>
{ran ? (
<motion.p
key={ran.at}
role="status"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-full bg-zinc-900 px-3.5 py-2 text-xs text-zinc-50 shadow-lg dark:bg-zinc-50 dark:text-zinc-900"
>
<svg aria-hidden viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor" strokeWidth={2.25} strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12.5l4.5 4.5L19 7.5" />
</svg>
{ran.label}
</motion.p>
) : null}
</AnimatePresence>
</div>
);
}
A docs header with a fake search field that becomes the real one. Press / to open it from anywhere.
Docs / Getting started
What Glide is, and isn't.
Press / to search.
"use client";
import * as React from "react";
import { motion, useReducedMotion } from "motion/react";
import { MorphSearch, MorphSearchIcon, type MorphSearchItem } from "@/components/ui/morph-search";
const page = (
<svg aria-hidden viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round">
<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9ZM14 3v6h6" />
</svg>
);
const heading = (
<svg aria-hidden viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round">
<path d="M4 9h16M4 15h16M10 3 8 21M16 3l-2 18" />
</svg>
);
const docs: MorphSearchItem[] = [
{ id: "introduction", group: "Getting started", label: "Introduction", description: "What Glide is, and isn't", icon: page },
{ id: "installation", group: "Getting started", label: "Installation", description: "Add Glide to Next.js or Vite", icon: page, keywords: ["install", "setup", "npm"] },
{ id: "quick-start", group: "Getting started", label: "Quick start", description: "Your first component in five minutes", icon: page },
{ id: "cli", group: "Getting started", label: "CLI", description: "Commands and flags", icon: page, keywords: ["terminal"] },
{ id: "button", group: "Components", label: "Button", description: "Actions, links and loading states", icon: page },
{ id: "dialog", group: "Components", label: "Dialog", description: "Modal windows that trap focus", icon: page, keywords: ["modal"] },
{ id: "popover", group: "Components", label: "Popover", description: "Floating panels anchored to a trigger", icon: page },
{ id: "tabs", group: "Components", label: "Tabs", description: "Switch between related views", icon: page },
{ id: "toast", group: "Components", label: "Toast", description: "Brief, non-blocking messages", icon: page, keywords: ["notification"] },
{ id: "theming", group: "Guides", label: "Theming", description: "Colors, radii and fonts", icon: page, keywords: ["css", "tokens"] },
{ id: "css-variables", group: "Guides", label: "CSS variables", description: "Theming", icon: heading, keywords: ["tokens"] },
{ id: "dark-mode", group: "Guides", label: "Dark mode", description: "Follow the system or a toggle", icon: page },
{ id: "animation", group: "Guides", label: "Animation", description: "Springs, easing and reduced motion", icon: page, keywords: ["motion"] },
{ id: "reduced-motion", group: "Guides", label: "Reduced motion", description: "Animation", icon: heading },
];
const suggested = ["installation", "dialog", "theming", "animation"].map((id) => ({
...docs.find((doc) => doc.id === id)!,
group: "Suggested",
}));
export function MorphSearchDocsDemo() {
const [doc, setDoc] = React.useState(docs[0]);
const reduceMotion = useReducedMotion();
return (
<div className="w-full max-w-3xl overflow-hidden rounded-xl border border-zinc-200 bg-white dark:border-zinc-800 dark:bg-zinc-950">
<header className="flex h-14 items-center gap-4 border-b border-zinc-200/80 px-4 dark:border-zinc-800/80">
<span className="flex shrink-0 items-center gap-2 text-sm font-semibold text-zinc-900 dark:text-zinc-50">
<span aria-hidden className="size-5 rounded-full bg-linear-to-br from-sky-400 to-emerald-400" />
Glide
<span className="rounded-full bg-zinc-100 px-1.5 py-0.5 font-mono text-[10px] font-normal text-zinc-500 dark:bg-zinc-900">
v3
</span>
</span>
<MorphSearch
variant="palette"
hotkey="/"
items={docs}
suggestions={suggested}
placeholder="Search documentation…"
onSelect={(item) => setDoc(item)}
className="mx-auto h-9 w-full max-w-64 justify-start gap-2.5 rounded-lg bg-zinc-50 px-2.5 dark:bg-zinc-900/60"
>
<MorphSearchIcon />
<span className="truncate">Search docs…</span>
<kbd className="ml-auto rounded-[5px] bg-background px-1.5 py-0.5 font-mono text-[10.5px] ring-1 ring-foreground/10">
/
</kbd>
</MorphSearch>
<span className="hidden shrink-0 text-sm text-zinc-500 sm:block">GitHub</span>
</header>
<motion.article
key={doc.id}
initial={{ opacity: 0, y: reduceMotion ? 0 : 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
className="px-6 pt-10 pb-14 sm:px-10"
>
<p className="text-xs text-zinc-500">
Docs <span className="px-1 text-zinc-300 dark:text-zinc-700">/</span> {doc.group}
</p>
<h2 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
{doc.label}
</h2>
<p className="mt-2 text-sm text-zinc-500">{doc.description}.</p>
<div className="mt-8 space-y-2.5">
{["w-full", "w-11/12", "w-4/5", "w-2/3"].map((line) => (
<span key={line} className={`block h-2.5 rounded-full bg-zinc-100 dark:bg-zinc-900 ${line}`} />
))}
</div>
<p className="mt-10 font-mono text-xs text-zinc-500">Press / to search.</p>
</motion.article>
</div>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
itemsRequired | MorphSearchItem[] | None | What to search. Each item has an id and label, and optionally a description, group, icon, shortcut (keys shown on the right) and keywords. |
onSelect | (item: MorphSearchItem) => void | None | Called when a result is picked with Enter or a click. The search closes after. |
variant | "inline" | "palette" | "inline" | inline grows from the trigger in place, for a nav bar. palette travels to the middle of the screen over a dimmed page, for a command menu. |
suggestions | MorphSearchItem[] | None | Shown before anything is typed. Defaults to items. |
placeholder | string | "Search…" | Placeholder for the field. Also its accessible name. |
hotkey | string | None | "k" opens and closes it with ⌘K (Ctrl+K elsewhere). "/" opens it with a bare slash. |
width | number | 360 (inline), 560 (palette) | Width of the open field, in pixels. Never wider than the screen. |
align | "start" | "end" | None | Inline only: which edge of the trigger the field grows from. Defaults to the side with more room. |
filter | (item: MorphSearchItem, query: string) => number | None | Scores an item against the query. 0 hides it; higher ranks first. The default matches every word and ranks labels that start with the query first. |
onQueryChange | (query: string) => void | None | Called as the query changes. For server results, update items from here and pass a filter that returns 1. |
emptyMessage | (query: string) => React.ReactNode | None | Shown when nothing matches. |
footer | boolean | true for palette | Keyboard hints under the results. |
children | React.ReactNode | None | Custom trigger content, such as a fake search field. Use MorphSearchIcon for its magnifier so it slides into the open field. |
className | string | None | Classes for the trigger button. Its look and corner radius carry into the morph. |
surfaceClassName | string | None | Classes for the open surface's background. Defaults to bg-background. |
itemsRequiredTypeMorphSearchItem[]
What to search. Each item has an id and label, and optionally a description, group, icon, shortcut (keys shown on the right) and keywords.
onSelectType(item: MorphSearchItem) => void
Called when a result is picked with Enter or a click. The search closes after.
variantType"inline" | "palette"Default"inline"
inline grows from the trigger in place, for a nav bar. palette travels to the middle of the screen over a dimmed page, for a command menu.
suggestionsTypeMorphSearchItem[]
Shown before anything is typed. Defaults to items.
placeholderTypestringDefault"Search…"
Placeholder for the field. Also its accessible name.
hotkeyTypestring
"k" opens and closes it with ⌘K (Ctrl+K elsewhere). "/" opens it with a bare slash.
widthTypenumberDefault360 (inline), 560 (palette)
Width of the open field, in pixels. Never wider than the screen.
alignType"start" | "end"
Inline only: which edge of the trigger the field grows from. Defaults to the side with more room.
filterType(item: MorphSearchItem, query: string) => number
Scores an item against the query. 0 hides it; higher ranks first. The default matches every word and ranks labels that start with the query first.
onQueryChangeType(query: string) => void
Called as the query changes. For server results, update items from here and pass a filter that returns 1.
emptyMessageType(query: string) => React.ReactNode
Shown when nothing matches.
footerTypebooleanDefaulttrue for palette
Keyboard hints under the results.
childrenTypeReact.ReactNode
Custom trigger content, such as a fake search field. Use MorphSearchIcon for its magnifier so it slides into the open field.
classNameTypestring
Classes for the trigger button. Its look and corner radius carry into the morph.
surfaceClassNameTypestring
Classes for the open surface's background. Defaults to bg-background.