RouteModal
A route-driven modal (right drawer or centered dialog) that plays its exit before navigating back, with a list fallback.
Wraps: (none)
Clicking a row in a list opens its detail over the list; the address bar changes too.
Use it
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.
Install
Terminal pnpm add @zedtech/zedkit-ui @fontsource-variable/inter @fontsource-variable/space-groteskLoad 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.*";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 contentsimport "./globals.css"; import "./fonts"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en" data-identity="glass"> <body>{children}</body> </html> ); }Load the fonts
The Glass look uses Inter and Space Grotesk; these imports self-host them.
src/app/fonts.tsCreate the fileimport "@fontsource-variable/inter"; import "@fontsource-variable/space-grotesk";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
| Content | Render as | Why |
|---|---|---|
| Filters, pickers, short forms on the current page | Drawer + DrawerTrigger (local state) | See Slide in a drawer panel |
| Record detail from a list | RouteModal drawer (default) | Keeps list context; roomy; fullscreen on mobile |
| Destructive confirmation on a route | RouteModal variant="dialog" | Small, focused; Esc and backdrop dismiss |
| Destructive confirmation on local state | ConfirmDialog | No route — trigger plus callback |
| Multi-step flow, SEO, deep links | Full page | Overlays 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, sobackrestores it. - Modal and full page look different — Import the same detail
component in both
[id]/page.tsxand@modal/(.)[id]/page.tsx. Cannot find module '../_detail-view'— From@modal/(.)[id]/page.tsxuse../../_detail-view(the file sits onitems/, not under@modal/). From[id]/page.tsxuse../_detail-view.