← All posts

Slide-in Overlay Pages with Parallel and Intercepting Routes

How to build an overlay panel with its own URL that opens as an overlay during in-app navigation and still works on a direct page load

Next.jsApp RouterRoutingAccessibility
5 min read

The /about page on this site is not really a page. It is a panel that slides in over whatever you are already viewing. The same approach works for any route you want to behave like an overlay — a settings panel, an image lightbox, a login form triggered from several pages. Click a trigger from the homepage and it overlays the homepage. Click it from a blog post and it overlays the post. It also needs to work when someone bookmarks the URL, shares the link, or types it into the address bar directly. In that case, there is no underlying page to overlay.

Those are two separate requirements pulling in different directions: a real, shareable URL, and an overlay that should not trigger a full page transition when someone is already inside the app. Next.js includes two App Router features built for that: parallel routes and intercepting routes.

Parallel routes: a second slot in the layout

A parallel route is a named slot, declared as a folder prefixed with @, that a layout can render alongside its normal children. On this site, that is src/app/@modal. The root layout accepts it as a prop and renders it next to the page content:

tsx
const RootLayout = ({
  children,
  modal,
}: {
  children: ReactNode
  modal: ReactNode
}) => (
  <html lang="en">
    <body>
      <Header />
      {children}
      {modal}
      <Footer />
    </body>
  </html>
)

On its own, a parallel route gives you a second independently routed tree inside the same layout. It does not explain how visiting /about can render a normal page in one case and an overlay in another. That part comes from intercepting routes.

Intercepting routes: same URL, different tree

An intercepting route lets a folder catch navigation to a path and render something different, but only when navigation happens client-side from within the app. The convention uses a (.) prefix to intercept a route at the same level:

txt
src/app/@modal/(.)about/page.tsx

That means two page.tsx files can resolve /about:

  • src/app/about/page.tsx — the real route for direct navigation
  • src/app/@modal/(.)about/page.tsx — what renders in the modal slot when you click to /about from inside the app

Both can render null:

tsx
// src/app/about/page.tsx
export default function AboutPage() {
  return null
}

// src/app/@modal/(.)about/page.tsx
export default function AboutModal() {
  return null
}

That looks pointless until you see where the actual panel lives.

The panel lives elsewhere

The visible UI does not come from about/page.tsx or the intercepted (.)about/page.tsx. Instead, it lives in a client component called AboutPanel, mounted once in the root layout:

tsx
<body>
  <AboutPanel />
  <Header />
  {children}
  {modal}
  <Footer />
</body>

AboutPanel does not care which route matched. It reads the current pathname and decides whether it should be open:

tsx
const isOpen = pathname === paths.about.getHref()

That gives you one visual result for two navigation paths:

Soft navigation, where someone clicks an in-app link to /about: the intercepted route matches, (.)about/page.tsx renders null, the URL changes to /about, and the already mounted AboutPanel sees the new pathname and opens. There is no full page transition, only the panel animation.

Hard navigation, where someone types the URL, refreshes, or opens a shared link: interception does not apply on a full page load, so the real about/page.tsx renders in children, also as null. AboutPanel mounts fresh, sees that the pathname is already /about, and opens immediately.

Both routes exist only to make /about a valid and interceptable URL. The actual state, including open and close behavior, focus management, and accessibility, lives in one component driven by usePathname().

The closing edge case

The obvious way to close an overlay with its own URL is router.back(). That works well for soft navigation because the /about visit usually has a real history entry behind it.

It fails on a direct load. If someone lands on /about first in a fresh tab or from a shared link, there may be no in-app history entry to return to. In that case, router.back() can send them out of the site or to an unrelated previous page.

The fix is to detect whether /about was already the URL when the panel first mounted. Because AboutPanel mounts once in the root layout, a ref can capture that initial state without extra lifecycle work:

tsx
const isOpen = pathname === paths.about.getHref()
const openedDirectlyRef = useRef(isOpen)

const handleClose = () =>
  openedDirectlyRef.current
    ? router.push(paths.home.getHref())
    : router.back()

If the first render already happens on /about, the panel was opened directly and closing should send the user to the homepage. If the panel was already mounted before the pathname changed to /about, it was opened from inside the app and closing should go back to the previous page instead.

Wiring the close handler

You can reuse handleClose for both the close button and the Escape key through a small focus-trap hook that accepts an onClose callback:

tsx
const panelRef = useRef<HTMLDivElement>(null)
useFocusTrap(panelRef, isOpen, handleClose)

<button onClick={handleClose} aria-label="Close about panel">
  Close
</button>

That focus-trap hook can handle the usual overlay accessibility work while isOpen is true: trap Tab inside the panel, move focus into the panel when it opens, and restore focus when it closes.

When to use this pattern

This pattern is useful any time a route should behave like an overlay, such as a settings panel, image lightbox, or auth modal opened from multiple pages. The main things to get right are consistent: keep the UI in one place, drive it from the pathname, and do not assume router.back() is always safe just because a user can reach the route by clicking a link.


Cheers,

Fabrice