Foundations
Overlays
Surfaces that appear above the interface for a moment: where they sit, how they close, where focus goes.
MarkdownAn overlay is a surface that appears above the interface for a moment — a menu, a popover, a list of suggestions — attached to the control that opened it, or to the point it is about. It keeps people in context: the interface they came from stays in view, and closing the overlay returns them to it.
Stacked layers
Open one layer, then another from it. Escape closes the topmost; a press in a layer closes only those above it; a press outside closes them all.
open: none
Code
"use client";
import { Radio, RadioGroup } from "@publictheta/ui/components/radio-group/radio-group.tsx";
import { anchoredClassName, useOverlay } from "@publictheta/ui/primitives/overlay.ts";
import type { Placement } from "@publictheta/ui/primitives/overlay.ts";
import type { ReactNode } from "react";
import { useState } from "react";
import { Controls, Readout } from "../../ui/controls.tsx";
const BUTTON =
"rounded-lg border border-border-muted bg-surface px-3 py-1.5 text-sm text-fg-strong outline-none hover:bg-surface-muted aria-expanded:bg-surface-strong focus-visible:ring-2 focus-visible:ring-focus-ring";
function Layer({
label,
placement,
onOpenChange,
children,
}: {
label: string;
placement: Placement;
onOpenChange: (open: boolean) => void;
children?: ReactNode;
}) {
const overlay = useOverlay({ placement, onOpenChange });
return (
<>
<button
type="button"
{...overlay.triggerProps}
onClick={() => overlay.setOpen(!overlay.open, "first")}
className={BUTTON}
>
{label}
</button>
<div
role="dialog"
aria-label={label}
{...overlay.contentProps}
className={`${anchoredClassName} mt-1.5 flex-col gap-3 rounded-xl border border-border-muted bg-surface-raised p-4 text-sm text-fg shadow-lg outline-none open:flex`}
>
<p className="font-medium text-fg-strong">{label}</p>
{children}
</div>
</>
);
}
export default function OverlayStack() {
const [placement, setPlacement] = useState<Placement>("block-end");
const [open, setOpen] = useState<string[]>([]);
const track = (label: string) => (next: boolean) =>
setOpen((current) =>
next ? [...current.filter((l) => l !== label), label] : current.filter((l) => l !== label),
);
return (
<div className="w-full">
<Controls>
<RadioGroup label="Placement" size="small" value={placement} onValueChange={setPlacement}>
<Radio value="block-end">Below</Radio>
<Radio value="block-start">Above</Radio>
<Radio value="inline-end">Beside</Radio>
</RadioGroup>
</Controls>
<div className="flex justify-center">
<Layer label="Layer one" placement={placement} onOpenChange={track("Layer one")}>
<p className="max-w-56 text-fg-muted">
Open another layer from here, then press Escape, or press outside each.
</p>
<div>
<Layer label="Layer two" placement="inline-end" onOpenChange={track("Layer two")}>
<p className="max-w-48 text-fg-muted">The topmost layer closes first.</p>
</Layer>
</div>
</Layer>
</div>
<Readout>open: {open.length === 0 ? "none" : open.join(" › ")}</Readout>
</div>
);
}Against a point
Press anywhere in the area: the overlay begins at the point and extends the way the text runs. Near an edge it opens to the other side. Escape returns focus to where it was.
closed
Code
"use client";
import { Radio, RadioGroup } from "@publictheta/ui/components/radio-group/radio-group.tsx";
import { anchoredClassName, useOverlay } from "@publictheta/ui/primitives/overlay.ts";
import type { Placement } from "@publictheta/ui/primitives/overlay.ts";
import { useState } from "react";
import { Controls, Readout } from "../../ui/controls.tsx";
export default function OverlayAtPoint() {
const [placement, setPlacement] = useState<Placement>("block-end");
const [direction, setDirection] = useState<"ltr" | "rtl">("ltr");
const [at, setAt] = useState<{ x: number; y: number } | null>(null);
const overlay = useOverlay({
placement,
onOpenChange(open) {
if (!open) setAt(null);
},
});
return (
<div className="w-full" dir={direction}>
<Controls>
<RadioGroup label="Placement" size="small" value={placement} onValueChange={setPlacement}>
<Radio value="block-end">Below</Radio>
<Radio value="block-start">Above</Radio>
<Radio value="inline-end">Beside</Radio>
</RadioGroup>
<RadioGroup label="Direction" size="small" value={direction} onValueChange={setDirection}>
<Radio value="ltr">Left to right</Radio>
<Radio value="rtl">Right to left</Radio>
</RadioGroup>
</Controls>
<button
type="button"
aria-haspopup="dialog"
onClick={(event) => {
// A click from the keyboard has no place of its own: the button's corner stands for it.
const box = event.currentTarget.getBoundingClientRect();
const point =
event.detail === 0 ? { x: box.left, y: box.top } : { x: event.clientX, y: event.clientY };
setAt(point);
overlay.openAt(point);
}}
className="flex h-48 w-full items-center justify-center rounded-lg border border-dashed border-border bg-surface text-sm text-fg-muted outline-none focus-visible:ring-2 focus-visible:ring-focus-ring"
>
Press anywhere here, near an edge too
</button>
<span {...overlay.anchorProps} />
<div
role="dialog"
aria-label="At the point"
{...overlay.contentProps}
className={`${anchoredClassName} w-44 rounded-xl border border-border-muted bg-surface-raised p-3 text-sm text-fg shadow-lg outline-none`}
>
Placed against the point, on the side with room.
</div>
<Readout>{at === null ? "closed" : `open at ${Math.round(at.x)}, ${Math.round(at.y)}`}</Readout>
</div>
);
}Best practices
Attach the overlay to its control. Place it against the control that opened it, on the side with room, so the relationship is evident. When there is no room on the preferred side, move it to the other rather than letting it be cut off.
Or to the point it is about. An overlay with no control of its own — a context menu at the pointer, a toolbar over a selection — is placed against that point or rectangle of the view, in the same way: beginning there, extending toward the end of the line in the direction its own text runs, and moved to the other side at an edge of the view. Where it fits on neither side, it is held within the view. It is where it says wherever in the page it happens to be written: inside a panel that has been moved, it is still at the point.
Keep it above everything. An overlay should never be clipped by a scrolling region or hidden beneath other content.
Let people dismiss it without deciding. Escape and a press outside close an overlay and change nothing. Pressing its control again closes it too, and so does focus moving somewhere outside it.
Give Escape to what is being done first. With focus inside an overlay, whatever has focus hears Escape before the overlay does: a field abandons what was typed, and only the next Escape closes the overlay. While an input method is composing, Escape is its own.
Decide whether a press outside is also a press. A menu, like the platform's own, takes the press that dismisses it: what is under the pointer is not pressed too, except another control that opens a menu, so that moving from menu to menu takes one press. An overlay that sits beside the work it serves — a popover of settings — lets the press through, so that closing it and going on are one gesture. A sheet over a dimmed interface always takes the press.
Block the page for what must be answered. A dialog is modal on every screen, not only on a phone: while it is open the rest of the page is inert — to a pointer, the keyboard and assistive technology — and does not scroll, and a scrollbar that took room keeps it, so that the page does not shift. A press outside it reaches nothing beneath. An alert that must be answered is not closed by such a press at all, and focus stays where it was, though whatever was opened from the alert closes; Escape still closes it.
Close one level at a time. When one overlay opens another — a submenu, a popover from within a popover — Escape closes only the topmost, and a press in an outer overlay closes only those above it. Closing an outer overlay — by any means — closes everything opened from it. The order is the order in which they are nested, also when both are open from the start.
Move focus in, and bring it back. When an overlay opens, focus moves into it: to the first control, or to the overlay itself when there is nothing to operate. When it closes, focus returns to the control that opened it — unless the person has already moved focus elsewhere, as by pressing another control. With no control to return to, or one that has since become unavailable, focus returns to whatever had it before the overlay opened. Focus is returned even if the overlay's content is removed from the page in the act of closing.
Do not outlive the control. An overlay whose control becomes unavailable, or is removed, closes.
Say what the control opens. A control that opens an overlay states that it does, what kind of overlay it opens, and whether it is open.
Fit the screen. An overlay is never taller than the view: what does not fit scrolls within it, and the page behind does not scroll with it.
Take no presses while leaving. An overlay that is fading away is already gone as far as the pointer is concerned, so a second press cannot act on it.
Use an overlay for what is brief. Choices and small tasks belong in an overlay; anything people need to keep in view, or return to, belongs in the interface itself.
Platform considerations
Pointer and keyboard. An overlay sits beside its control and is dismissed by Escape or a click outside.
Touch, on a phone. Space is short and a finger covers what it presses. An overlay rises from the bottom edge as a sheet, across the full width, above a dimmed interface; a tap outside it closes it.
A sheet shows a grabber at its top, which says it can be dragged away. It follows a finger that drags it down. Let go past a third of its height, or flicked, it closes; otherwise it returns. For people who have asked for less motion it returns at once rather than gliding, though it still follows the finger, which is the finger's motion and not its own.
A sheet is modal. While it is open the rest of the page is inert — to a finger, to the keyboard and to assistive technology alike — and does not scroll behind it; a sheet that is a dialog says that it is modal. All of it is put back exactly as it was found when the sheet closes, whatever the order in which nested sheets close, and also if the sheet is removed from the page while open.
The platform's way back — the back gesture on Android — closes the topmost sheet, where the browser reports it.
An overlay opened from within a sheet rises as a second sheet over the first, unless it is a further level of the same thing — a submenu of a menu. Such a level takes the place of the sheet it was opened from: it covers it, under the same scrim, and a swipe down closes both. What opens such a level gives it a way back.
Several documents, several roots. An overlay belongs to the document it is in: it answers that document's keys and presses, which matters for an interface drawn into a frame or another window. Overlays of separate applications on one page do not disturb one another's placement.
Behaviour
Opening
The control that opens it states whether it is open, and what it opens
const screen = await render(<Overlay label="Panel">Content</Overlay>);
await expect.element(trigger(screen, "Panel")).toHaveAttribute("aria-expanded", "false");
await userEvent.click(trigger(screen, "Panel"));
await expect.element(trigger(screen, "Panel")).toHaveAttribute("aria-expanded", "true");
const id = panel(screen, "Panel").element().id;
await expect.element(trigger(screen, "Panel")).toHaveAttribute("aria-controls", id);It appears above everything else on the page
const screen = await render(
<>
<Overlay label="Panel">Content</Overlay>
<div style={{ position: "fixed", inset: 0, zIndex: 2147483647 }} />
</>,
);
const button = trigger(screen, "Panel").element() as HTMLElement;
button.click();
await expect.poll(() => isOpen(screen, "Panel")).toBe(true);
const rect = panel(screen, "Panel").element().getBoundingClientRect();
const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
expect(panel(screen, "Panel").element().contains(hit)).toBe(true);It is placed against the control that opened it
const screen = await render(<Overlay label="Panel">Content</Overlay>);
await userEvent.click(trigger(screen, "Panel"));
const anchor = trigger(screen, "Panel").element().getBoundingClientRect();
const rect = panel(screen, "Panel").element().getBoundingClientRect();
expect(Math.abs(rect.top - anchor.bottom)).toBeLessThan(1);
expect(Math.abs(rect.left - anchor.left)).toBeLessThan(1);With no room on its side, it flips to the other
const screen = await render(
<Overlay label="Panel" triggerClassName="fixed bottom-0 left-0">
<div style={{ height: 200 }}>Tall</div>
</Overlay>,
);
// Opened by a click made in script: a control fixed to the foot of the view is, to the
// driver of an emulated phone, sometimes outside it — and the clause is about where the overlay goes.
(trigger(screen, "Panel").element() as HTMLElement).click();
await expect.poll(() => isOpen(screen, "Panel")).toBe(true);
const anchor = trigger(screen, "Panel").element().getBoundingClientRect();
const rect = panel(screen, "Panel").element().getBoundingClientRect();
expect(rect.bottom).toBeLessThanOrEqual(anchor.top + 1);Focus
Opening moves focus to the first thing inside that takes it
const screen = await render(
<Overlay label="Panel">
<input aria-label="Name" />
</Overlay>,
);
await userEvent.click(trigger(screen, "Panel"));
await expect.element(screen.getByRole("textbox", { name: "Name" })).toHaveFocus();With nothing inside to take it, focus moves to the overlay
const screen = await render(<Overlay label="Panel">Text only</Overlay>);
await userEvent.click(trigger(screen, "Panel"));
await expect.element(panel(screen, "Panel")).toHaveFocus();Closing returns focus to the control that opened it
const screen = await render(
<Overlay label="Panel">
<input aria-label="Name" />
</Overlay>,
);
await userEvent.click(trigger(screen, "Panel"));
await userEvent.keyboard("{Escape}");
await expect.element(trigger(screen, "Panel")).toHaveFocus();Asked to open when it is already open, it still moves focus as asked
const screen = await render(
<Overlay label="Panel" focus="none">
<button type="button">Inside</button>
</Overlay>,
);
await userEvent.click(trigger(screen, "Panel"));
await expect.poll(() => isOpen(screen, "Panel")).toBe(true);
await expect.element(screen.getByRole("button", { name: "Inside" })).not.toHaveFocus();
(screen.getByRole("button", { name: "Enter Panel" }).element() as HTMLElement).click();
await expect.element(screen.getByRole("button", { name: "Inside" })).toHaveFocus();Closing by pressing another control leaves focus on that controlChromium, Firefox, WebKit
Not yet written.
Dismissal
Escape closes it
const screen = await render(<Overlay label="Panel">Content</Overlay>);
await userEvent.click(trigger(screen, "Panel"));
await userEvent.keyboard("{Escape}");
await expect.poll(() => isOpen(screen, "Panel")).toBe(false);
await expect.element(trigger(screen, "Panel")).toHaveAttribute("aria-expanded", "false");Escape pressed to an input method that is composing is left to it
const screen = await render(
<Overlay label="Panel">
<input aria-label="Field" />
</Overlay>,
);
await userEvent.click(trigger(screen, "Panel"));
screen
.getByRole("textbox")
.element()
.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", isComposing: true, bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 50));
expect(await isOpen(screen, "Panel")).toBe(true);What has focus inside hears Escape first, and one that claims it keeps the overlay open
const screen = await render(
<Overlay label="Panel">
<input
aria-label="Field"
onKeyDown={(event) => {
if (event.key === "Escape" && event.currentTarget.value !== "") {
event.currentTarget.value = "";
event.preventDefault();
}
}}
/>
</Overlay>,
);
await userEvent.click(trigger(screen, "Panel"));
await userEvent.keyboard("draft{Escape}");
await expect.element(screen.getByRole("textbox")).toHaveValue("");
expect(await isOpen(screen, "Panel")).toBe(true);
await userEvent.keyboard("{Escape}");
await expect.poll(() => isOpen(screen, "Panel")).toBe(false);Focus that moves outside closes itChromium, Firefox, WebKit
Not yet written.
A press outside closes it
const screen = await render(
<>
<Overlay label="Panel">Content</Overlay>
<p>Elsewhere</p>
</>,
);
await userEvent.click(trigger(screen, "Panel"));
await userEvent.click(screen.getByText("Elsewhere"), throughScrim);
await expect.poll(() => isOpen(screen, "Panel")).toBe(false);A press inside keeps it open
const screen = await render(<Overlay label="Panel">Content</Overlay>);
await userEvent.click(trigger(screen, "Panel"));
await userEvent.click(screen.getByText("Content"));
expect(await isOpen(screen, "Panel")).toBe(true);Pressing the control that opened it closes it, rather than closing and reopening
const screen = await render(<Overlay label="Panel">Content</Overlay>);
await userEvent.click(trigger(screen, "Panel"));
await userEvent.click(trigger(screen, "Panel"), throughScrim);
await expect.poll(() => isOpen(screen, "Panel")).toBe(false);Modal overlays
A press outside a modal overlay only closes it: what it lands on is not pressed
const onPress = vi.fn();
const screen = await render(beside(onPress, true));
await userEvent.click(trigger(screen, "Panel"));
await userEvent.click(screen.getByRole("button", { name: "Beside" }), throughScrim);
await expect.poll(() => isOpen(screen, "Panel")).toBe(false);
expect(onPress).not.toHaveBeenCalled();
await expect.element(trigger(screen, "Panel")).toHaveFocus();The press after that one is an ordinary press
const onPress = vi.fn();
const screen = await render(beside(onPress, true));
await userEvent.click(trigger(screen, "Panel"));
await userEvent.click(screen.getByRole("button", { name: "Beside" }), throughScrim);
await userEvent.click(screen.getByRole("button", { name: "Beside" }), throughScrim);
expect(onPress).toHaveBeenCalledTimes(1);A press on a control that opens another overlay opens it at onceChromium, Firefox, WebKit
Not yet written.
On a phone, where an overlay is a sheet, a press outside only closes itChromium · touch, WebKit · touch
Not yet written.
A press outside an overlay that is not modal also presses what it lands onChromium, Firefox, WebKit
Not yet written.
Stacking
An overlay opened from within another stays open with it
const screen = await render(nested());
await openBoth(screen);
expect(await isOpen(screen, "Outer")).toBe(true);Escape closes only the topmost, and focus returns to the control that opened it
const screen = await render(nested());
await openBoth(screen);
await userEvent.keyboard("{Escape}");
await expect.poll(() => isOpen(screen, "Inner")).toBe(false);
expect(await isOpen(screen, "Outer")).toBe(true);
await expect.element(trigger(screen, "Inner")).toHaveFocus();A press in the outer overlay closes only the inner
const screen = await render(nested());
await openBoth(screen);
await userEvent.click(screen.getByText("Outer content"), throughScrim);
await expect.poll(() => isOpen(screen, "Inner")).toBe(false);
expect(await isOpen(screen, "Outer")).toBe(true);Closing the outer closes the inner with it
const screen = await render(
<>
{nested()}
<p>Elsewhere</p>
</>,
);
await openBoth(screen);
await userEvent.click(screen.getByText("Elsewhere"), throughScrim);
await expect.poll(() => isOpen(screen, "Outer")).toBe(false);
expect(await isOpen(screen, "Inner")).toBe(false);
// The inner control is inside the closed outer overlay, so it is hidden.
await expect
.element(screen.getByRole("button", { name: "Open Inner", includeHidden: true }))
.toHaveAttribute("aria-expanded", "false");Closing the outer by any means closes the inner with it
const screen = await render(nested());
await openBoth(screen);
// With focus nowhere inside, so that nothing but the closing itself is at work.
(document.activeElement as HTMLElement).blur();
(screen.getByRole("button", { name: "Dismiss Outer" }).element() as HTMLElement).click();
await expect.poll(() => isOpen(screen, "Outer")).toBe(false);
await expect
.element(screen.getByRole("button", { name: "Open Inner", includeHidden: true }))
.toHaveAttribute("aria-expanded", "false");An overlay keeps its place however often its owner renders
const screen = await render(nested());
await openBoth(screen);
(screen.getByRole("button", { name: "Render Outer" }).element() as HTMLElement).click();
await userEvent.keyboard("{Escape}");
await expect.poll(() => isOpen(screen, "Inner")).toBe(false);
expect(await isOpen(screen, "Outer")).toBe(true);Overlays that begin open, one inside another, stack as they are nested
function Begun({ label, children }: { label: string; children?: ReactNode }) {
const overlay = useOverlay({ defaultOpen: true });
return (
<>
<button type="button" {...overlay.triggerProps}>
Open {label}
</button>
<div
role="dialog"
aria-label={label}
{...overlay.contentProps}
className={`${anchoredClassName} border bg-surface p-2`}
>
{children}
</div>
</>
);
}
const screen = await render(
<Begun label="Outer">
<p>Outer content</p>
<Begun label="Inner">Inner content</Begun>
</Begun>,
);
await expect.poll(() => isOpen(screen, "Inner")).toBe(true);
expect(await isOpen(screen, "Outer")).toBe(true);
// The inner is the one on top, to the eye and to the keyboard.
await expect.element(panel(screen, "Inner")).toHaveFocus();
const rect = panel(screen, "Inner").element().getBoundingClientRect();
const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
expect(panel(screen, "Inner").element().contains(hit)).toBe(true);
await userEvent.keyboard("{Escape}");
await expect.poll(() => isOpen(screen, "Inner")).toBe(false);
expect(await isOpen(screen, "Outer")).toBe(true);Anchoring at a point
Opened at a point, it begins at the point, wherever it is renderedChromium, Firefox, WebKit
Not yet written.
Opened against a rectangle, it sits beneath it, from its start edgeChromium, Firefox, WebKit
Not yet written.
With no room below the point, it opens above itChromium, Firefox, WebKit
Not yet written.
With no room above the point, one that opens above opens belowChromium, Firefox, WebKit
Not yet written.
With no room toward the end of the line, it opens toward the startChromium, Firefox, WebKit
Not yet written.
In right-to-left text it opens leftward from the point, and with no room there rightwardChromium, Firefox, WebKit
Not yet written.
Where it fits neither above nor below the point, it is kept within the viewChromium, Firefox, WebKit
Not yet written.
Opened at another point while it is open, it moves thereChromium, Firefox, WebKit
Not yet written.
With no control to return to, closing returns focus to what had it before
const screen = await render(<Pointed points={[{ x: 100, y: 120 }]} />);
await userEvent.click(screen.getByRole("textbox", { name: "Before" }));
await openAt(screen);
const dialog = pointed(screen).element();
await expect.element(dialog).toHaveFocus();
await userEvent.keyboard("{Escape}");
await expect.poll(() => dialog.matches(":popover-open")).toBe(false);
await expect.element(screen.getByRole("textbox", { name: "Before" })).toHaveFocus();What changes beneath it
Content that arrives after the overlay was opened is shown
function Late() {
const overlay = useOverlay({ defaultOpen: true });
const [arrived, setArrived] = useState(false);
return (
<>
<button type="button" onClick={() => setArrived(true)}>
Arrive
</button>
<button type="button" {...overlay.triggerProps}>
Control
</button>
{arrived && (
<div role="dialog" aria-label="Late" {...overlay.contentProps} className={anchoredClassName}>
Content
</div>
)}
</>
);
}
const screen = await render(<Late />);
(screen.getByRole("button", { name: "Arrive" }).element() as HTMLElement).click();
await expect.poll(() => isOpen(screen, "Late")).toBe(true);
await expect.element(panel(screen, "Late")).toHaveFocus();Focus returns to the control when the content is removed in the act of closing
function Conditional() {
const [open, setOpen] = useState(false);
const overlay = useOverlay({ open, onOpenChange: setOpen });
return (
<>
<button type="button" {...overlay.triggerProps} onClick={() => overlay.setOpen(true, "first")}>
Control
</button>
{open && (
<div role="dialog" aria-label="Brief" {...overlay.contentProps} className={anchoredClassName}>
<input aria-label="Inside" />
</div>
)}
</>
);
}
const screen = await render(<Conditional />);
await userEvent.click(screen.getByRole("button", { name: "Control" }));
await expect.element(screen.getByRole("textbox", { name: "Inside" })).toHaveFocus();
await userEvent.keyboard("{Escape}");
await expect.element(screen.getByRole("dialog", { name: "Brief" })).not.toBeInTheDocument();
await expect.element(screen.getByRole("button", { name: "Control" })).toHaveFocus();A control that can no longer be used closes its overlay, and focus goes back to where it was before
const screen = await render(<Losing how="disabled" />);
await loseControl(screen);
await expect.poll(() => isOpen(screen, "Held")).toBe(false);
await expect.element(screen.getByRole("textbox", { name: "Before" })).toHaveFocus();A control that is removed closes its overlay, and focus goes back to where it was before
const screen = await render(<Losing how="removed" />);
await loseControl(screen);
await expect.poll(() => isOpen(screen, "Held")).toBe(false);
await expect.element(screen.getByRole("textbox", { name: "Before" })).toHaveFocus();An overlay in another document answers the keys and presses of that document
function Framed() {
const [body, setBody] = useState<HTMLElement | null>(null);
const overlay = useOverlay({ defaultOpen: true });
return (
<>
{/* Heard twice where a frame begins with one document and loads another. */}
{/* An empty frame the test fills itself: in a sandbox WebKit runs none of the listeners this is about. */}
{/* oxlint-disable-next-line react/iframe-missing-sandbox */}
<iframe
title="Frame"
ref={(node) => setBody(node?.contentDocument?.body ?? null)}
onLoad={(event) => setBody(event.currentTarget.contentDocument!.body)}
/>
{body !== null &&
createPortal(
<>
<button type="button" {...overlay.triggerProps} onClick={() => overlay.setOpen(true)}>
Control
</button>
<div role="dialog" aria-label="Framed" {...overlay.contentProps}>
Content
</div>
<p>Elsewhere</p>
</>,
body,
)}
</>
);
}
const screen = await render(<Framed />);
const frame = screen.container.querySelector("iframe")!;
const inFrame = <T extends Element>(selector: string) =>
frame.contentDocument!.querySelector<T>(selector);
const open = () => inFrame('[role="dialog"]')?.matches(":popover-open") ?? false;
await expect.poll(open).toBe(true);
frame.contentDocument!.body.dispatchEvent(
new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }),
);
await expect.poll(open).toBe(false);
inFrame<HTMLElement>("button")!.click();
await expect.poll(open).toBe(true);
inFrame("p")!.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }));
await expect.poll(open).toBe(false);Overlays of separate React roots on one page each sit against their own control
// Rendered on a server, each root counts its ids from the same beginning.
const ui = (
<Overlay label="Panel" focus="none">
Content
</Overlay>
);
const hosts = [0, 1].map((index) => {
const host = document.createElement("div");
host.style.marginTop = `${index * 120}px`;
host.innerHTML = renderToString(ui);
document.body.append(host);
return { host, root: hydrateRoot(host, ui) };
});
try {
const find = (host: HTMLElement, selector: string) => host.querySelector<HTMLElement>(selector);
await expect.poll(() => find(hosts[1]!.host, "[aria-haspopup]")).not.toBeNull();
for (const { host } of hosts) find(host, "[aria-haspopup]")!.click();
await expect.poll(() => find(hosts[1]!.host, '[role="dialog"]')!.matches(":popover-open")).toBe(true);
// The same id in both: this is what the two would collide on.
expect(find(hosts[0]!.host, '[role="dialog"]')!.id).toBe(find(hosts[1]!.host, '[role="dialog"]')!.id);
for (const { host } of hosts) {
const anchor = find(host, "[aria-haspopup]")!.getBoundingClientRect();
const rect = find(host, '[role="dialog"]')!.getBoundingClientRect();
expect(Math.abs(rect.top - anchor.bottom)).toBeLessThan(1);
}
} finally {
for (const { host, root } of hosts) {
root.unmount();
host.remove();
}
}Blocking overlays
One that blocks makes the page behind it inert and still on every screen, and says it is modal
const screen = await render(
<>
<p>Beside</p>
<Blocking label="Question" />
</>,
);
await userEvent.click(trigger(screen, "Question"));
expect(isInert(screen.getByText("Beside").element())).toBe(true);
expect(isInert(panel(screen, "Question").element())).toBe(false);
expect(getComputedStyle(document.documentElement).overflowY).toBe("hidden");
expect(panel(screen, "Question").element().getAttribute("aria-modal")).toBe("true");
await userEvent.keyboard("{Escape}");
await expect.poll(() => isOpen(screen, "Question")).toBe(false);
expect(document.querySelector("[inert]")).toBeNull();
expect(document.documentElement.style.overflow).toBe("");One that blocks, taken off the page while open, leaves nothing inert behind it
const page = (present: boolean) => (
<>
<p>Beside</p>
<Blocking label="Question" present={present} />
</>
);
const screen = await render(page(true));
await userEvent.click(trigger(screen, "Question"));
expect(isInert(screen.getByText("Beside").element())).toBe(true);
await screen.rerender(page(false));
await expect.poll(() => document.querySelector("[inert]")).toBeNull();
expect(document.documentElement.style.overflow).toBe("");A press outside one that is not dismissed by it leaves it open with focus inside, but closes what was opened from it
const screen = await render(
<>
<p>Beside</p>
<Blocking label="Question" dismissOnPressOutside={false} defaultFocus="first">
<Overlay label="Inner" />
</Blocking>
</>,
);
await userEvent.click(trigger(screen, "Question"));
await userEvent.click(trigger(screen, "Inner"));
await expect.poll(() => isOpen(screen, "Inner")).toBe(true);
await userEvent.click(screen.getByText("Beside"), { force: true });
await expect.poll(() => isOpen(screen, "Inner")).toBe(false);
expect(await isOpen(screen, "Question")).toBe(true);
expect(panel(screen, "Question").element().contains(document.activeElement)).toBe(true);Opened without being told where, focus goes where its owner said it should
// Open from the first, as by its owner's state: no control said where.
const screen = await render(<Blocking label="Question" defaultFocus="first" defaultOpen />);
await expect.element(screen.getByRole("textbox", { name: "Question field" })).toHaveFocus();Sheets
A sheet shows a grabber at its top, which says it can be dragged awayChromium · touch, WebKit · touch
Not yet written.
A sheet follows the finger that drags it down, and let go a little way down it returnsChromium · touch, WebKit · touch
Not yet written.
Dragged down past a third of its height and let go, a sheet closesChromium · touch, WebKit · touch
Not yet written.
Flicked down, a sheet closes though it was not dragged farChromium · touch, WebKit · touch
Not yet written.
Asked for less motion, a sheet let go returns at once rather than glidingChromium · touch, WebKit · touch
Not yet written.
While a sheet is open the rest of the page is inert, and as it was found once the sheet has closedChromium · touch, WebKit · touch
Not yet written.
A sheet opened from a sheet leaves the page inert until both have closedChromium · touch, WebKit · touch
Not yet written.
A swipe down on a sheet opened from a sheet closes that one aloneChromium · touch, WebKit · touch
Not yet written.
A sheet taken off the page while open leaves nothing inert behind itChromium · touch, WebKit · touch
Not yet written.
A sheet that is a dialog says that it is modal, for as long as it is openChromium · touch, WebKit · touch
Not yet written.
The page behind a sheet does not scrollChromium · touch, WebKit · touch
Not yet written.
The platform's way back, where the browser tells of it, closes only the topmost sheetChromium · touch, WebKit · touch
Not yet written.
One that takes the place of the sheet it was opened from covers it, under the one scrim, and a swipe down closes bothChromium · touch, WebKit · touch
Not yet written.
React
Install
npx shadcn@latest add https://ui.publictheta.com/r/overlay.jsonSource
src/primitives/overlay.ts
/**
* An overlay: a surface shown above the interface, attached to the control that
* opened it — or to a point, as a context menu is. `popover="manual"` puts it in
* the top layer, above everything, and CSS anchor positioning places it,
* flipping when it does not fit.
*
* Dismissal is done here rather than by `popover="auto"`, whose light dismiss
* differs between engines when an overlay opens on a press — which a menu must,
* so the pointer can be dragged to an item. The open overlays of a document form
* one stack:
*
* - Escape closes the topmost.
* - A press closes every overlay above the one it lands in. The control that
* opened an overlay counts as inside it, so pressing it toggles.
* - Focus that moves outside every overlay closes them all.
* - Closing an overlay, however it is closed, closes every overlay above it.
*
* Focus moves in when an overlay opens and returns when it closes — to its
* control, or with none to whatever had focus before — unless the person has
* already put it somewhere else.
*
* On a phone an overlay is a sheet, and a sheet is modal: the rest of the page
* is inert and does not scroll, a swipe down closes it, and so does the
* platform's way back where the browser tells of it. An overlay that blocks —
* a dialog — is modal in that way on every screen.
*/
import { useCallback, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import type { CSSProperties, RefCallback } from "react";
import { useControllableState } from "./use-controllable-state.ts";
/** Where an overlay sits relative to its anchor, in logical terms. */
export type Placement = "block-end" | "block-start" | "inline-end";
const FLIPS: Record<Placement, string> = {
"block-end": "flip-block, flip-inline, flip-block flip-inline",
"block-start": "flip-block, flip-inline, flip-block flip-inline",
"inline-end": "flip-inline, flip-block, flip-inline flip-block",
};
const AREAS: Record<Placement, string> = {
// Below, aligned to the anchor's start edge, extending toward its end.
"block-end": "block-end span-inline-end",
"block-start": "block-start span-inline-end",
// Beside, aligned to the anchor's top: a submenu.
"inline-end": "inline-end span-block-end",
};
/**
* The anchoring, as classes reading the variables the content is given. Classes
* rather than inline style so a presentation can override them — a sheet on a
* phone sets its own `position-area` and insets.
*/
export const anchoredClassName =
"[inset:var(--overlay-inset,auto)] m-0 [position-anchor:var(--overlay-anchor)] [position-area:var(--overlay-area)] [position-try-fallbacks:var(--overlay-fallbacks)]";
/** How near a viewport edge an overlay that fits on neither side of its point is held. */
const EDGE = 8;
type Insets = [top: string, right: string, bottom: string, left: string];
/**
* A placement against a point, as insets from it. In physical terms chosen from
* the overlay's own direction: a position area is resolved in the direction of
* the page, which a part of it written the other way does not share.
*/
function insetsFor(placement: Placement, rtl: boolean): Insets {
const insets: Insets = ["auto", "auto", "auto", "auto"];
const inline = rtl ? 1 : 3;
if (placement === "inline-end") {
insets[0] = "anchor(top)";
insets[inline] = rtl ? "anchor(left)" : "anchor(right)";
} else {
insets[placement === "block-end" ? 0 : 2] = placement === "block-end" ? "anchor(bottom)" : "anchor(top)";
insets[inline] = rtl ? "anchor(right)" : "anchor(left)";
}
return insets;
}
interface Layer {
readonly content: HTMLElement;
readonly trigger: () => HTMLElement | null;
readonly close: () => void;
readonly modal: () => boolean;
/** Whether the rest of the page is inert behind it on every screen, not only as a sheet. */
readonly blocking: boolean;
/** Whether a press outside closes it. */
readonly dismissible: () => boolean;
/** On a phone, whether it takes the place of the sheet it was opened from. */
readonly replaces: boolean;
/** Show it again, above whatever has been shown since: its parent, when both began open. */
readonly raise: () => void;
/** Undo what it did to the page around it, ahead of its closing. */
release: () => void;
}
/** The open overlays of one document, lowest first, and what listens for them. */
interface Root {
readonly document: Document;
readonly stack: Layer[];
// The rest of a press that only dismissed: its mouse events must not reach what was under it.
consuming: AbortController | undefined;
readonly listening: AbortController;
}
const roots = new WeakMap<Document, Root>();
function contains(layer: Layer, path: readonly EventTarget[]): boolean {
const trigger = layer.trigger();
return path.includes(layer.content) || (trigger !== null && path.includes(trigger));
}
/** Close a layer and everything above it, topmost first. */
function closeFrom(root: Root, index: number): void {
for (const layer of root.stack.slice(Math.max(index, 0)).toReversed()) layer.close();
}
const SHEET = "(pointer: coarse) and (width < 40rem)";
/** Whether overlays are presented as sheets: on a phone. A sheet is always modal. */
function isSheet(document: Document): boolean {
return document.defaultView?.matchMedia(SHEET).matches ?? false;
}
function subscribeToSheet(onChange: () => void): () => void {
const media = matchMedia(SHEET);
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}
/**
* Whether overlays are sheets here, for a part that only a sheet has: a way
* back, a button that closes. A server renders for a screen where they are not.
*/
export function useSheet(): boolean {
return useSyncExternalStore(
subscribeToSheet,
() => matchMedia(SHEET).matches,
() => false,
);
}
function closeTopmost(root: Root, event: KeyboardEvent): void {
event.preventDefault();
event.stopPropagation();
closeFrom(root, root.stack.length - 1);
}
function isEscape(event: KeyboardEvent): boolean {
// While an input method is composing, Escape is its own: it cancels the composition.
return event.key === "Escape" && !event.isComposing && event.keyCode !== 229;
}
/** A sheet's backdrop is the sheet's own pseudo-element: a press there is outside it. */
function onBackdrop(layer: Layer, event: { target: EventTarget | null; clientX: number; clientY: number }) {
if (event.target !== layer.content) return false;
const box = layer.content.getBoundingClientRect();
return (
event.clientX < box.left ||
event.clientX > box.right ||
event.clientY < box.top ||
event.clientY > box.bottom
);
}
function consume(root: Root): void {
root.consuming?.abort();
const controller = new AbortController();
root.consuming = controller;
const stop = (event: Event) => {
event.preventDefault();
event.stopImmediatePropagation();
if (event.type === "click") controller.abort();
};
for (const type of ["mousedown", "mouseup", "click"]) {
root.document.addEventListener(type, stop, { capture: true, signal: controller.signal });
}
root.document.addEventListener("pointercancel", () => controller.abort(), {
capture: true,
signal: controller.signal,
});
}
function rootOf(document: Document): Root {
const existing = roots.get(document);
if (existing !== undefined) return existing;
const listening = new AbortController();
const root: Root = { document, stack: [], consuming: undefined, listening };
const { stack } = root;
const { signal } = listening;
// Escape with focus outside the topmost overlay is the overlay's at once. With
// focus inside, what is inside hears it first — a field abandons what was
// typed — and the overlay closes only if nothing claimed it.
document.addEventListener(
"keydown",
(event) => {
const top = stack.at(-1);
if (top === undefined || !isEscape(event)) return;
if (!event.composedPath().includes(top.content)) closeTopmost(root, event);
},
{ capture: true, signal },
);
document.addEventListener(
"keydown",
(event) => {
if (stack.length > 0 && isEscape(event) && !event.defaultPrevented) closeTopmost(root, event);
},
{ signal },
);
// Capture, so an open overlay hears a press before anything beneath it.
document.addEventListener(
"pointerdown",
(event) => {
root.consuming?.abort();
const path = event.composedPath();
const pressed = stack.findLastIndex((layer) => contains(layer, path) && !onBackdrop(layer, event));
// One that a press outside does not close keeps open what lies beneath it.
const inside = Math.max(
pressed,
stack.findLastIndex((layer) => !layer.dismissible()),
);
// A press outside what blocks the page goes nowhere: focus stays where it was.
if (stack.slice(pressed + 1).some((layer) => layer.blocking) && event.button === 0) {
event.preventDefault();
}
const closing = stack.slice(inside + 1);
if (closing.length === 0) return;
// A press that dismisses a menu does nothing else, as in a platform's own
// menus — unless it is on another control that opens one, so that moving
// from menu to menu takes one press.
const modal = isSheet(document) || closing.some((layer) => layer.modal() || layer.blocking);
// By what it is rather than by its class: an element of another document is not this one's `Element`.
const opener = path.some(
(node) => (node as Node).nodeType === 1 && (node as Element).hasAttribute("aria-haspopup"),
);
closeFrom(root, inside + 1);
if (modal && !opener && event.button === 0) {
event.preventDefault();
consume(root);
}
},
{ capture: true, signal },
);
// Focus that goes outside every overlay — by Tab, or by a script — leaves them closed behind it.
document.addEventListener(
"focusin",
(event) => {
const path = event.composedPath();
if (!stack.some((layer) => contains(layer, path))) closeFrom(root, 0);
},
{ capture: true, signal },
);
roots.set(document, root);
return root;
}
/** The layer this one was opened from: the nearest beneath it that holds it. */
function parentOf(root: Root, layer: Layer): Layer | undefined {
const index = root.stack.indexOf(layer);
return root.stack.slice(0, Math.max(index, 0)).findLast((below) => below.content.contains(layer.content));
}
/** A sheet and the sheets beneath whose place it has taken: to a finger, one sheet. */
function sheetOf(root: Root, layer: Layer): Layer[] {
const layers = [layer];
for (let current = layer; current.replaces;) {
const parent = parentOf(root, current);
if (parent === undefined) break;
layers.push(parent);
current = parent;
}
return layers;
}
const inert = new WeakMap<Element, number>();
/**
* Make everything but the sheet inert, so assistive technology and the keyboard
* stay within it as a pointer does. Counted, so sheets may nest, and what was
* already inert by its owner's hand is left as it was found.
*/
function isolate(content: HTMLElement): () => void {
const held: Element[] = [];
const { body } = content.ownerDocument;
for (let node: Element = content; node !== body && node.parentElement !== null; node = node.parentElement) {
for (const sibling of node.parentElement.children) {
const count = inert.get(sibling) ?? 0;
if (sibling === node || (count === 0 && sibling.hasAttribute("inert"))) continue;
sibling.setAttribute("inert", "");
inert.set(sibling, count + 1);
held.push(sibling);
}
}
return () => {
for (const element of held) {
const count = (inert.get(element) ?? 1) - 1;
if (count > 0) {
inert.set(element, count);
} else {
inert.delete(element);
element.removeAttribute("inert");
}
}
};
}
const locks = new WeakMap<Document, { count: number; overflow: string; gutter: string }>();
/**
* Keep the page behind a sheet or a dialog from scrolling, and put back exactly
* what it had. A scrollbar that took room keeps its room, so that the page does
* not shift sideways under the scrim.
*/
function lockScroll(document: Document): () => void {
const root = document.documentElement;
const view = document.defaultView;
const lock = locks.get(document) ?? {
count: 0,
overflow: root.style.overflow,
gutter: root.style.scrollbarGutter,
};
if (lock.count === 0 && view !== null && view.innerWidth > root.clientWidth) {
root.style.scrollbarGutter = "stable";
}
locks.set(document, lock);
lock.count += 1;
root.style.overflow = "hidden";
return () => {
lock.count -= 1;
if (lock.count > 0) return;
root.style.overflow = lock.overflow;
root.style.scrollbarGutter = lock.gutter;
locks.delete(document);
};
}
interface CloseWatcherLike extends EventTarget {
destroy: () => void;
}
/** The platform's way back — the back gesture on Android — where the browser tells of it. */
function watchForBack(document: Document, close: () => void): () => void {
const Watcher = (document.defaultView as { CloseWatcher?: new () => CloseWatcherLike } | null)
?.CloseWatcher;
if (Watcher === undefined) return () => {};
try {
const watcher = new Watcher();
watcher.addEventListener("close", close);
return () => watcher.destroy();
} catch {
// A browser may refuse one: there is then no way back to hear of.
return () => {};
}
}
/** A finger that has travelled less than this was still, for a tap. */
const SLOP = 8;
/** Pixels per millisecond: a flick down closes a sheet that has not been dragged far. */
const FLICK = 0.5;
/** Milliseconds of a finger's path that say how fast it was going. */
const TRAIL = 100;
function scrolled(target: EventTarget | null, content: HTMLElement): boolean {
for (let node = target as HTMLElement | null; node !== null; node = node.parentElement) {
if (node.scrollTop > 0) return true;
if (node === content) break;
}
return false;
}
/**
* A sheet follows a finger that drags it down, and on release closes if it was
* dragged past a threshold or flicked, and returns otherwise. Touch events, not
* pointer events: a browser cancels a touch pointer once it decides to scroll,
* and only a touch move can tell it not to.
*/
function swipe(root: Root, layer: Layer): () => void {
const { content } = layer;
const controller = new AbortController();
const { signal } = controller;
let from: { x: number; y: number; time: number } | null = null;
let dragging = false;
let offset = 0;
// Where the finger has just been, for how fast it was going when it lifted.
let trail: Array<{ y: number; time: number }> = [];
const move = (translate: string, transition: string) => {
for (const { content: surface } of sheetOf(root, layer)) {
surface.style.transition = transition;
surface.style.translate = translate;
}
};
content.addEventListener(
"touchstart",
(event) => {
const touch = event.touches[0];
dragging = false;
from =
event.touches.length === 1 &&
touch !== undefined &&
// Its own, not that of an overlay opened from within it, whose touches pass through here.
(event.target as Element).closest("[popover]") === content &&
!onBackdrop(layer, { target: event.target, clientX: touch.clientX, clientY: touch.clientY }) &&
// What has been scrolled down scrolls back up first.
!scrolled(event.target, content)
? { x: touch.clientX, y: touch.clientY, time: event.timeStamp }
: null;
trail = [{ y: 0, time: event.timeStamp }];
},
{ passive: true, signal },
);
content.addEventListener(
"touchmove",
(event) => {
const touch = event.touches[0];
if (touch === undefined) return;
if (from === null) return;
const dx = touch.clientX - from.x;
const dy = touch.clientY - from.y;
if (!dragging) {
// Decided by the first move, which is the one a browser lets be refused: after it, a
// scroll has begun. Upward or sideways, the gesture is something else's.
if (dy <= 0 || Math.abs(dx) > dy || !event.cancelable) {
from = null;
return;
}
dragging = true;
}
if (event.cancelable) event.preventDefault();
offset = Math.max(0, dy);
trail = [
...trail.filter((sample) => event.timeStamp - sample.time < TRAIL),
{ y: dy, time: event.timeStamp },
];
move(`0 ${offset}px`, "none");
},
{ passive: false, signal },
);
const release = (event: TouchEvent) => {
const began = from;
from = null;
if (began === null || !dragging) return;
dragging = false;
// The finger that dragged the sheet did not also tap what was under it.
if (offset > SLOP && event.cancelable) event.preventDefault();
const far = offset > Math.min(content.offsetHeight / 3, 160);
const [first, last] = [trail.at(0), trail.at(-1)];
trail = [];
const flicked =
first !== undefined &&
last !== undefined &&
last !== first &&
event.timeStamp - last.time < TRAIL &&
(last.y - first.y) / (last.time - first.time) > FLICK;
if (event.type === "touchend" && (far || flicked)) {
// Onward from where it was let go, rather than back up to fade.
move("0 100%", "");
closeFrom(root, root.stack.indexOf(sheetOf(root, layer).at(-1) ?? layer));
} else {
move("", "");
}
};
content.addEventListener("touchend", release, { signal });
content.addEventListener("touchcancel", release, { signal });
return () => {
controller.abort();
if (dragging) move("", "");
};
}
/** What makes an overlay modal: the page behind it inert and still. */
function block(root: Root, layer: Layer): () => void {
const { content } = layer;
const undo = [isolate(content), lockScroll(root.document)];
// Only a dialog may say it is modal; the rest of the page being inert says it for any role.
const role = content.getAttribute("role");
if ((role === "dialog" || role === "alertdialog") && !content.hasAttribute("aria-modal")) {
content.setAttribute("aria-modal", "true");
undo.push(() => content.removeAttribute("aria-modal"));
}
return () => {
for (const step of undo.toReversed()) step();
};
}
/** What makes a sheet of an overlay, for as long as it is one. */
function sheet(root: Root, layer: Layer): () => void {
const { content } = layer;
const undo = [watchForBack(root.document, layer.close), swipe(root, layer)];
// What blocks is modal already, sheet or not.
if (!layer.blocking) undo.unshift(block(root, layer));
const parent = layer.replaces ? parentOf(root, layer) : undefined;
if (parent !== undefined) {
// Over the whole of the sheet beneath, under that sheet's scrim. Left in
// place on closing, so that it does not shrink as it fades.
content.dataset.overlayLevel = "";
content.style.minHeight = `${parent.content.getBoundingClientRect().height}px`;
}
return () => {
for (const step of undo.toReversed()) step();
};
}
/** A sheet where overlays are sheets, and not where they are not, as the screen changes. */
function present(root: Root, layer: Layer): () => void {
const blocked = layer.blocking ? block(root, layer) : undefined;
const media = root.document.defaultView?.matchMedia(SHEET);
if (media === undefined) return () => blocked?.();
let undo: (() => void) | undefined;
const update = () => {
undo?.();
delete layer.content.dataset.overlayLevel;
layer.content.style.minHeight = "";
undo = media.matches ? sheet(root, layer) : undefined;
};
update();
media.addEventListener("change", update);
return () => {
media.removeEventListener("change", update);
undo?.();
blocked?.();
};
}
/** Where focus goes when the overlay opens. */
export type InitialFocus = "first" | "last" | "content" | "none";
/** A point in the viewport, or with a size a rectangle of it, to place an overlay against. */
export interface OverlayPoint {
readonly x: number;
readonly y: number;
readonly width?: number;
readonly height?: number;
}
export interface OverlayOptions {
open?: boolean | undefined;
defaultOpen?: boolean | undefined;
onOpenChange?: ((open: boolean) => void) | undefined;
placement?: Placement | undefined;
/** The elements `first` and `last` choose among; defaults to anything focusable. */
focusable?: ((content: HTMLElement) => HTMLElement[]) | undefined;
/**
* Whether a press outside only dismisses, as with a menu, rather than also
* acting on what it lands on, as with a popover beside the work it serves.
*/
modal?: boolean | undefined;
/**
* Whether, on a phone, it takes the place of the sheet it was opened from —
* one sheet under one scrim, closed together by a swipe — rather than rising
* as a second sheet: a level of the same thing, as a submenu is. Its owner
* gives it a way back.
*/
replaces?: boolean | undefined;
/**
* Whether the rest of the page is inert and does not scroll behind it on every
* screen — not only on a phone, where every overlay is a sheet and so modal:
* a dialog, which must be answered or dismissed before anything else. A press
* outside it reaches nothing beneath.
*/
blocking?: boolean | undefined;
/** Whether a press outside closes it. An alert that must be answered is not closed by a stray press. */
dismissOnPressOutside?: boolean | undefined;
/** Where focus goes when it opens without being told: as its owner's state changes, say. */
defaultFocus?: InitialFocus | undefined;
}
export interface Overlay {
readonly open: boolean;
/** Open, saying where focus should go, or close. */
readonly setOpen: (open: boolean, focus?: InitialFocus) => void;
/** Open against a point or a rectangle of the viewport rather than a control, or move there if open. */
readonly openAt: (at: OverlayPoint, focus?: InitialFocus) => void;
readonly contentId: string;
/** Spread on the control that opens the overlay. */
readonly triggerProps: {
readonly ref: RefCallback<HTMLElement>;
readonly "aria-expanded": boolean;
readonly "aria-controls": string;
readonly style: CSSProperties;
};
/** Spread on the overlay itself. */
readonly contentProps: {
readonly ref: RefCallback<HTMLElement>;
readonly id: string;
readonly popover: "manual";
readonly style: CSSProperties;
readonly tabIndex: -1;
};
/**
* Spread on an empty element beside the overlay, for one that `openAt` opens:
* what the overlay is anchored to, moved to the point. It joins the top layer,
* so that it is where it says whatever it is rendered inside.
*/
readonly anchorProps: {
readonly ref: RefCallback<HTMLElement>;
readonly popover: "manual";
readonly "aria-hidden": true;
readonly style: CSSProperties;
};
}
const FOCUSABLE =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
function defaultFocusable(content: HTMLElement): HTMLElement[] {
return Array.from(content.querySelectorAll<HTMLElement>(FOCUSABLE));
}
function canTakeFocus(element: HTMLElement | null): element is HTMLElement {
return (
element !== null && element.isConnected && !element.matches(":disabled") && element.checkVisibility()
);
}
let overlays = 0;
let salt: string | undefined;
function subscribeToNothing(): () => void {
return () => {};
}
/**
* A name no other overlay on the page has. `useId` alone repeats between two
* React roots; a count kept by this module does not, nor does it between two
* copies of the module once salted. A server cannot know either, so the name is
* completed after hydration.
*/
function useAnchorName(id: string): string {
const [serial] = useState(() => (overlays += 1));
const suffix = useSyncExternalStore(
subscribeToNothing,
() => `-${(salt ??= Math.random().toString(36).slice(2, 8))}${serial}`,
() => "",
);
return `--overlay-${id.replace(/[^\w-]/g, "")}${suffix}`;
}
interface Attached {
readonly root: Root;
readonly layer: Layer;
readonly undo: () => void;
}
export function useOverlay({
open: openProp,
defaultOpen = false,
onOpenChange,
placement = "block-end",
focusable = defaultFocusable,
modal = false,
replaces = false,
blocking = false,
dismissOnPressOutside = true,
defaultFocus = "content",
}: OverlayOptions = {}): Overlay {
const contentId = useId();
const anchorName = useAnchorName(contentId);
const triggerRef = useRef<HTMLElement>(null);
const contentRef = useRef<HTMLElement>(null);
const anchorRef = useRef<HTMLElement>(null);
const focusIntent = useRef<InitialFocus>(defaultFocus);
/** What had focus before the overlay opened: where focus returns when there is no control to return to. */
const returnTo = useRef<HTMLElement>(null);
const attached = useRef<Attached>(null);
const [open, setOpenState] = useControllableState(openProp, defaultOpen, onOpenChange);
const [point, setPoint] = useState<Required<OverlayPoint> | null>(null);
// What the element is brought into step with. Kept current before any effect
// below runs, and read by the refs, which hear of an element before effects do.
const live = useRef({
open,
modal,
replaces,
blocking,
dismissOnPressOutside,
placement,
point,
focusable,
close: setOpenState,
});
useLayoutEffect(() => {
live.current = {
open,
modal,
replaces,
blocking,
dismissOnPressOutside,
placement,
point,
focusable,
close: setOpenState,
};
});
const focusInto = useCallback((intent: InitialFocus) => {
const content = contentRef.current;
if (content === null || intent === "none") return;
const candidates = intent === "content" ? [] : live.current.focusable(content);
const target = candidates.at(intent === "last" ? -1 : 0);
// The overlay is in view wherever the page is scrolled to: focus on it is no reason to scroll the page.
if (target === undefined) content.focus({ preventScroll: true });
else target.focus();
}, []);
const restoreFocus = useCallback(() => {
const target = [triggerRef.current, returnTo.current].find(canTakeFocus);
target?.focus();
}, []);
/** Against a point: on the side its direction says, and held within the view where it fits on neither. */
const place = useCallback((content: HTMLElement) => {
const { point: at, placement: side } = live.current;
if (at === null) {
content.style.removeProperty("--overlay-inset");
return;
}
const view = content.ownerDocument.defaultView;
if (view === null) return;
const rtl = view.getComputedStyle(content).direction === "rtl";
const insets = insetsFor(side, rtl);
content.style.setProperty("--overlay-inset", insets.join(" "));
if (!content.matches(":popover-open") || isSheet(content.ownerDocument)) return;
// Measured once the browser has flipped it where flipping is enough.
const box = content.getBoundingClientRect();
const { clientWidth, clientHeight } = content.ownerDocument.documentElement;
if (box.top < 0 || box.bottom > clientHeight) {
insets[0] = "auto";
insets[2] = `${EDGE}px`;
}
if (box.left < 0 || box.right > clientWidth) {
insets[rtl ? 1 : 3] = "auto";
insets[rtl ? 3 : 1] = `${EDGE}px`;
}
content.style.setProperty("--overlay-inset", insets.join(" "));
}, []);
const detach = useCallback(() => {
const current = attached.current;
if (current === null) return;
attached.current = null;
const { root, layer, undo } = current;
const index = root.stack.indexOf(layer);
// What closes with it lets go of the page now, not when its own turn comes:
// focus is about to return to something they may be holding inert.
for (const above of root.stack.slice(index + 1)) above.release();
closeFrom(root, index + 1);
root.stack.splice(index, 1);
undo();
if (root.stack.length === 0) {
root.listening.abort();
roots.delete(root.document);
}
}, []);
const showInTopLayer = useCallback(
(content: HTMLElement) => {
const anchor = anchorRef.current;
// The anchor first: an element of the top layer is anchored only to what is beneath it there.
if (live.current.point !== null && anchor !== null) {
if (anchor.matches(":popover-open")) anchor.hidePopover();
anchor.showPopover();
}
content.showPopover();
place(content);
},
[place],
);
const attach = useCallback(
(content: HTMLElement) => {
detach();
const root = rootOf(content.ownerDocument);
const layer: Layer = {
content,
trigger: () => triggerRef.current,
close: () => live.current.close(false),
modal: () => live.current.modal,
blocking: live.current.blocking,
dismissible: () => live.current.dismissOnPressOutside,
replaces: live.current.replaces,
raise() {
content.hidePopover();
showInTopLayer(content);
focusInto(focusIntent.current);
},
release() {},
};
// Beneath any opened from within it that was shown first, as when both begin open.
const above = root.stack.findIndex((other) => {
const trigger = other.trigger();
return content.contains(other.content) || (trigger !== null && content.contains(trigger));
});
root.stack.splice(above === -1 ? root.stack.length : above, 0, layer);
const undoSheet = present(root, layer);
// A control that can no longer be used cannot hold an overlay open.
const trigger = triggerRef.current;
const observer = new MutationObserver(() => {
if (trigger?.matches(":disabled")) layer.close();
});
if (trigger !== null) observer.observe(trigger, { attributes: true, attributeFilter: ["disabled"] });
let released = false;
layer.release = () => {
if (released) return;
released = true;
observer.disconnect();
undoSheet();
};
attached.current = { root, layer, undo: layer.release };
return { root, layer };
},
[detach, focusInto, showInTopLayer],
);
/** Bring the element into step with the state. */
const sync = useCallback(() => {
const content = contentRef.current;
if (content === null) return;
const shown = content.matches(":popover-open");
const active = content.ownerDocument.activeElement as HTMLElement | null;
if (live.current.open && !shown) {
returnTo.current = active === content.ownerDocument.body ? null : active;
// What a swipe that closed it left behind.
content.style.translate = "";
content.style.transition = "";
showInTopLayer(content);
const { root, layer } = attach(content);
const within = root.stack.slice(root.stack.indexOf(layer) + 1);
for (const inner of within) inner.raise();
if (within.length === 0) focusInto(focusIntent.current);
} else if (live.current.open && attached.current?.layer.content !== content) {
// Shown already, and let go of since: an effect run again.
attach(content);
} else if (!live.current.open && shown) {
const focusInside = active !== null && content.contains(active);
// First, so that what a sheet made inert can take focus again.
detach();
content.hidePopover();
const anchor = anchorRef.current;
if (anchor?.matches(":popover-open")) anchor.hidePopover();
if (focusInside) restoreFocus();
}
}, [attach, detach, focusInto, restoreFocus, showInTopLayer]);
const setOpen = useCallback(
(next: boolean, focus: InitialFocus = defaultFocus) => {
focusIntent.current = focus;
// Asked to open when already open — a submenu the pointer opened, now
// entered by a key — there is still focus to move.
if (next && open) focusInto(focus);
if (next && !open) setPoint(null);
setOpenState(next);
},
[open, defaultFocus, focusInto, setOpenState],
);
const openAt = useCallback(
(at: OverlayPoint, focus: InitialFocus = defaultFocus) => {
focusIntent.current = focus;
setPoint({ x: at.x, y: at.y, width: at.width ?? 0, height: at.height ?? 0 });
if (open) focusInto(focus);
setOpenState(true);
},
[open, defaultFocus, focusInto, setOpenState],
);
// React's state leads and the element follows — synchronously, so a close
// caused by a press happens before the press moves focus anywhere.
useLayoutEffect(() => {
sync();
// `open` is not read here: it is what there is to come into step with.
// oxlint-disable-next-line react/exhaustive-effect-dependencies
}, [open, sync]);
// Moved while open: placed again against where it now is.
useLayoutEffect(() => {
const content = contentRef.current;
if (content !== null) place(content);
// Neither is read here: they are what a placing depends on.
// oxlint-disable-next-line react/exhaustive-effect-dependencies
}, [point, placement, place]);
const mounted = useRef(false);
useLayoutEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
detach();
};
}, [detach]);
const triggerCallback = useCallback((node: HTMLElement | null) => {
triggerRef.current = node;
if (node !== null) return;
// Gone for good, rather than a ref changing hands within one render: an
// overlay cannot stay open over a control that is no longer there.
queueMicrotask(() => {
if (mounted.current && triggerRef.current === null && live.current.open) live.current.close(false);
});
}, []);
const contentCallback = useCallback(
(node: HTMLElement | null) => {
const previous = contentRef.current;
contentRef.current = node;
if (node !== null) {
// It may arrive after the overlay was opened.
sync();
return;
}
if (previous === null) return;
// Heard before the element leaves the page, while focus can still be seen inside it.
const document = previous.ownerDocument;
const focusInside = previous.contains(document.activeElement);
queueMicrotask(() => {
if (contentRef.current !== null) return;
detach();
const active = document.activeElement;
if (focusInside && (active === null || active === document.body)) restoreFocus();
});
},
[sync, detach, restoreFocus],
);
const anchorCallback = useCallback((node: HTMLElement | null) => {
anchorRef.current = node;
}, []);
return {
open,
setOpen,
openAt,
contentId,
triggerProps: {
ref: triggerCallback,
"aria-expanded": open,
"aria-controls": contentId,
style: { anchorName },
},
contentProps: {
ref: contentCallback,
id: contentId,
popover: "manual",
style: {
"--overlay-anchor": point === null ? anchorName : `${anchorName}-at`,
"--overlay-area": point === null ? AREAS[placement] : "none",
"--overlay-fallbacks": FLIPS[placement],
} as CSSProperties,
// Focusable, so focus has somewhere to go when nothing inside asks for it.
tabIndex: -1,
},
anchorProps: {
ref: anchorCallback,
popover: "manual",
"aria-hidden": true,
style: {
// Over what a browser gives a popover: nothing of it is to be seen or pressed.
position: "fixed",
inset: "auto",
left: point?.x ?? 0,
top: point?.y ?? 0,
width: point?.width ?? 0,
height: point?.height ?? 0,
margin: 0,
padding: 0,
border: 0,
background: "none",
pointerEvents: "none",
anchorName: `${anchorName}-at`,
},
},
};
}
/**
* Whether its owner gave an overlay a button that closes it — counted, as each
* such button mounts and unmounts. A sheet given none needs one all the same:
* with the page behind it inert and no Escape key, assistive technology needs a
* control that closes it.
*/
export function useCloseButtons(): { readonly closable: boolean; readonly addClose: () => () => void } {
const [closes, setCloses] = useState(0);
const addClose = useCallback(() => {
setCloses((count) => count + 1);
return () => setCloses((count) => count - 1);
}, []);
return useMemo(() => ({ closable: closes > 0, addClose }), [closes, addClose]);
}