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

proptypedefault
open?booleanfalsewhether the layer is asked to be shown
onClose?() => voidasked to close: Esc, or a click on the backdrop
dismissible?booleantruewhen false, a backdrop click is ignored
className?stringmerged over the defaults, so it wins
…restprops of dialogforwarded 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.

tsx
const { close, isActive, open } = useGraftDialog<void>();

<Button onClickBlur={() => open()}>
  <Button.Label label="open" />
</Button>

<Layer open={isActive} onClose={close} aria-label="Preview">
  <YourOwnPanel />
</Layer>
previewEsc and the backdrop close them

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.

tsx
<Layer open={isActive} onClose={close} className="backdrop:bg-black/80 backdrop:backdrop-blur-none">
  <YourOwnPanel />
</Layer>
previewno blur, heavier dim
css
: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.

tsx
<Layer open={isActive} onClose={close}>
  <Dialog className="max-w-lg"></Dialog>
</Layer>

<Layer open={isActive} onClose={close} className="place-items-stretch">
  <FullScreenTakeover />
</Layer>
previewedge to edge

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.

tsx
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.

css
/* 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.

css
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.