Menubar
File, Edit, View on a glass bar — once one is open, pointing at a neighbour switches.
The idea
It behaves the way a desktop menubar does, because that is the one place a reader already knows the rules: with a menu open, pointing at a neighbour switches to it without another click, and Left and Right walk from menu to menu, each opening as you arrive. The bar is a single tab stop. The tile behind the open title is one element sliding along the bar — the same tile as the rows inside — so switching menus reads as one thing moving sideways.
Composes DropdownMenu and Kbd.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { Menubar } from "mihirmodi-ui";
// Each menu takes the same entries as DropdownMenu.
<Menubar
aria-label="Editor"
menus={[
{ label: "File", items: [{ label: "New tab", shortcut: ["⌘", "T"], onSelect: newTab }] },
{ label: "Edit", items: [{ label: "Undo", shortcut: ["⌘", "Z"], onSelect: undo }] },
{
label: "View",
items: [{ type: "checkbox", label: "Full URLs", checked: urls, onCheckedChange: setUrls }],
},
]}
/>Props
| Prop | Type | Notes |
|---|---|---|
| menus{ label, items: MenuEntry[] }[] | { label, items: MenuEntry[] }[] | Each menu takes the same entries as DropdownMenu. |
| aria-labelstring | string= "Menu bar" | The bar's name. |
| classNamestring | string | On the bar. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import { useId, useRef, useState, type KeyboardEvent } from "react";
import { Floating, focusQuietly } from "./layer.js";
import { MenuPanel, RowHighlight, useRowRect, type MenuEntry } from "./menu.js";
export type MenubarMenu = { label: string; items: MenuEntry[] };
export type MenubarProps = {
menus: MenubarMenu[];
/** The bar's accessible name. */
"aria-label"?: string;
className?: string;
};
/**
* A row of menus — File, Edit, View — on a glass bar, each opening the same
* menu body as DropdownMenu.
*
* It behaves the way a desktop menubar does, because that is the one place a
* reader already knows the rules: once a menu is open, pointing at a neighbour
* switches to it without another click, and Left and Right walk from menu to
* menu with each one opening as you arrive. The bar is a single tab stop; the
* arrows move along it.
*
* The tile behind the open (or pointed-at) title is one element that slides
* along the bar, the same tile the rows inside use, so switching menus reads
* as one thing moving sideways rather than one title going dark and another
* lighting up.
*/
export function Menubar({ menus, "aria-label": ariaLabel = "Menu bar", className = "" }: MenubarProps) {
const uid = useId();
const [open, setOpen] = useState<number | null>(null);
const [focus, setFocus] = useState<"first" | "last" | "none">("none");
const [current, setCurrent] = useState(0);
const [hover, setHover] = useState<number | null>(null);
const bar = useRef<HTMLDivElement>(null);
const btns = useRef<(HTMLButtonElement | null)[]>([]);
const lit = open ?? hover;
const rect = useRowRect(() => (lit === null ? null : btns.current[lit]), [lit, menus.length]);
const n = menus.length;
const show = (i: number, f: "first" | "last" | "none") => {
setFocus(f);
setOpen(i);
setCurrent(i);
};
const close = (refocus: boolean) => {
const i = open;
setOpen(null);
if (refocus && i !== null) focusQuietly(btns.current[i]);
};
const horizontal = (d: 1 | -1) => {
if (open === null) return;
show((open + d + n) % n, "first");
};
const onKey = (e: KeyboardEvent, i: number) => {
let handled = true;
const go = (j: number) => {
setCurrent(j);
btns.current[j]?.focus();
};
switch (e.key) {
case "ArrowRight":
go((i + 1) % n);
break;
case "ArrowLeft":
go((i - 1 + n) % n);
break;
case "Home":
go(0);
break;
case "End":
go(n - 1);
break;
case "ArrowDown":
case "Enter":
case " ":
show(i, "first");
break;
case "ArrowUp":
show(i, "last");
break;
default:
handled = false;
}
if (handled) {
e.preventDefault();
e.stopPropagation();
}
};
return (
<div
ref={bar}
role="menubar"
aria-label={ariaLabel}
onPointerLeave={() => setHover(null)}
className={`glass-nav dock-shadow relative inline-flex items-center gap-0.5 rounded-[12px] border-[0.5px] border-gray-alpha-400 p-1 ${className}`}
>
<RowHighlight rect={rect} className="rounded-[8px] bg-gray-alpha-200" />
{menus.map((menu, i) => (
<button
key={menu.label}
ref={(el) => {
btns.current[i] = el;
}}
id={`${uid}-t${i}`}
type="button"
role="menuitem"
aria-haspopup="menu"
aria-expanded={open === i}
aria-controls={open === i ? `${uid}-m${i}` : undefined}
tabIndex={i === current ? 0 : -1}
onClick={(e) => (open === i ? close(false) : show(i, e.detail === 0 ? "first" : "none"))}
onPointerEnter={(e) => {
if (e.pointerType === "touch") return;
setHover(i);
if (open !== null && open !== i) show(i, "none");
}}
onKeyDown={(e) => onKey(e, i)}
onFocus={() => setCurrent(i)}
className={`relative h-7 select-none rounded-[8px] px-2.5 text-[13px] transition-colors duration-150 ease-out motion-reduce:transition-none ${
open === i ? "text-gray-1000" : "text-muted hover:text-gray-1000"
}`}
>
{menu.label}
</button>
))}
{menus.map((menu, i) => (
<Floating
key={menu.label}
open={open === i}
anchor={() => btns.current[i]?.getBoundingClientRect()}
side="bottom"
align="start"
offset={8}
alignOffset={-4}
inkFrom={() => bar.current}
inside={() => [bar.current]}
onOutside={() => close(false)}
>
<MenuPanel
id={`${uid}-m${i}`}
entries={menu.items}
labelledBy={`${uid}-t${i}`}
focus={focus}
onCloseAll={close}
onHorizontal={horizontal}
inkFrom={() => bar.current}
/>
</Floating>
))}
</div>
);
}
"use client";
import { Fragment, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { Kbd } from "../actions/Kbd.js";
import { CheckIcon, glyphProps } from "../../lib/glyph.js";
import { SLIDE } from "../../lib/motion.js";
import { Floating, focusQuietly, useIsoLayoutEffect } from "./layer.js";
/* ── Entries ──────────────────────────────────────────────────────────────── */
/** A command. Choosing it closes the menu. */
export type MenuItemEntry = {
type?: "item";
label: string;
icon?: ReactNode;
/** Keys, drawn as caps: `["⌘", "N"]`. */
shortcut?: string[];
disabled?: boolean;
onSelect?: () => void;
};
/** A setting that is on or off. The menu stays open, so you see the mark land. */
export type MenuCheckboxEntry = {
type: "checkbox";
label: string;
checked: boolean;
onCheckedChange: (checked: boolean) => void;
shortcut?: string[];
disabled?: boolean;
};
/** One of several. A labelled group of rows; the menu stays open. */
export type MenuRadioEntry = {
type: "radio";
label?: string;
value: string;
onValueChange: (value: string) => void;
options: { value: string; label: string; shortcut?: string[]; disabled?: boolean }[];
};
/** Rows under a small heading. */
export type MenuGroupEntry = { type: "group"; label?: string; items: MenuEntry[] };
export type MenuSeparatorEntry = { type: "separator" };
/** A row that opens another menu beside it. */
export type MenuSubmenuEntry = { type: "submenu"; label: string; icon?: ReactNode; disabled?: boolean; items: MenuEntry[] };
export type MenuEntry = MenuItemEntry | MenuCheckboxEntry | MenuRadioEntry | MenuGroupEntry | MenuSeparatorEntry | MenuSubmenuEntry;
type Row =
| { kind: "item"; label: string; disabled: boolean; entry: MenuItemEntry }
| { kind: "checkbox"; label: string; disabled: boolean; entry: MenuCheckboxEntry }
| { kind: "radio"; label: string; disabled: boolean; entry: MenuRadioEntry; option: MenuRadioEntry["options"][number] }
| { kind: "submenu"; label: string; disabled: boolean; entry: MenuSubmenuEntry };
/** Every row the keyboard can land on, in the order they are drawn. */
function flatten(entries: MenuEntry[], out: Row[] = []): Row[] {
for (const e of entries) {
if (e.type === "separator") continue;
if (e.type === "group") flatten(e.items, out);
else if (e.type === "radio") for (const o of e.options) out.push({ kind: "radio", label: o.label, disabled: !!o.disabled, entry: e, option: o });
else if (e.type === "checkbox") out.push({ kind: "checkbox", label: e.label, disabled: !!e.disabled, entry: e });
else if (e.type === "submenu") out.push({ kind: "submenu", label: e.label, disabled: !!e.disabled, entry: e });
else out.push({ kind: "item", label: e.label, disabled: !!e.disabled, entry: e });
}
return out;
}
/** Does any row want the leading column — an icon, a check, a dot? Then every row keeps it, so the labels line up. */
function wantsGutter(entries: MenuEntry[]): boolean {
return entries.some((e) =>
e.type === "group" ? wantsGutter(e.items) : e.type === "checkbox" || e.type === "radio" || ((e.type === undefined || e.type === "item" || e.type === "submenu") && !!e.icon)
);
}
/** The next enabled row from `from` in direction `dir`, wrapping. -1 when there is none. */
function step(rows: Row[], from: number, dir: 1 | -1, wrap = true): number {
const n = rows.length;
for (let k = 1; k <= n; k++) {
let j = from + dir * k;
if (wrap) j = ((j % n) + n) % n;
else if (j < 0 || j >= n) return -1;
if (!rows[j].disabled) return j;
}
return -1;
}
/* ── The shared highlight ─────────────────────────────────────────────────── */
export type RowRect = { x: number; y: number; w: number; h: number };
/**
* The box of whichever row is current, relative to its positioned parent —
* measured after every child has committed, so a list that just re-ranked is
* measured as it now stands.
*/
export function useRowRect(get: () => HTMLElement | null | undefined, deps: unknown[]): RowRect | null {
const [rect, setRect] = useState<RowRect | null>(null);
useIsoLayoutEffect(() => {
const el = get();
const next = el ? { x: el.offsetLeft, y: el.offsetTop, w: el.offsetWidth, h: el.offsetHeight } : null;
setRect((p) => (p && next && p.x === next.x && p.y === next.y && p.w === next.w && p.h === next.h ? p : next));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return rect;
}
/**
* One tile behind the current row, that slides from row to row instead of each
* row lighting its own background. The keyboard and the pointer move the same
* tile, so the eye follows one thing. Arriving from nothing, it appears where it
* is needed rather than sliding in from wherever it last was; leaving, it fades
* in place.
*/
export function RowHighlight({ rect, className = "" }: { rect: RowRect | null; className?: string }) {
const reduce = useReducedMotion();
const last = useRef<RowRect | null>(null);
const shown = useRef(false);
const at = rect ?? last.current;
const jump = !!rect && !shown.current;
useEffect(() => {
shown.current = !!rect;
if (rect) last.current = rect;
});
if (!at) return null;
const move = reduce || jump ? { duration: 0 } : SLIDE;
return (
<LazyMotion features={domAnimation} strict>
<m.span
aria-hidden
className={`pointer-events-none absolute left-0 top-0 ${className}`}
initial={false}
animate={{ x: at.x, y: at.y, width: at.w, height: at.h, opacity: rect ? 1 : 0 }}
transition={{ x: move, y: move, width: move, height: move, opacity: { duration: rect ? 0.1 : 0.15, ease: "easeOut" } }}
/>
</LazyMotion>
);
}
/* ── The panel ────────────────────────────────────────────────────────────── */
function ChevronRight() {
return (
<svg {...glyphProps} strokeWidth="1.8" className="h-3.5 w-3.5">
<path d="M9.5 6l6 6-6 6" />
</svg>
);
}
export type MenuPanelProps = {
entries: MenuEntry[];
id?: string;
/** The trigger, which names the menu. */
labelledBy?: string;
"aria-label"?: string;
/** Which row is current on open: the first (opened from the keyboard), the last (ArrowUp), or none (a click). */
focus?: "first" | "last" | "none";
/** Close every level. `refocus` gives focus back to the trigger. */
onCloseAll: (refocus: boolean) => void;
/** Close just this level — set on a submenu. */
onBack?: () => void;
/** Left and Right at the edge of a menu — a menubar moves to its neighbour. */
onHorizontal?: (dir: 1 | -1) => void;
/** The pointer arrived in this panel — its parent keeps its submenu open. */
onPointerEnter?: () => void;
inkFrom?: () => Element | null | undefined;
};
/**
* The body every menu here shares — dropdown, context menu, menubar — so they
* read and answer the keyboard the same way.
*
* Focus sits on the menu itself and `aria-activedescendant` names the current
* row, as Select does: the rows are never focused one by one, so the one
* sliding tile is the only mark of where you are, and it is the same mark for
* the pointer and the keys. Up and Down move and wrap, Home and End jump,
* typing a few letters finds the row that starts with them, Enter and Space
* choose, Right opens a submenu and Left or Escape closes it.
*
* A command closes the menu; a checkbox or a radio does not, because the point
* of changing a setting in a menu is to see that it changed.
*/
export function MenuPanel({
entries,
id,
labelledBy,
"aria-label": ariaLabel,
focus = "none",
onCloseAll,
onBack,
onHorizontal,
onPointerEnter,
inkFrom,
}: MenuPanelProps) {
const uid = useId();
const rows = useMemo(() => flatten(entries), [entries]);
const gutter = useMemo(() => wantsGutter(entries), [entries]);
const [active, setActive] = useState(() => (focus === "first" ? step(rows, -1, 1) : focus === "last" ? step(rows, rows.length, -1) : -1));
const [sub, setSub] = useState<{ index: number; focus: "first" | "none" } | null>(null);
const panel = useRef<HTMLDivElement>(null);
const rowEls = useRef<(HTMLElement | null)[]>([]);
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const typed = useRef({ text: "", at: 0 });
const rect = useRowRect(() => (active >= 0 ? rowEls.current[active] : null), [active, rows]);
const rowId = (i: number) => `${uid}-r${i}`;
useEffect(() => {
focusQuietly(panel.current);
return () => clearTimeout(timer.current);
}, []);
useEffect(() => {
if (active >= 0) rowEls.current[active]?.scrollIntoView({ block: "nearest" });
}, [active]);
const openSub = (i: number, f: "first" | "none") => {
clearTimeout(timer.current);
setActive(i);
setSub({ index: i, focus: f });
};
const closeSub = () => {
clearTimeout(timer.current);
setSub(null);
focusQuietly(panel.current);
};
const activate = (i: number, fromKeys: boolean) => {
const r = rows[i];
if (!r || r.disabled) return;
if (r.kind === "item") {
onCloseAll(true);
r.entry.onSelect?.();
} else if (r.kind === "checkbox") r.entry.onCheckedChange(!r.entry.checked);
else if (r.kind === "radio") r.entry.onValueChange(r.option.value);
else openSub(i, fromKeys ? "first" : "none");
};
const hover = (i: number) => {
if (document.activeElement !== panel.current) focusQuietly(panel.current);
if (i === active && (rows[i]?.kind !== "submenu" || sub?.index === i)) return;
clearTimeout(timer.current);
const r = rows[i];
if (r.disabled) {
setActive(-1);
return;
}
setActive(i);
// Intent, both ways: a submenu opens once the pointer has settled on its row,
// and an open one survives the pointer crossing a neighbour on its way over.
if (r.kind === "submenu") timer.current = setTimeout(() => setSub({ index: i, focus: "none" }), 110);
else if (sub) timer.current = setTimeout(() => setSub(null), 220);
};
const typeAhead = (key: string) => {
const now = Date.now();
const t = typed.current;
t.text = now - t.at > 600 ? key.toLowerCase() : t.text + key.toLowerCase();
t.at = now;
const n = rows.length;
// A fresh letter starts from the row after this one, so pressing it again walks the matches.
const start = t.text.length === 1 ? active + 1 : Math.max(active, 0);
for (let k = 0; k < n; k++) {
const j = (start + k + n) % n;
if (!rows[j].disabled && rows[j].label.toLowerCase().startsWith(t.text)) {
setActive(j);
return;
}
}
};
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
// Every key stops here: a submenu's keys must not reach the menu it opened from.
e.stopPropagation();
if (e.metaKey || e.ctrlKey || e.altKey) return;
const k = e.key;
let handled = true;
if (k === "ArrowDown") setActive((a) => step(rows, a < 0 ? -1 : a, 1));
else if (k === "ArrowUp") setActive((a) => step(rows, a < 0 ? rows.length : a, -1));
else if (k === "Home") setActive(step(rows, -1, 1));
else if (k === "End") setActive(step(rows, rows.length, -1));
else if (k === "Enter" || k === " ") {
if (active >= 0) activate(active, true);
} else if (k === "ArrowRight") {
if (active >= 0 && rows[active].kind === "submenu" && !rows[active].disabled) openSub(active, "first");
else onHorizontal?.(1);
} else if (k === "ArrowLeft") {
if (onBack) onBack();
else onHorizontal?.(-1);
} else if (k === "Escape") {
if (onBack) onBack();
else onCloseAll(true);
} else if (k === "Tab") onCloseAll(true);
else if (k.length === 1 && /\S/.test(k)) typeAhead(k);
else handled = false;
if (handled) e.preventDefault();
};
let n = 0;
const row = (r: Row, key: string) => {
const i = n++;
const mark =
r.kind === "checkbox" ? (
<span className={`transition-opacity duration-150 ease-out motion-reduce:transition-none ${r.entry.checked ? "opacity-100" : "opacity-0"}`}>
<CheckIcon className="h-3.5 w-3.5" />
</span>
) : r.kind === "radio" ? (
<span
className={`h-1.5 w-1.5 rounded-full bg-current transition-[opacity,transform] duration-150 ease-geist motion-reduce:transition-none ${
r.entry.value === r.option.value ? "scale-100 opacity-100" : "scale-50 opacity-0"
}`}
/>
) : r.kind === "item" || r.kind === "submenu" ? (
<span className="text-quiet">{r.entry.icon}</span>
) : null;
const shortcut = r.kind === "radio" ? r.option.shortcut : r.kind === "submenu" ? undefined : r.entry.shortcut;
const checked = r.kind === "checkbox" ? r.entry.checked : r.kind === "radio" ? r.entry.value === r.option.value : undefined;
return (
<div
key={key}
id={rowId(i)}
ref={(el) => {
rowEls.current[i] = el;
}}
role={r.kind === "checkbox" ? "menuitemcheckbox" : r.kind === "radio" ? "menuitemradio" : "menuitem"}
aria-checked={checked}
aria-disabled={r.disabled || undefined}
aria-haspopup={r.kind === "submenu" ? "menu" : undefined}
aria-expanded={r.kind === "submenu" ? sub?.index === i : undefined}
onPointerMove={(e) => {
if (e.pointerType !== "touch") hover(i);
}}
onClick={() => activate(i, false)}
className={`relative flex h-8 cursor-default select-none items-center gap-2.5 rounded-[8px] px-2.5 text-[13px] text-gray-1000 ${
r.disabled ? "opacity-50" : ""
}`}
>
{gutter && <span className="flex w-4 shrink-0 items-center justify-center">{mark}</span>}
<span className="min-w-0 flex-1 truncate">{r.label}</span>
{shortcut && <Kbd keys={shortcut} className="ml-4 shrink-0" />}
{r.kind === "submenu" && (
<span className="-mr-1 ml-4 shrink-0 text-quiet">
<ChevronRight />
</span>
)}
</div>
);
};
const render = (list: MenuEntry[], path: string): ReactNode[] =>
list.map((e, j) => {
const key = `${path}.${j}`;
if (e.type === "separator") return <div key={key} role="separator" className="mx-2.5 my-1 h-[0.5px] bg-gray-alpha-400" />;
if (e.type === "group" || e.type === "radio") {
const head = `${uid}-g${key}`;
return (
<div key={key} role="group" aria-labelledby={e.label ? head : undefined}>
{e.label && (
<div id={head} className="flex h-7 items-end px-2.5 pb-1 text-[12px] text-faint">
{e.label}
</div>
)}
{e.type === "group" ? render(e.items, key) : e.options.map((o, k) => row(rows[n], `${key}.${k}.${o.value}`))}
</div>
);
}
return row(rows[n], key);
});
return (
<>
<div
ref={panel}
id={id}
role="menu"
tabIndex={-1}
aria-orientation="vertical"
aria-labelledby={labelledBy}
aria-label={ariaLabel}
aria-activedescendant={active >= 0 ? rowId(active) : undefined}
onKeyDown={onKeyDown}
onPointerEnter={onPointerEnter}
onPointerLeave={() => {
if (!sub) setActive(-1);
}}
// The focus holder, not a control: the sliding tile is its focus mark,
// as the panel of a SlideOver carries none of its own.
className="glass-menu dock-shadow relative max-h-[min(420px,calc(100dvh-16px))] min-w-[208px] max-w-[320px] overflow-y-auto rounded-[12px] border-[0.5px] border-gray-alpha-400 p-1 outline-none"
>
<RowHighlight rect={rect} className="rounded-[8px] bg-gray-alpha-200" />
{render(entries, "m")}
</div>
{rows.map((r, i) =>
r.kind === "submenu" ? (
<Fragment key={i}>
<Floating
open={sub?.index === i}
anchor={() => rowEls.current[i]?.getBoundingClientRect()}
side="right"
align="start"
offset={6}
alignOffset={-5}
inkFrom={inkFrom}
>
<MenuPanel
entries={r.entry.items}
labelledBy={rowId(i)}
focus={sub?.focus ?? "none"}
onCloseAll={onCloseAll}
onBack={closeSub}
onHorizontal={onHorizontal}
onPointerEnter={() => {
clearTimeout(timer.current);
setActive(i);
}}
inkFrom={inkFrom}
/>
</Floating>
</Fragment>
) : null
)}
</>
);
}
"use client";
import {
Fragment,
cloneElement,
useEffect,
useLayoutEffect,
useRef,
useState,
version,
type KeyboardEvent as ReactKeyboardEvent,
type ReactElement,
type ReactNode,
type RefObject,
} from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, LazyMotion, domAnimation, m } from "motion/react";
import { DropMenu } from "../DropMenu.js";
import { CLOSE } from "../../lib/motion.js";
/*
* The one layer every overlay in this directory stands on: a portal, a scroll
* lock, a focus trap that gives focus back, Escape, a press outside, and a
* position anchored to a trigger that flips when there is no room. Nothing here
* is exported from the package — the components are the API; this is how they
* agree with each other when one opens inside another.
*/
/** Layout effects warn on the server in React 18, and there is nothing to measure there anyway. */
export const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
/* ── Portal ───────────────────────────────────────────────────────────────── */
export type PortalProps = {
children: ReactNode;
/** Re-read the ink each time this turns true. */
open: boolean;
/**
* Where the layer was opened from. A host can recolour `--color-ink` for one
* region only (the docs site does it per stage); a layer moved to the body
* would lose that, so it copies the opener's ink onto its own root.
*/
inkFrom?: () => Element | null | undefined;
};
/**
* Renders into a root of its own at the end of `<body>`, so a layer escapes any
* `overflow: hidden`, transform or stacking context its trigger sits in. Nothing
* is rendered until the client has mounted, so server and client markup agree.
*/
export function Portal({ children, open, inkFrom }: PortalProps) {
const [host, setHost] = useState<HTMLDivElement | null>(null);
const from = useRef(inkFrom);
from.current = inkFrom;
useEffect(() => {
const el = document.createElement("div");
el.setAttribute("data-overlay-root", "");
document.body.appendChild(el);
setHost(el);
return () => {
el.remove();
setHost(null);
};
}, []);
useIsoLayoutEffect(() => {
if (!host || !open) return;
const source = from.current?.();
const ink = source ? getComputedStyle(source).getPropertyValue("--color-ink").trim() : "";
const page = getComputedStyle(document.documentElement).getPropertyValue("--color-ink").trim();
if (ink && ink !== page) host.style.setProperty("--color-ink", ink);
else host.style.removeProperty("--color-ink");
}, [host, open]);
return host ? createPortal(children, host) : null;
}
/* ── Scroll lock ──────────────────────────────────────────────────────────── */
let locks = 0;
let unlock: (() => void) | null = null;
/**
* Stops the page scrolling under a modal. Counted, so a dialog opened from a
* drawer does not release the lock when it closes. The scrollbar's gutter is
* kept rather than padded back in, so neither the page nor anything fixed to the
* viewport shifts sideways when the bar disappears.
*/
export function useScrollLock(on: boolean) {
useEffect(() => {
if (!on) return;
if (locks++ === 0) {
const html = document.documentElement;
const before = { overflow: html.style.overflow, gutter: html.style.scrollbarGutter };
const hasBar = window.innerWidth - html.clientWidth > 0;
html.style.overflow = "hidden";
if (hasBar) html.style.scrollbarGutter = "stable";
unlock = () => {
html.style.overflow = before.overflow;
html.style.scrollbarGutter = before.gutter;
};
}
return () => {
if (--locks === 0) {
unlock?.();
unlock = null;
}
};
}, [on]);
}
/* ── Focus ────────────────────────────────────────────────────────────────── */
const TABBABLE =
'a[href],area[href],button:not([disabled]),input:not([disabled]):not([type="hidden"]),select:not([disabled]),textarea:not([disabled]),iframe,audio[controls],video[controls],[contenteditable]:not([contenteditable="false"]),[tabindex]';
/** What Tab would visit inside `root`, in order. */
export function tabbables(root: Element): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(TABBABLE)).filter(
(el) => el.tabIndex >= 0 && !el.closest("[inert]") && el.getClientRects().length > 0
);
}
/** Keep Tab inside `root`: off the last element wraps to the first, and back. */
export function trapTab(e: ReactKeyboardEvent, root: HTMLElement | null) {
if (e.key !== "Tab" || !root || !root.contains(e.target as Node)) return;
const list = tabbables(root);
if (list.length === 0) {
e.preventDefault();
return;
}
// From the panel itself (not in the list), Tab goes to the first control and
// Shift+Tab to the last, rather than out to the page behind.
const i = list.indexOf(document.activeElement as HTMLElement);
if (e.shiftKey && i <= 0) {
e.preventDefault();
list[list.length - 1].focus();
} else if (!e.shiftKey && (i === -1 || i === list.length - 1)) {
e.preventDefault();
list[0].focus();
}
}
/** Focus something without the page jumping to it. */
export function focusQuietly(el: Element | null | undefined) {
if (el && "focus" in el && (el as HTMLElement).isConnected) (el as HTMLElement).focus({ preventScroll: true });
}
/** The tabbable that comes after `el` in the page, skipping every overlay root. */
export function nextInPage(el: Element | null, dir: 1 | -1 = 1): HTMLElement | null {
if (!el) return null;
const list = tabbables(document.body).filter((n) => !n.closest("[data-overlay-root]"));
const i = list.indexOf(el as HTMLElement);
return i < 0 ? null : (list[i + dir] ?? null);
}
/* ── Pressing outside ─────────────────────────────────────────────────────── */
type Layer = { nodes: () => (Element | null | undefined)[] };
const stack: Layer[] = [];
/**
* Registers an open layer, and calls `onOutside` for a press that lands outside
* it. A press inside any layer opened *after* this one — a submenu, a popover
* opened from inside a popover — counts as inside, since those belong to it even
* though they live elsewhere in the DOM.
*
* It listens in the capture phase, so it hears the press even when something
* under the pointer stops it from bubbling.
*/
export function useOutside(open: boolean, nodes: () => (Element | null | undefined)[], onOutside?: () => void) {
const get = useRef(nodes);
get.current = nodes;
const cb = useRef(onOutside);
cb.current = onOutside;
useEffect(() => {
if (!open) return;
const me: Layer = { nodes: () => get.current() };
stack.push(me);
const onDown = (e: PointerEvent) => {
if (!cb.current) return;
const t = e.target as Node;
const mine = stack.slice(stack.indexOf(me));
if (!mine.some((l) => l.nodes().some((n) => n?.contains(t)))) cb.current();
};
document.addEventListener("pointerdown", onDown, true);
return () => {
document.removeEventListener("pointerdown", onDown, true);
stack.splice(stack.indexOf(me), 1);
};
}, [open]);
}
/* ── Anchored position ────────────────────────────────────────────────────── */
export type Side = "top" | "bottom" | "left" | "right";
export type Align = "start" | "center" | "end";
export type Placed = { top: number; left: number; side: Side; align: Align };
export type PlaceOptions = { side: Side; align: Align; offset?: number; alignOffset?: number; margin?: number };
const opposite: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };
/**
* Where a box of `w × h` goes beside `a`. It takes the side asked for unless the
* box does not fit there and the opposite side has more room; an edge-aligned
* box swaps to the other edge before it is pushed; and then it is clamped inside
* the viewport, so a menu is never cut off — it just stops being perfectly
* aligned, which is the smaller loss.
*/
export function place(a: DOMRect, w: number, h: number, o: PlaceOptions): Placed {
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const gap = o.offset ?? 6;
const shift = o.alignOffset ?? 0;
const margin = o.margin ?? 8;
const room: Record<Side, number> = {
bottom: vh - a.bottom - gap - margin,
top: a.top - gap - margin,
right: vw - a.right - gap - margin,
left: a.left - gap - margin,
};
const need = o.side === "top" || o.side === "bottom" ? h : w;
const side = room[o.side] < need && room[opposite[o.side]] > room[o.side] ? opposite[o.side] : o.side;
let align = o.align;
let top: number;
let left: number;
if (side === "top" || side === "bottom") {
top = side === "bottom" ? a.bottom + gap : a.top - gap - h;
const at = (al: Align) => (al === "start" ? a.left + shift : al === "end" ? a.right - w - shift : a.left + a.width / 2 - w / 2);
left = at(align);
if (align === "start" && left + w > vw - margin) align = "end";
else if (align === "end" && left < margin) align = "start";
left = at(align);
} else {
left = side === "right" ? a.right + gap : a.left - gap - w;
const at = (al: Align) => (al === "start" ? a.top + shift : al === "end" ? a.bottom - h - shift : a.top + a.height / 2 - h / 2);
top = at(align);
if (align === "start" && top + h > vh - margin) align = "end";
else if (align === "end" && top < margin) align = "start";
top = at(align);
}
left = Math.max(margin, Math.min(left, vw - w - margin));
top = Math.max(margin, Math.min(top, vh - h - margin));
return { top: Math.round(top), left: Math.round(left), side, align };
}
/**
* Keeps `box` placed beside the anchor while open — measured before paint, and
* again on any scroll (in any container), resize, or change in the box's own
* size. The box is measured by its layout size, not its bounding rect, so the
* opening scale does not shrink what is measured.
*/
export function useAnchored(
open: boolean,
anchor: () => DOMRect | null | undefined,
box: RefObject<HTMLElement | null>,
options: PlaceOptions
): Placed | null {
const [pos, setPos] = useState<Placed | null>(null);
const a = useRef(anchor);
a.current = anchor;
const o = useRef(options);
o.current = options;
useIsoLayoutEffect(() => {
if (!open) return;
let raf = 0;
const update = () => {
raf = 0;
const r = a.current();
const el = box.current;
if (!r || !el) return;
const next = place(r, el.offsetWidth, el.offsetHeight, o.current);
setPos((p) =>
p && p.top === next.top && p.left === next.left && p.side === next.side && p.align === next.align ? p : next
);
};
const schedule = () => {
if (!raf) raf = requestAnimationFrame(update);
};
update();
window.addEventListener("scroll", schedule, { capture: true, passive: true });
window.addEventListener("resize", schedule);
const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(schedule);
if (box.current) ro?.observe(box.current);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("scroll", schedule, { capture: true });
window.removeEventListener("resize", schedule);
ro?.disconnect();
};
}, [open]);
return pos;
}
const ORIGIN: Record<Side, Record<Align, string>> = {
bottom: { start: "origin-top-left", center: "origin-top", end: "origin-top-right" },
top: { start: "origin-bottom-left", center: "origin-bottom", end: "origin-bottom-right" },
right: { start: "origin-top-left", center: "origin-left", end: "origin-bottom-left" },
left: { start: "origin-top-right", center: "origin-right", end: "origin-bottom-right" },
};
export type FloatingProps = {
open: boolean;
anchor: () => DOMRect | null | undefined;
side?: Side;
align?: Align;
offset?: number;
alignOffset?: number;
/** Where the ink is copied from — usually the trigger. */
inkFrom?: () => Element | null | undefined;
/** Extra elements a press may land on without counting as outside — the trigger. */
inside?: () => (Element | null | undefined)[];
/** A press outside the panel, the trigger, and anything opened from it. */
onOutside?: () => void;
children: ReactNode;
};
/**
* A panel anchored to a trigger, in the body, growing out of whichever edge it
* ended up on with DropMenu's squash-and-stretch. The transform-origin follows
* the flip, so a menu that had to open upward still grows out of its trigger
* rather than toward it.
*/
export function Floating({
open,
anchor,
side = "bottom",
align = "start",
offset,
alignOffset,
inkFrom,
inside,
onOutside,
children,
}: FloatingProps) {
const box = useRef<HTMLDivElement>(null);
const pos = useAnchored(open, anchor, box, { side, align, offset, alignOffset });
useOutside(open, () => [box.current, ...(inside?.() ?? [])], onOutside);
const s = pos?.side ?? side;
const al = pos?.align ?? align;
// Each opening gets fresh contents. Reopened while it is still leaving, the
// panel is caught mid-exit and reused, and without a new key whatever it
// does on mount (take focus, start on the first row) would not happen again.
const opens = useRef(0);
const was = useRef(false);
if (open && !was.current) opens.current++;
was.current = open;
return (
<Portal open={open} inkFrom={inkFrom}>
<div
ref={box}
// Not hidden before it is measured: the measure happens before paint, and
// a hidden ancestor would make the panel refuse focus on mount.
className="pointer-events-none fixed z-[62] w-max"
style={{ top: pos?.top ?? 0, left: pos?.left ?? 0 }}
>
<DropMenu open={open} placement={s === "top" ? "up" : "down"} origin={ORIGIN[s][al]} className={open ? "pointer-events-auto" : ""}>
<Fragment key={opens.current}>{children}</Fragment>
</DropMenu>
</div>
</Portal>
);
}
/* ── Modal layer ──────────────────────────────────────────────────────────── */
export type ModalLayerProps = {
open: boolean;
onDismiss: () => void;
/** A press on the scrim dismisses. Off for a decision that must be made. */
backdrop?: boolean;
/** Escape dismisses. */
escape?: boolean;
/** What takes focus on open. Default: the first control that is not the corner close button. */
initialFocus?: () => HTMLElement | null | undefined;
/** Where the panel stands: centred, high (a palette), or on the bottom edge (a sheet). */
placement?: "center" | "top" | "bottom";
/** The panel — a motion element with its own enter and exit. */
children: ReactNode;
};
/* The last thing pressed, for browsers that do not focus a button on click
(Safari): without it a dialog opened by a click would have no opener to give
focus back to, or to take its ink from. */
let lastPressed: Element | null = null;
let listening = false;
function listenForPresses() {
if (listening || typeof document === "undefined") return;
listening = true;
document.addEventListener("pointerdown", (e) => (lastPressed = e.target as Element), true);
}
function currentOpener(): Element | null {
const a = document.activeElement;
if (a && a !== document.body) return a;
return lastPressed?.isConnected ? (lastPressed.closest("button, a[href], [tabindex]") ?? lastPressed) : null;
}
const PLACE = {
center: "items-center p-4",
top: "items-start px-4 pt-[14vh] pb-4",
bottom: "items-end p-2",
} as const;
/**
* Everything a modal shares: the scrim, the scroll lock, focus moved in and given
* back to whatever had it, Tab kept inside, Escape and a press on the scrim.
*
* While it is up, keys and the wheel stop at its edge. A page is free to listen
* for arrows or scroll on the window (a carousel, a slide deck), and a modal is
* a promise that nothing behind it reacts. Modified keys still pass, so a global
* shortcut can close what it opened.
*/
export function ModalLayer({ open, onDismiss, backdrop = true, escape = true, initialFocus, placement = "center", children }: ModalLayerProps) {
const [layer, setLayer] = useState<HTMLDivElement | null>(null);
const opener = useRef<Element | null>(null);
const was = useRef(false);
const pressed = useRef(false);
const first = useRef(initialFocus);
first.current = initialFocus;
// Who opened it is whoever had focus (or was pressed) when `open` turned true.
if (open && !was.current && typeof document !== "undefined") opener.current = currentOpener();
was.current = open;
useEffect(listenForPresses, []);
useScrollLock(open);
useEffect(() => {
if (!open || !layer) return;
const target =
first.current?.() ??
tabbables(layer).find((el) => !el.hasAttribute("data-dialog-close")) ??
layer.querySelector<HTMLElement>("[data-modal-panel]");
focusQuietly(target);
}, [open, layer]);
useEffect(() => {
if (!open) return;
return () => {
const was = document.activeElement as HTMLElement | null;
focusQuietly(opener.current);
// Opened from nowhere focusable (a hotkey on the page): let go of the
// field in the leaving panel rather than leave typing aimed at it.
if (was && document.activeElement === was && was.closest("[data-overlay-root]")) was.blur();
};
}, [open]);
return (
<Portal open={open} inkFrom={() => opener.current}>
<LazyMotion features={domAnimation} strict>
<AnimatePresence>
{open && (
<m.div
key="scrim"
aria-hidden
className="overlay-scrim fixed inset-0 z-[58]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: CLOSE }}
transition={{ duration: 0.2, ease: "easeOut" }}
/>
)}
{open && (
<div
key="layer"
ref={setLayer}
className={`fixed inset-0 z-[58] flex justify-center ${PLACE[placement]}`}
onPointerDown={(e) => {
pressed.current = e.target === e.currentTarget;
}}
onClick={(e) => {
if (backdrop && pressed.current && e.target === e.currentTarget) onDismiss();
pressed.current = false;
}}
onKeyDown={(e) => {
if (e.key === "Escape" && !e.defaultPrevented) {
e.preventDefault();
if (escape) onDismiss();
} else trapTab(e, layer);
if (!e.metaKey && !e.ctrlKey) e.stopPropagation();
}}
onWheel={(e) => e.stopPropagation()}
>
{children}
</div>
)}
</AnimatePresence>
</LazyMotion>
</Portal>
);
}
/* ── Triggers ─────────────────────────────────────────────────────────────── */
type AnyProps = Record<string, unknown>;
type Handler = (e: { defaultPrevented?: boolean }) => void;
const REACT_19 = parseInt(version, 10) >= 19;
function assignRef(ref: unknown, el: HTMLElement | null) {
if (typeof ref === "function") ref(el);
else if (ref && typeof ref === "object" && "current" in ref) (ref as { current: unknown }).current = el;
}
/** The trigger's own `id` if it has one, so ARIA can point at it; otherwise ours. */
export function triggerId(child: ReactElement, fallback: string): string {
const id = (child.props as AnyProps).id;
return typeof id === "string" && id ? id : fallback;
}
/**
* Hands a trigger its ARIA, its handlers and a ref without wrapping it in
* anything, so your own Button stays the element that is focused and pressed.
* Handlers are chained: the child's own runs first, and calling
* `preventDefault()` in it keeps ours from running.
*/
export function withTrigger(child: ReactElement, ours: AnyProps, ref: (el: HTMLElement | null) => void) {
const own = child.props as AnyProps;
const merged: AnyProps = { ...ours };
for (const k of Object.keys(ours)) {
const a = own[k];
const b = ours[k];
if (/^on[A-Z]/.test(k) && typeof a === "function" && typeof b === "function") {
merged[k] = (e: { defaultPrevented?: boolean }) => {
(a as Handler)(e);
if (!e?.defaultPrevented) (b as Handler)(e);
};
}
}
const childRef = REACT_19 ? own.ref : (child as unknown as { ref?: unknown }).ref;
merged.ref = (el: HTMLElement | null) => {
ref(el);
assignRef(childRef, el);
};
return cloneElement(child, merged);
}