[3.x] Add Layers (Modals / Dialogs / Slideovers) - #3236
Open
claudiodekker wants to merge 2 commits into
Open
Conversation
This was referenced Aug 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
stacksbranch (2021).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:
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/createdirectly (a shared link, a refresh, a new tab) sees the layer immediately while the client fetches/usersto 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 aclose()method, androuter.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:
<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.keymatches an open layer rewrites it in place instead; that's how a wizard steps through three URLs inside a single layer.Inertia::close()closes the layer that made the request and refreshes what's beneath it, flash included. Andredirect()->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.createInertiaApp({ layer })takes one shell component that receivesopen,index,isTop,type,close, anddone. 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. Aloadingoption 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/modalcontributors for proving this could work at all as a package. Living inside core simply lets us go places a package can't reach: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.baseand 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.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
Companion PRs
->layer(),Inertia::close(),->interstitial(), and testing assertions: [3.x] Add support for Layers inertia-laravel#903