Skip to content

[3.x] Add Layers (Modals / Dialogs / Slideovers) - #3236

Open
claudiodekker wants to merge 2 commits into
inertiajs:3.xfrom
claudiodekker:layers
Open

[3.x] Add Layers (Modals / Dialogs / Slideovers)#3236
claudiodekker wants to merge 2 commits into
inertiajs:3.xfrom
claudiodekker:layers

Conversation

@claudiodekker

@claudiodekker claudiodekker commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

This PR makes it so Inertia can render a page on top of another page instead of replacing it. We call that a layer. Modals, slideovers, drawers, and multi-step wizards are all just layers with different styling: Inertia ships the mechanism plus one unstyled <dialog>-based component, and your app decides what a layer looks like.

Layers work in Vue, React, and Svelte, with SSR, with no breaking changes, and on any backend that speaks the Inertia protocol. The back button just works.

Some history

We've wanted this for a long time, and we've tried before:

Meanwhile, inertiaui/modal came around, and shipped a method we considered/tried/had at the time but didn't ship (as we didn't consider the approach "good enough" for core at the time). Yet, time has proven us wrong, and has shown just how much people really want this, and how happy they are even if the solution was far from perfect.

These last few days I've been giving it another shot, and together with SOTA LLMs at my side, I believe this PR to be the answer to what we always wanted to ship from the inside.

How it works

The server marks a response as a layer:

return Inertia::render('Users/Create')->layer(base: '/users');

That's the whole backend story.

The route is a real page with a real URL. When it's opened from another page, the client renders it on top of that page. And because the response declares its base, someone who lands on /users/create directly (a shared link, a refresh, a new tab) sees the layer immediately while the client fetches /users to sit underneath it. Deep links just work, on every backend, with no extra server machinery.

Opening a layer doesn't need anything special on the client either: A plain <Link href="/users/create"> does it, because the server decides what's a layer: a route is always a layer, or never one. If you want programmatic control, router.layer(url) returns a handle with events and a close() method, and router.layer({ component, props }) opens a local layer composed entirely on the client, with no server round trip. Perfect for confirmation prompts.

A few things fall out of the design that we could never get right before:

  • A layer is a page. It has its own props, URL, errors, flash, layouts, <Head> title, remembered state, and scroll regions. Every data feature (deferred props, polling, WhenVisible, InfiniteScroll, prop helpers, optimistic updates) works inside a layer and targets that layer, without touching the page beneath it. Links and forms rendered inside a layer automatically target the layer.
  • History is native. Every layer open is a history entry. Back closes the top layer (with its exit animation), forward reopens it, and closing a layer from code unwinds the entries it created. The page beneath keeps its state the whole time.
  • Layers stack, and keys give them identity. Opening a layer from a layer stacks a new one on top. A response whose key matches an open layer rewrites it in place instead; that's how a wizard steps through three URLs inside a single layer.
  • The server can close layers too. Inertia::close() closes the layer that made the request and refreshes what's beneath it, flash included. And redirect()->route('password.confirm')->interstitial() marks a redirect as a detour, so a pending layer survives a full-page interruption (like password confirmation) and opens where it was originally asked for.
  • The look is yours. createInertiaApp({ layer }) takes one shell component that receives open, index, isTop, type, close, and done. The shipped <Layer> component is an unstyled native <dialog> that handles focus trapping, Escape, scroll locking, and ARIA. You style it, or replace it entirely. A loading option lets you show a placeholder while a cold-opened layer's base page is being fetched.

What being first-party unlocks

A huge thank-you to Pascal and the inertiaui/modal contributors for proving this could work at all as a package. Living inside core simply lets us go places a package can't reach:

  • A layer is a page in the protocol itself. Every Inertia feature works inside a layer out of the box (useForm, <Link>, deferred props, polling, WhenVisible, InfiniteScroll, useRemember, layouts, <Head>) with no wrapped or forked components. They're the same components you already use, and they automatically target the layer they're rendered in.
  • History and the back button are native. Layers participate in the router's own history handling, so back closes one layer at a time with its exit animation, forward reopens it, and close callbacks fire no matter how the layer was closed, including via the browser's buttons.
  • Deep links resolve on the client. The response declares its base and the client fetches it as a normal Inertia request. No sub-request machinery on the server, which also means it works identically on every backend, not just Laravel.
  • It works everywhere Inertia works: Vue, React, and Svelte, with full SSR support. A cold-opened layer server-renders inside its shell and hydrates in place.

No breaking changes

Everything is additive. There are no new HTTP headers, and the new page-object fields (layer, close, interstitial) are optional; a response without them behaves exactly as it does today. An app that never returns a layer response renders no dialog and behaves identically to before, and there's an e2e test asserting exactly that. Existing apps upgrade to this without touching a line.

Tests

  • ~540 new unit tests in core, covering response composition, stacking and rewrites, the close sequence, history and unwinding, cold-open walking, per-layer prop scoping, handles and events, interstitials, and SSR helpers.
  • 130 new Playwright end-to-end tests, each running against all three adapters (Vue, React, Svelte) and all three browser engines, covering warm opens, cold opens, stacked layers, the back and forward buttons, focus and scroll behavior, exit animations, SSR and hydration, and the loading placeholder.
  • The companion demo PR (below) adds another 62 browser tests against a real Laravel app.

Companion PRs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant