microkit
componentblur-glide-menusource: ok · hit
Blur Glide Menu
A nested menu whose steps blur before they glide aside, while the surface animates to the height of the step arriving.
npx shadcn add "https://microkit.co/r/blur-glide-menu.json"free
Where it goes
onboarding
Visual style
glass-blur
Interaction
dropdown
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Files1 · source cached 10/15/2026, 9:36:26 AM
registry/microkit/blur-glide-menu.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent, RefObject } from "react";
import { Archive, ArrowLeft, ChevronDown, ChevronRight, Copy, FolderInput, Globe, Inbox, Layers, Palette, Tag, UserPlus } from "lucide-react";
import type { LucideIcon } from "lucide-react";
type MenuRow = {
id: string;
label: string;
icon?: LucideIcon;
initials?: string;
tint?: string;
dot?: string;
hint?: string;
panel?: MenuPanel;
};
type MenuPanel = { search?: string; rows: MenuRow[] };
const rootPanel: MenuPanel = {
rows: [
{
id: "invite",
label: "Invite people",
icon: UserPlus,
hint: "@",
panel: {
search: "Find people",
rows: [
{ id: "priya", label: "Priya Raman", initials: "PR", tint: "#a8c7ff" },
{ id: "noah", label: "Noah Whitfield", initials: "NW", tint: "#ffd6a5" },
{ id: "ines", label: "Ines Duarte", initials: "ID", tint: "#b9f0d0" },
],
},
},
{
id: "move",
label: "Move to",
icon: FolderInput,
hint: "M",
panel: {
search: "Find a space",
rows: [
{
id: "roadmap",
label: "Roadmap",
icon: Layers,
panel: {
search: "Find a board",
rows: [
{ id: "quarter", label: "Quarter planning", initials: "QP", tint: "#a8c7ff" },
{ id: "bets", label: "Platform bets", initials: "PB", tint: "#d9c2ff" },
{ id: "hiring", label: "Hiring plan", initials: "HP", tint: "#ffd6a5" },
{ id: "launch", label: "Launch checklist", initials: "LC", tint: "#b9f0d0" },
],
},
},
{
id: "system",
label: "Design system",
icon: Palette,
panel: {
search: "Find a board",
rows: [
{ id: "tokens", label: "Colour tokens", initials: "CT", tint: "#ffc9d4" },
{ id: "specs", label: "Component specs", initials: "CS", tint: "#a8c7ff" },
{ id: "icons", label: "Icon set", initials: "IS", tint: "#b9f0d0" },
],
},
},
{ id: "site", label: "Marketing site", icon: Globe },
{ id: "support", label: "Support inbox", icon: Inbox },
],
},
},
{
id: "label",
label: "Set label",
icon: Tag,
hint: "L",
panel: {
rows: [
{ id: "priority", label: "Priority", dot: "#ff9c9c" },
{ id: "blocked", label: "Blocked", dot: "#ffd6a5" },
{ id: "research", label: "Research", dot: "#a8c7ff" },
{ id: "shipped", label: "Shipped", dot: "#b9f0d0" },
],
},
},
{ id: "duplicate", label: "Duplicate", icon: Copy, hint: "⌘D" },
{ id: "archive", label: "Archive", icon: Archive },
],
};
const ROW_HEIGHT = 26;
const PANEL_MS = 420;
const CLOSE_MS = 240;
const panelKeyframes = `
@keyframes glide-menu-in {
0% { opacity: 0; transform: translateX(100%); filter: blur(8px); }
45% { opacity: .9; }
65% { filter: blur(6.5px); }
100% { opacity: 1; transform: translateX(0); filter: blur(0); }
}
@keyframes glide-menu-out {
0% { opacity: 1; transform: translateX(0); filter: blur(0); }
30% { opacity: .9; filter: blur(6px); }
100% { opacity: 0; transform: translateX(-100%); filter: blur(9px); }
}
@keyframes glide-menu-in-back {
0% { opacity: 0; transform: translateX(-100%); filter: blur(8px); }
45% { opacity: .9; }
65% { filter: blur(6.5px); }
100% { opacity: 1; transform: translateX(0); filter: blur(0); }
}
@keyframes glide-menu-out-back {
0% { opacity: 1; transform: translateX(0); filter: blur(0); }
30% { opacity: .9; filter: blur(6px); }
100% { opacity: 0; transform: translateX(100%); filter: blur(9px); }
}`;
const phaseClass = {
idle: "",
in: "[animation:glide-menu-in_.4s_cubic-bezier(.45,.05,.15,1)_both] motion-reduce:[animation-duration:.01ms]",
"in-back": "[animation:glide-menu-in-back_.4s_cubic-bezier(.45,.05,.15,1)_both] motion-reduce:[animation-duration:.01ms]",
out: "absolute inset-x-0 top-0 [animation:glide-menu-out_.4s_cubic-bezier(.45,.05,.15,1)_both] motion-reduce:animate-none motion-reduce:opacity-0",
"out-back": "absolute inset-x-0 top-0 [animation:glide-menu-out-back_.4s_cubic-bezier(.45,.05,.15,1)_both] motion-reduce:animate-none motion-reduce:opacity-0",
};
function stepAt(trail: string[]) {
let panel = rootPanel;
let title = "";
for (const id of trail) {
const row = panel.rows.find((candidate) => candidate.id === id);
if (!row?.panel) break;
panel = row.panel;
title = row.label;
}
return { panel, title };
}
function matches(panel: MenuPanel, query: string) {
const term = query.trim().toLowerCase();
return term ? panel.rows.filter((row) => row.label.toLowerCase().includes(term)) : panel.rows;
}
function MenuPanel({
panel,
title,
phase,
query,
panelRef,
searchRef,
onQuery,
onSelect,
onBack,
}: {
panel: MenuPanel;
title: string;
phase: keyof typeof phaseClass;
query: string;
panelRef?: RefObject<HTMLDivElement | null>;
searchRef?: RefObject<HTMLInputElement | null>;
onQuery?: (value: string) => void;
onSelect?: (row: MenuRow) => void;
onBack?: () => void;
}) {
const [cursor, setCursor] = useState({ index: 0, visible: false });
const rows = matches(panel, query);
const leaving = phase === "out" || phase === "out-back";
return (
<div className={`w-full ${phaseClass[phase]}`} ref={panelRef} inert={leaving || undefined}>
{title && (
<>
<div className="flex h-[26px] items-center gap-[5px] pr-[5px]">
<button
type="button"
className="grid size-5 flex-none place-items-center rounded-[5px] border-0 bg-transparent text-[light-dark(#69727f,#868d97)] [transition:background-color_.18s_ease,color_.18s_ease] hover:bg-[light-dark(#e7eaef,#22262c)] hover:text-[light-dark(#262d38,#dfe2e5)] focus-visible:outline focus-visible:outline-[1.5px] focus-visible:outline-offset-0 focus-visible:outline-[#86b6ff] motion-reduce:[transition-duration:.01ms]"
onClick={onBack}
aria-label={`Back from ${title}`}
>
<ArrowLeft size={13} strokeWidth={1.8} aria-hidden="true" />
</button>
{panel.search ? (
<input
ref={searchRef}
className="min-w-0 flex-1 truncate border-0 bg-transparent p-0 text-[11px] text-[light-dark(#262d38,#dfe2e5)] placeholder:text-[light-dark(#868f9c,#6d747e)] focus:outline-none focus-visible:outline-none"
data-search=""
type="text"
value={query}
placeholder={panel.search}
aria-label={panel.search}
onChange={(event) => onQuery?.(event.target.value)}
/>
) : (
<span className="min-w-0 flex-1 truncate text-[11px] text-[light-dark(#262d38,#dfe2e5)]">{title}</span>
)}
</div>
<div className="my-[3px] h-px bg-[light-dark(#e7eaef,#23262c)]" />
</>
)}
<div
className="relative"
role="menu"
onMouseLeave={() => setCursor((state) => ({ ...state, visible: false }))}
>
<span
className={`pointer-events-none absolute inset-x-0 top-0 h-[26px] rounded-[5px] bg-[light-dark(#e7eaef,#22262c)] [transition:transform_.3s_cubic-bezier(.32,.72,0,1),opacity_.18s_ease] motion-reduce:[transition-duration:.01ms] ${cursor.visible ? "opacity-100" : "opacity-0"}`}
style={{ transform: `translateY(${cursor.index * ROW_HEIGHT}px)` }}
aria-hidden="true"
/>
{rows.map((row, index) => (
<button
key={row.id}
type="button"
role="menuitem"
className="group relative z-[1] flex h-[26px] w-full items-center gap-2 rounded-[5px] border-0 bg-transparent px-[7px] text-left text-[11px] text-[light-dark(#3b434f,#c6cbd2)] [transition:color_.18s_ease] hover:text-[light-dark(#14181e,#f4f5f7)] focus-visible:text-[light-dark(#14181e,#f4f5f7)] focus-visible:outline focus-visible:outline-[1.5px] focus-visible:outline-offset-[-1.5px] focus-visible:outline-[#86b6ff] motion-reduce:[transition-duration:.01ms]"
data-row=""
aria-haspopup={row.panel ? "menu" : undefined}
onMouseEnter={() => setCursor({ index, visible: true })}
onFocus={() => setCursor({ index, visible: true })}
onClick={() => onSelect?.(row)}
onKeyDown={(event) => {
if (event.key === "ArrowRight" && row.panel) {
event.preventDefault();
onSelect?.(row);
}
}}
>
{row.icon && (
<span className="grid size-[14px] flex-none place-items-center text-[light-dark(#69727f,#868d97)] [transition:color_.18s_ease] group-hover:text-[light-dark(#3b434f,#c6cbd2)] group-focus-visible:text-[light-dark(#3b434f,#c6cbd2)] motion-reduce:[transition-duration:.01ms]">
<row.icon size={14} strokeWidth={1.7} aria-hidden="true" />
</span>
)}
{row.initials && (
<span
className="grid size-[18px] flex-none place-items-center rounded-md text-[9px] font-semibold text-[light-dark(#ffffff,#15171a)]"
style={{ background: row.tint }}
aria-hidden="true"
>
{row.initials}
</span>
)}
{row.dot && (
<span
className="mx-[3px] size-2 flex-none rounded-full"
style={{ background: row.dot }}
aria-hidden="true"
/>
)}
<span className="min-w-0 flex-1 truncate">{row.label}</span>
{row.hint && (
<span className="flex-none font-mono text-[10px] text-[light-dark(#868f9c,#6b727c)]">{row.hint}</span>
)}
{row.panel && (
<ChevronRight
className="flex-none text-[light-dark(#a2aab6,#5c636d)] [transition:transform_.24s_cubic-bezier(.32,.72,0,1),color_.18s_ease] group-hover:text-[light-dark(#69727f,#868d97)] group-hover:[transform:translateX(2px)] group-focus-visible:text-[light-dark(#69727f,#868d97)] group-focus-visible:[transform:translateX(2px)] motion-reduce:[transition-duration:.01ms]"
size={13}
strokeWidth={1.8}
aria-hidden="true"
/>
)}
</button>
))}
{!rows.length && (
<p className="m-0 flex h-[26px] items-center px-[7px] text-[11px] text-[light-dark(#868f9c,#6d747e)]">No matches</p>
)}
</div>
</div>
);
}
export function BlurGlideMenu() {
const [open, setOpen] = useState(true);
const [nav, setNav] = useState<{ trail: string[]; direction: -1 | 0 | 1 }>({ trail: [], direction: 0 });
const [query, setQuery] = useState("");
const [leaving, setLeaving] = useState<{ trail: string[]; query: string; direction: -1 | 1 } | null>(null);
const [height, setHeight] = useState<number>();
const rootRef = useRef<HTMLDivElement>(null);
const activeRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const step = stepAt(nav.trail);
useEffect(() => {
if (activeRef.current) setHeight(activeRef.current.offsetHeight);
}, [nav, query, open]);
useEffect(() => {
if (!leaving) return;
const timer = setTimeout(() => setLeaving(null), PANEL_MS);
return () => clearTimeout(timer);
}, [leaving]);
useEffect(() => {
if (open) return;
const timer = setTimeout(() => {
setNav({ trail: [], direction: 0 });
setQuery("");
setLeaving(null);
}, CLOSE_MS);
return () => clearTimeout(timer);
}, [open]);
useEffect(() => {
if (!open) return;
const region = rootRef.current?.parentElement;
if (!region) return;
const handlePointerDown = (event: Event) => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false);
};
region.addEventListener("pointerdown", handlePointerDown);
return () => region.removeEventListener("pointerdown", handlePointerDown);
}, [open]);
useEffect(() => {
if (nav.direction === 0) return;
const target = searchRef.current ?? activeRef.current?.querySelector<HTMLButtonElement>("[data-row]");
target?.focus({ preventScroll: true });
}, [nav]);
const enter = (id: string) => {
setLeaving({ trail: nav.trail, query, direction: 1 });
setNav({ trail: [...nav.trail, id], direction: 1 });
setQuery("");
};
const back = () => {
if (!nav.trail.length) return;
setLeaving({ trail: nav.trail, query, direction: -1 });
setNav({ trail: nav.trail.slice(0, -1), direction: -1 });
setQuery("");
};
const dismiss = () => {
setOpen(false);
triggerRef.current?.focus({ preventScroll: true });
};
const moveFocus = (delta: number) => {
const rows = Array.from(activeRef.current?.querySelectorAll<HTMLButtonElement>("[data-row]") ?? []);
if (!rows.length) return;
const index = rows.indexOf(document.activeElement as HTMLButtonElement);
const next = index < 0 ? (delta > 0 ? 0 : rows.length - 1) : (index + delta + rows.length) % rows.length;
rows[next].focus({ preventScroll: true });
};
const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
const inSearch = (event.target as HTMLElement).dataset.search !== undefined;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (!open) {
setOpen(true);
return;
}
moveFocus(event.key === "ArrowDown" ? 1 : -1);
return;
}
if (!open) return;
if (event.key === "Escape") {
event.preventDefault();
if (nav.trail.length) back();
else dismiss();
return;
}
if (event.key === "ArrowLeft" && !inSearch && nav.trail.length) {
event.preventDefault();
back();
}
};
return (
<div
className="relative h-[182px] w-[214px] text-center text-[11px] text-[light-dark(#262d38,#dfe2e5)]"
ref={rootRef}
onKeyDown={handleKeyDown}
>
<style>{panelKeyframes}</style>
<button
type="button"
className="inline-flex h-[26px] items-center gap-[7px] rounded-md border border-[light-dark(#dfe3ea,#2f333a)] bg-[light-dark(#ffffff,#16181c)] pl-[10px] pr-2 text-[11px] text-[light-dark(#262d38,#dfe2e5)] [transition:border-color_.2s_ease,background-color_.2s_ease] hover:border-[light-dark(#adb6c3,#3d434c)] hover:bg-[light-dark(#f0f2f5,#1b1e23)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#86b6ff] motion-reduce:[transition-duration:.01ms]"
ref={triggerRef}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => (open ? dismiss() : setOpen(true))}
>
Board actions
<ChevronDown
className={`text-[light-dark(#69727f,#868d97)] [transition:transform_.34s_cubic-bezier(.32,.72,0,1)] motion-reduce:[transition-duration:.01ms] ${open ? "[transform:rotate(180deg)]" : ""}`}
size={13}
strokeWidth={1.8}
aria-hidden="true"
/>
</button>
<div
className={`absolute left-0 top-[33px] z-[1] w-full origin-top rounded-[9px] border border-[light-dark(#dfe3ea,#2b2f36)] bg-[light-dark(#ffffff,#121418)] p-[5px] text-left shadow-[inset_0_1px_0_rgba(255,255,255,.04),0_16px_34px_rgba(0,0,0,.45)] ${
open
? "visible opacity-100 [filter:blur(0px)] [transform:none] [transition:opacity_.22s_ease,transform_.36s_cubic-bezier(.32,.72,0,1),filter_.28s_ease,visibility_0s]"
: "invisible opacity-0 blur-[5px] [transform:translateY(-6px)_scale(.97)] [transition:opacity_.18s_ease,transform_.26s_cubic-bezier(.32,.72,0,1),filter_.22s_ease,visibility_0s_linear_.26s]"
} motion-reduce:[transition-duration:.01ms]`}
>
<div
className="relative overflow-hidden [transition:height_.4s_cubic-bezier(.45,.05,.15,1)] motion-reduce:[transition-duration:.01ms]"
style={{ height }}
>
{leaving && (
<MenuPanel
key={leaving.trail.join("/") || "root"}
{...stepAt(leaving.trail)}
phase={leaving.direction === 1 ? "out" : "out-back"}
query={leaving.query}
/>
)}
<MenuPanel
key={nav.trail.join("/") || "root"}
{...step}
phase={nav.direction === 0 ? "idle" : nav.direction === 1 ? "in" : "in-back"}
query={query}
panelRef={activeRef}
searchRef={step.panel.search ? searchRef : undefined}
onQuery={setQuery}
onBack={back}
onSelect={(row) => (row.panel ? enter(row.id) : dismiss())}
/>
</div>
</div>
</div>
);
}