Skip to content
zedkit
@zedtech/zedkit-ui

RouteModal

A route-driven modal (right drawer or centered dialog) that plays its exit before navigating back, with a list fallback.

Stable@zedtech/zedkit-ui

Wraps: (none)

Clicking a row in a list opens its detail over the list; the address bar changes too.

View props table
Who is it for?
Developers opening list details in an overlay with a shareable URL (Next.js parallel/intercepting routes).
When should I use it?
Inside an intercepting `@modal` slot, with `onClose` wired to `closeRoute`.
When should I pick something else?
For state-driven modals — use `Dialog`/`Drawer` directly with local state.

Use it

Order in a drawer
import { RouteModal, closeRoute } from "@zedtech/zedkit-ui";
import { useRouter } from "next/navigation";
const router = useRouter();
<RouteModal title="Delivery" onClose={() => closeRoute({ ...router, fallback: "/items" })}>
  <DetailView />
</RouteModal>

Centered dialog

Same intercept, centered instead of a side drawer.

import { RouteModal, closeRoute } from "@zedtech/zedkit-ui";
<RouteModal title="Invoice" variant="dialog" onClose={() => closeRoute({ ...router, fallback: "/invoices" })}>
  <DetailView />
</RouteModal>

Kit not installed yet?

This ships on npm only; there is no copy-source brick for it.

You get
Every component from one import, with the theme tokens included. Updates arrive with your package manager.
You need
  • React 19 and Tailwind CSS 4, for example a Next.js app created with the Tailwind option.
  1. Install

    Terminal
    pnpm add @zedtech/zedkit-ui @fontsource-variable/inter @fontsource-variable/space-grotesk
  2. Load the styles

    Tailwind does not scan node_modules, so the @source line points it at the kit's class names. The path is relative to this file.

    src/app/globals.cssReplace the contents
    @import "tailwindcss";
    @import "@zedtech/zedkit-ui/styles.css";
    @source "../../node_modules/@zedtech/zedkit-ui/src/**/*.{ts,tsx}";
    @source not "../../node_modules/@zedtech/zedkit-ui/src/**/*.test.*";
    
  3. Pick a look

    Set data-identity="glass" on <html> so the finished app matches the site. Fresh app: replace src/app/layout.tsx. Existing app: merge, and keep ThemeProvider and other providers.

    src/app/layout.tsxReplace the contents
    import "./globals.css";
    import "./fonts";
    
    export default function RootLayout({ children }: { children: React.ReactNode }) {
      return (
        <html lang="en" data-identity="glass">
          <body>{children}</body>
        </html>
      );
    }
    
  4. Load the fonts

    The Glass look uses Inter and Space Grotesk; these imports self-host them.

    src/app/fonts.tsCreate the file
    import "@fontsource-variable/inter";
    import "@fontsource-variable/space-grotesk";
    
  5. Import and render

    Order in a drawer
    import { RouteModal, closeRoute } from "@zedtech/zedkit-ui";
    import { useRouter } from "next/navigation";
    
    const router = useRouter();
    <RouteModal title="Delivery" onClose={() => closeRoute({ ...router, fallback: "/items" })}>
      <DetailView />
    </RouteModal>
    

Package version 2.0.1.

Props

Prop

Type

Accessibility

Inherits the Dialog/Drawer focus trap and Escape handling; exit plays before onClose navigates (immediate under reduced motion). Widths come from --container-route-modal-* tokens; exit timing reads --motion-slow/--motion-base live. The drawer variant uses DrawerHandle, sticky header, DrawerBody scroll, and DrawerCloseButton (44px). The dialog variant inherits Dialog's visual-viewport cap, sticky chrome, and 44px close.

Drawer vs dialog vs page

ContentRender asWhy
Filters, pickers, short forms on the current pageDrawer + DrawerTrigger (local state)See Slide in a drawer panel
Record detail from a listRouteModal drawer (default)Keeps list context; roomy; fullscreen on mobile
Destructive confirmation on a routeRouteModal variant="dialog"Small, focused; Esc and backdrop dismiss
Destructive confirmation on local stateConfirmDialogNo route — trigger plus callback
Multi-step flow, SEO, deep linksFull pageOverlays are for quick tasks, not journeys

For a local slide-in (filters, quick edit) without a dedicated URL, use Drawer with trigger state instead of an intercepting route.

Next.js parallel and intercepting routes

Verified against @zedtech/zedkit-ui 0.4.0 and the docs workspace Next version at writing time.

Open a list detail in an overlay without leaving the list: the URL stays shareable (direct access renders the full page), closing returns to the list with its query state intact. The kit owns RouteModal and closeRoute; the app owns the route files and one shared detail view.

File tree for an items list with a detail overlay:

app/items/
├── layout.tsx              # renders {children} + {modal}
├── page.tsx                # the list (links to /items/[id])
├── [id]/
│   └── page.tsx            # full detail page (direct access, refresh)
├── @modal/
│   ├── default.tsx         # null when no intercept is active
│   └── (.)[id]/
│       └── page.tsx        # intercept: same detail view in RouteModal
└── _detail-view.tsx        # shared view (both pages import this)

Layout with the modal slot:

// app/items/layout.tsx
export default function ItemsLayout({
  children,
  modal,
}: {
  children: React.ReactNode;
  modal: React.ReactNode;
}) {
  return (
    <>
      {children}
      {modal}
    </>
  );
}

Intercept page (reuses the shared view so drawer and full page never drift):

// app/items/@modal/(.)[id]/page.tsx
"use client";

import { use } from "react";
import { RouteModal, closeRoute } from "@zedtech/zedkit-ui";
import { useRouter } from "next/navigation";
import { DetailView } from "../../_detail-view";

export default function ItemModal({ params }: { params: Promise<{ id: string }> }) {
  const { id } = use(params);
  const router = useRouter();
  return (
    <RouteModal
      title="Item"
      onClose={() => closeRoute({ ...router, fallback: "/items" })}
    >
      <DetailView id={id} />
    </RouteModal>
  );
}

Default slot (no modal on direct load of the list or full page):

// app/items/@modal/default.tsx
export default function ModalDefault() {
  return null;
}

Full-page detail (same DetailView):

// app/items/[id]/page.tsx
import { DetailView } from "../_detail-view";

export default async function ItemPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  return <DetailView id={id} />;
}

Closing and navigation

closeRoute prefers router.back() when the previous history entry is same-document; otherwise it replaces the fallback path (bookmark, external referrer, or single history entry):

closeRoute({ back: router.back, replace: router.replace, fallback: "/items" });

RouteModal calls onClose only after the exit animation (or immediately when prefers-reduced-motion is set). Wire onClose to closeRoute, not raw router.back, so the panel finishes closing before navigation.

Troubleshooting

  • Refresh shows the full page, not the drawer — Expected: intercepts run only when navigating from the list. Direct /items/[id] loads the full-page route.
  • Closing drops list filters — List state must live in the URL (search params) on page.tsx, not only in React state, so back restores it.
  • Modal and full page look different — Import the same detail component in both [id]/page.tsx and @modal/(.)[id]/page.tsx.
  • Cannot find module '../_detail-view' — From @modal/(.)[id]/page.tsx use ../../_detail-view (the file sits on items/, not under @modal/). From [id]/page.tsx use ../_detail-view.

On this page