Layer
The engine every modal surface sits on: a native dialog element, opened as a modal. Top layer, backdrop, focus trap and Esc come from the browser, not from the kit.
API
Props
| prop | type | default | |
|---|---|---|---|
open? | boolean | false | whether the layer is asked to be shown |
onClose? | () => void | — | asked to close: Esc, or a click on the backdrop |
dismissible? | boolean | true | when false, a backdrop click is ignored |
className? | string | — | merged over the defaults, so it wins |
…rest | props of dialog | — | forwarded to the native element |
Examples
Usage
Layer holds behaviour and nothing else: it does not draw a panel, and what you put inside is entirely yours. For the usual modal panel there is Dialog; reach for a bare Layer when the shape is not a dialog.
const { close, isActive, open } = useGraftDialog<void>();
<Button onClickBlur={() => open()}>
<Button.Label label="open" />
</Button>
<Layer open={isActive} onClose={close} aria-label="Preview">
<YourOwnPanel />
</Layer>The backdrop
Dimmed and blurred by default, and both are ordinary classes on the layer, written with the backdrop: variant. There is no prop and no variable for them: you already have the className, and it is merged, so a heavier dim or no blur at all is the same edit you would make anywhere else in the kit.
<Layer open={isActive} onClose={close} className="backdrop:bg-black/80 backdrop:backdrop-blur-none">
<YourOwnPanel />
</Layer>:root {
--margo-layer-duration: 320ms;
--margo-layer-easing: cubic-bezier(0.32, 0.72, 0, 1);
}Placement
The layer is a grid the size of the viewport that centres what it holds, and nothing more: no padding, no width, no radius. Where a panel sits and how large it is belong to the panel — Dialog keeps its own margin, Sheet anchors itself to an edge. Stretching the grid is the one placement decision left to the layer, and it is a className.
<Layer open={isActive} onClose={close}>
<Dialog className="max-w-lg">…</Dialog>
</Layer>
<Layer open={isActive} onClose={close} className="place-items-stretch">
<FullScreenTakeover />
</Layer>Details
What the browser gives you
The element underneath is a native dialog opened with showModal(), so four things arrive for free and correct: the top layer, which ends every z-index argument; the backdrop; the focus trap, with focus returned to whatever opened it; and the rest of the page made inert.
The kit only adds what the platform leaves to you. Esc is intercepted and turned into an onClose call, and a click on the backdrop is recognised by comparing the event target with the dialog itself — the backdrop is not an element you can listen to.
Controlled, always
open is yours and the component never flips it: onClose is a request, not a notification. That is what lets a layer ask for confirmation before closing, or stay open while a mutation is in flight.
Naming it is yours too. A layer that shows a visible title points at it with aria-labelledby; one that does not carries an aria-label. Nothing is generated behind your back.
What is inside, and when
A closed layer holds nothing. The dialog element is always there, empty, and what you put inside is never rendered until it is actually asked for — a panel that costs thirty milliseconds to build costs them the first time it is opened, not on every page that happens to declare it.
The layer owns that lifetime end to end. Children are mounted when open turns true, kept for the whole exit, and dropped only once the browser reports every animation finished, so nothing is in the DOM while the layer is closed and no exit is cut short.
if (open) childrenRef.current = children;
if (open && !isMounted) setIsMounted(true);
// closing
layer.close();
const animations = layer.getAnimations({ subtree: true });
void Promise.allSettled(animations.map((animation) => animation.finished)).then(() => {
setIsMounted(false);
});Which means open states an intent, not a presence: it is false from the moment you ask to close, while the dialog is still on screen. If a state of yours has to survive exactly as long as the panel does, put it in a provider inside the layer and it will be unmounted at the right time, on its own.
During the exit the layer renders the same element objects it held while open, so React skips the subtree and a heavy panel costs nothing to close. With no animation declared the browser reports none, the children are dropped at once, and the same code covers both cases.
The transition
Closing a native dialog removes it from the top layer immediately, which is why exit animations are usually faked with a timer. Here it is CSS: allow-discrete on overlay and display keeps the element around for the length of the exit, and @starting-style gives the entry its first frame.
/* fixed on its own, so leaving the top layer never moves it */
.margo-layer {
position: fixed;
inset: 0;
z-index: var(--margo-layer-z-index);
transition:
visibility var(--margo-layer-duration) var(--margo-layer-easing),
overlay var(--margo-layer-duration) var(--margo-layer-easing) allow-discrete,
display var(--margo-layer-duration) var(--margo-layer-easing) allow-discrete;
}
/* the backdrop fades, and so does whatever the layer holds */
.margo-layer[open]::backdrop,
.margo-layer[open] > *:not([data-margo-travel]) {
opacity: 1;
}
@starting-style {
.margo-layer[open]::backdrop,
.margo-layer[open] > *:not([data-margo-travel]) {
opacity: 0;
}
}The backdrop fades, and so does what the layer holds, so a panel of your own needs no animation to arrive decently. A panel that travels opts out by carrying data-margo-travel — that is how a Sheet slides in at full opacity, since something arriving from an edge already says it is arriving.
No duration is written in JavaScript, and a browser without @starting-style simply gets no animation instead of a broken layer. Motion is disabled entirely under prefers-reduced-motion.
Scrolling
Behind the layer the page is locked, in CSS, by asking the document whether a layer is open — no scroll position saved and restored in JavaScript.
html:has(.margo-layer[open]) {
overflow: clip;
}The motion is a token
Only the timing stayed a variable, and it is a design token rather than a knob on the component: --margo-layer-duration is declared in the theme, drives the layer, its backdrop and the slide of a Sheet, and drops to nothing under prefers-reduced-motion. Override it once and every layered surface follows.