/Navigation
Navigation • React
Blur Glide Menu
A nested menu whose steps blur before they glide aside, while the surface animates to the height of the step arriving.
Installation
Requires lucide-react for its icons. The shadcn CLI installs it for you; copying the code by hand means installing it yourself.
Terminal
npx shadcn@latest add https://microkit.co/r/blur-glide-menu.jsonCode
1"use client";23import { useEffect, useRef, useState } from "react";4import type { KeyboardEvent as ReactKeyboardEvent, RefObject } from "react";5import { Archive, ArrowLeft, ChevronDown, ChevronRight, Copy, FolderInput, Globe, Inbox, Layers, Palette, Tag, UserPlus } from "lucide-react";6import type { LucideIcon } from "lucide-react";78type MenuRow = {9id: string;10label: string;11icon?: LucideIcon;12initials?: string;13tint?: string;14dot?: string;15hint?: string;16panel?: MenuPanel;17};1819type MenuPanel = { search?: string; rows: MenuRow[] };2021const rootPanel: MenuPanel = {22rows: [23{24id: "invite",25label: "Invite people",26icon: UserPlus,27hint: "@",28panel: {29search: "Find people",30rows: [31{ id: "priya", label: "Priya Raman", initials: "PR", tint: "#a8c7ff" },32{ id: "noah", label: "Noah Whitfield", initials: "NW", tint: "#ffd6a5" },33{ id: "ines", label: "Ines Duarte", initials: "ID", tint: "#b9f0d0" },34],35},36},37{38id: "move",39label: "Move to",40icon: FolderInput,41hint: "M",42panel: {43search: "Find a space",44rows: [45{46id: "roadmap",47label: "Roadmap",48icon: Layers,49panel: {50search: "Find a board",51rows: [52{ id: "quarter", label: "Quarter planning", initials: "QP", tint: "#a8c7ff" },53{ id: "bets", label: "Platform bets", initials: "PB", tint: "#d9c2ff" },54{ id: "hiring", label: "Hiring plan", initials: "HP", tint: "#ffd6a5" },55{ id: "launch", label: "Launch checklist", initials: "LC", tint: "#b9f0d0" },56],57},58},59{60id: "system",61label: "Design system",62icon: Palette,63panel: {64search: "Find a board",65rows: [66{ id: "tokens", label: "Colour tokens", initials: "CT", tint: "#ffc9d4" },67{ id: "specs", label: "Component specs", initials: "CS", tint: "#a8c7ff" },68{ id: "icons", label: "Icon set", initials: "IS", tint: "#b9f0d0" },69],70},71},72{ id: "site", label: "Marketing site", icon: Globe },73{ id: "support", label: "Support inbox", icon: Inbox },74],75},76},77{78id: "label",79label: "Set label",80icon: Tag,81hint: "L",82panel: {83rows: [84{ id: "priority", label: "Priority", dot: "#ff9c9c" },85{ id: "blocked", label: "Blocked", dot: "#ffd6a5" },86{ id: "research", label: "Research", dot: "#a8c7ff" },87{ id: "shipped", label: "Shipped", dot: "#b9f0d0" },88],89},90},91{ id: "duplicate", label: "Duplicate", icon: Copy, hint: "⌘D" },92{ id: "archive", label: "Archive", icon: Archive },93],94};9596const ROW_HEIGHT = 26;97const PANEL_MS = 420;98const CLOSE_MS = 240;99100const panelKeyframes = `101@keyframes glide-menu-in {1020% { opacity: 0; transform: translateX(100%); filter: blur(8px); }10345% { opacity: .9; }10465% { filter: blur(6.5px); }105100% { opacity: 1; transform: translateX(0); filter: blur(0); }106}107@keyframes glide-menu-out {1080% { opacity: 1; transform: translateX(0); filter: blur(0); }10930% { opacity: .9; filter: blur(6px); }110100% { opacity: 0; transform: translateX(-100%); filter: blur(9px); }111}112@keyframes glide-menu-in-back {1130% { opacity: 0; transform: translateX(-100%); filter: blur(8px); }11445% { opacity: .9; }11565% { filter: blur(6.5px); }116100% { opacity: 1; transform: translateX(0); filter: blur(0); }117}118@keyframes glide-menu-out-back {1190% { opacity: 1; transform: translateX(0); filter: blur(0); }12030% { opacity: .9; filter: blur(6px); }121100% { opacity: 0; transform: translateX(100%); filter: blur(9px); }122}`;123124const phaseClass = {125idle: "",126in: "[animation:glide-menu-in_.4s_cubic-bezier(.45,.05,.15,1)_both] motion-reduce:[animation-duration:.01ms]",127"in-back": "[animation:glide-menu-in-back_.4s_cubic-bezier(.45,.05,.15,1)_both] motion-reduce:[animation-duration:.01ms]",128out: "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",129"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",130};131132function stepAt(trail: string[]) {133let panel = rootPanel;134let title = "";135for (const id of trail) {136const row = panel.rows.find((candidate) => candidate.id === id);137if (!row?.panel) break;138panel = row.panel;139title = row.label;140}141return { panel, title };142}143144function matches(panel: MenuPanel, query: string) {145const term = query.trim().toLowerCase();146return term ? panel.rows.filter((row) => row.label.toLowerCase().includes(term)) : panel.rows;147}148149function MenuPanel({150panel,151title,152phase,153query,154panelRef,155searchRef,156onQuery,157onSelect,158onBack,159}: {160panel: MenuPanel;161title: string;162phase: keyof typeof phaseClass;163query: string;164panelRef?: RefObject<HTMLDivElement | null>;165searchRef?: RefObject<HTMLInputElement | null>;166onQuery?: (value: string) => void;167onSelect?: (row: MenuRow) => void;168onBack?: () => void;169}) {170const [cursor, setCursor] = useState({ index: 0, visible: false });171const rows = matches(panel, query);172const leaving = phase === "out" || phase === "out-back";173174return (175<div className={`w-full ${phaseClass[phase]}`} ref={panelRef} inert={leaving || undefined}>176{title && (177<>178<div className="flex h-[26px] items-center gap-[5px] pr-[5px]">179<button180type="button"181className="grid size-5 flex-none place-items-center rounded-[5px] border-0 bg-transparent text-[#868d97] [transition:background-color_.18s_ease,color_.18s_ease] hover:bg-[#22262c] hover:text-[#dfe2e5] focus-visible:outline focus-visible:outline-[1.5px] focus-visible:outline-offset-0 focus-visible:outline-[#86b6ff] motion-reduce:[transition-duration:.01ms]"182onClick={onBack}183aria-label={`Back from ${title}`}184>185<ArrowLeft size={13} strokeWidth={1.8} aria-hidden="true" />186</button>187{panel.search ? (188<input189ref={searchRef}190className="min-w-0 flex-1 truncate border-0 bg-transparent p-0 text-[11px] text-[#dfe2e5] placeholder:text-[#6d747e] focus:outline-none focus-visible:outline-none"191data-search=""192type="text"193value={query}194placeholder={panel.search}195aria-label={panel.search}196onChange={(event) => onQuery?.(event.target.value)}197/>198) : (199<span className="min-w-0 flex-1 truncate text-[11px] text-[#dfe2e5]">{title}</span>200)}201</div>202<div className="my-[3px] h-px bg-[#23262c]" />203</>204)}205<div206className="relative"207role="menu"208onMouseLeave={() => setCursor((state) => ({ ...state, visible: false }))}209>210<span211className={`pointer-events-none absolute inset-x-0 top-0 h-[26px] rounded-[5px] bg-[#22262c] [transition:transform_.3s_cubic-bezier(.32,.72,0,1),opacity_.18s_ease] motion-reduce:[transition-duration:.01ms] ${cursor.visible ? "opacity-100" : "opacity-0"}`}212style={{ transform: `translateY(${cursor.index * ROW_HEIGHT}px)` }}213aria-hidden="true"214/>215{rows.map((row, index) => (216<button217key={row.id}218type="button"219role="menuitem"220className="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-[#c6cbd2] [transition:color_.18s_ease] hover:text-[#f4f5f7] focus-visible:text-[#f4f5f7] focus-visible:outline focus-visible:outline-[1.5px] focus-visible:outline-offset-[-1.5px] focus-visible:outline-[#86b6ff] motion-reduce:[transition-duration:.01ms]"221data-row=""222aria-haspopup={row.panel ? "menu" : undefined}223onMouseEnter={() => setCursor({ index, visible: true })}224onFocus={() => setCursor({ index, visible: true })}225onClick={() => onSelect?.(row)}226onKeyDown={(event) => {227if (event.key === "ArrowRight" && row.panel) {228event.preventDefault();229onSelect?.(row);230}231}}232>233{row.icon && (234<span className="grid size-[14px] flex-none place-items-center text-[#868d97] [transition:color_.18s_ease] group-hover:text-[#c6cbd2] group-focus-visible:text-[#c6cbd2] motion-reduce:[transition-duration:.01ms]">235<row.icon size={14} strokeWidth={1.7} aria-hidden="true" />236</span>237)}238{row.initials && (239<span240className="grid size-[18px] flex-none place-items-center rounded-md text-[9px] font-semibold text-[#15171a]"241style={{ background: row.tint }}242aria-hidden="true"243>244{row.initials}245</span>246)}247{row.dot && (248<span249className="mx-[3px] size-2 flex-none rounded-full"250style={{ background: row.dot }}251aria-hidden="true"252/>253)}254<span className="min-w-0 flex-1 truncate">{row.label}</span>255{row.hint && (256<span className="flex-none font-mono text-[10px] text-[#6b727c]">{row.hint}</span>257)}258{row.panel && (259<ChevronRight260className="flex-none text-[#5c636d] [transition:transform_.24s_cubic-bezier(.32,.72,0,1),color_.18s_ease] group-hover:text-[#868d97] group-hover:[transform:translateX(2px)] group-focus-visible:text-[#868d97] group-focus-visible:[transform:translateX(2px)] motion-reduce:[transition-duration:.01ms]"261size={13}262strokeWidth={1.8}263aria-hidden="true"264/>265)}266</button>267))}268{!rows.length && (269<p className="m-0 flex h-[26px] items-center px-[7px] text-[11px] text-[#6d747e]">No matches</p>270)}271</div>272</div>273);274}275276export function BlurGlideMenu() {277const [open, setOpen] = useState(true);278const [nav, setNav] = useState<{ trail: string[]; direction: -1 | 0 | 1 }>({ trail: [], direction: 0 });279const [query, setQuery] = useState("");280const [leaving, setLeaving] = useState<{ trail: string[]; query: string; direction: -1 | 1 } | null>(null);281const [height, setHeight] = useState<number>();282const rootRef = useRef<HTMLDivElement>(null);283const activeRef = useRef<HTMLDivElement>(null);284const triggerRef = useRef<HTMLButtonElement>(null);285const searchRef = useRef<HTMLInputElement>(null);286287const step = stepAt(nav.trail);288289useEffect(() => {290if (activeRef.current) setHeight(activeRef.current.offsetHeight);291}, [nav, query, open]);292293useEffect(() => {294if (!leaving) return;295const timer = setTimeout(() => setLeaving(null), PANEL_MS);296return () => clearTimeout(timer);297}, [leaving]);298299useEffect(() => {300if (open) return;301const timer = setTimeout(() => {302setNav({ trail: [], direction: 0 });303setQuery("");304setLeaving(null);305}, CLOSE_MS);306return () => clearTimeout(timer);307}, [open]);308309useEffect(() => {310if (!open) return;311const region = rootRef.current?.parentElement;312if (!region) return;313const handlePointerDown = (event: Event) => {314if (!rootRef.current?.contains(event.target as Node)) setOpen(false);315};316region.addEventListener("pointerdown", handlePointerDown);317return () => region.removeEventListener("pointerdown", handlePointerDown);318}, [open]);319320useEffect(() => {321if (nav.direction === 0) return;322const target = searchRef.current ?? activeRef.current?.querySelector<HTMLButtonElement>("[data-row]");323target?.focus({ preventScroll: true });324}, [nav]);325326const enter = (id: string) => {327setLeaving({ trail: nav.trail, query, direction: 1 });328setNav({ trail: [...nav.trail, id], direction: 1 });329setQuery("");330};331332const back = () => {333if (!nav.trail.length) return;334setLeaving({ trail: nav.trail, query, direction: -1 });335setNav({ trail: nav.trail.slice(0, -1), direction: -1 });336setQuery("");337};338339const dismiss = () => {340setOpen(false);341triggerRef.current?.focus({ preventScroll: true });342};343344const moveFocus = (delta: number) => {345const rows = Array.from(activeRef.current?.querySelectorAll<HTMLButtonElement>("[data-row]") ?? []);346if (!rows.length) return;347const index = rows.indexOf(document.activeElement as HTMLButtonElement);348const next = index < 0 ? (delta > 0 ? 0 : rows.length - 1) : (index + delta + rows.length) % rows.length;349rows[next].focus({ preventScroll: true });350};351352const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {353const inSearch = (event.target as HTMLElement).dataset.search !== undefined;354if (event.key === "ArrowDown" || event.key === "ArrowUp") {355event.preventDefault();356if (!open) {357setOpen(true);358return;359}360moveFocus(event.key === "ArrowDown" ? 1 : -1);361return;362}363if (!open) return;364if (event.key === "Escape") {365event.preventDefault();366if (nav.trail.length) back();367else dismiss();368return;369}370if (event.key === "ArrowLeft" && !inSearch && nav.trail.length) {371event.preventDefault();372back();373}374};375376return (377<div378className="relative h-[182px] w-[214px] text-center text-[11px] text-[#dfe2e5]"379ref={rootRef}380onKeyDown={handleKeyDown}381>382<style>{panelKeyframes}</style>383<button384type="button"385className="inline-flex h-[26px] items-center gap-[7px] rounded-md border border-[#2f333a] bg-[#16181c] pl-[10px] pr-2 text-[11px] text-[#dfe2e5] [transition:border-color_.2s_ease,background-color_.2s_ease] hover:border-[#3d434c] hover:bg-[#1b1e23] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#86b6ff] motion-reduce:[transition-duration:.01ms]"386ref={triggerRef}387aria-haspopup="menu"388aria-expanded={open}389onClick={() => (open ? dismiss() : setOpen(true))}390>391Board actions392<ChevronDown393className={`text-[#868d97] [transition:transform_.34s_cubic-bezier(.32,.72,0,1)] motion-reduce:[transition-duration:.01ms] ${open ? "[transform:rotate(180deg)]" : ""}`}394size={13}395strokeWidth={1.8}396aria-hidden="true"397/>398</button>399<div400className={`absolute left-0 top-[33px] z-[1] w-full origin-top rounded-[9px] border border-[#2b2f36] bg-[#121418] p-[5px] text-left shadow-[inset_0_1px_0_rgba(255,255,255,.04),0_16px_34px_rgba(0,0,0,.45)] ${401open402? "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]"403: "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]"404} motion-reduce:[transition-duration:.01ms]`}405>406<div407className="relative overflow-hidden [transition:height_.4s_cubic-bezier(.45,.05,.15,1)] motion-reduce:[transition-duration:.01ms]"408style={{ height }}409>410{leaving && (411<MenuPanel412key={leaving.trail.join("/") || "root"}413{...stepAt(leaving.trail)}414phase={leaving.direction === 1 ? "out" : "out-back"}415query={leaving.query}416/>417)}418<MenuPanel419key={nav.trail.join("/") || "root"}420{...step}421phase={nav.direction === 0 ? "idle" : nav.direction === 1 ? "in" : "in-back"}422query={query}423panelRef={activeRef}424searchRef={step.panel.search ? searchRef : undefined}425onQuery={setQuery}426onBack={back}427onSelect={(row) => (row.panel ? enter(row.id) : dismiss())}428/>429</div>430</div>431</div>432);433}