Skip to content

Modals and popouts

A modal is not a command on top of the history — it is a child route of its panel. An open modal is visible in the URL, survives a reload, closes on the system back and is restored by a deep link.

Modals are declared in the options of the panel that owns them:

export const meetingPanel = plansView.panel(
"meeting/$id",
{
modals: {
edit: lazy(() => import("./edit.ui")),
share: lazy(() => import("./share.ui")),
attendees: {
component: lazy(() => import("./attendees.ui")),
modals: { invite: lazy(() => import("./invite.ui")) }, // nested
},
},
popouts: { delete: DeleteMeetingPopout },
},
() => import("./meeting.ui"),
);

A { component, modals } entry makes the nested modals child routes of that modal — the declaration is recursive. Chain steps are sibling routes of the panel.

Pass routeMasks into the router, or opening a modal puts its own path in the address bar:

export const router = createRouter({
routeTree: app.routeTree,
routeMasks: app.routeMasks, // masks: the URL stays at the panel
// …
});

The host owns navigation and choreography; the component renders its own surface:

import { ModalCardSurface, useModalProps, useNavigator } from "@vkma.dev/router/preset";
export default function ShareMeetingCard(): ReactElement {
const { note } = useModalProps<{ note?: string }>() ?? {};
const navigator = useNavigator();
return (
<ModalCardSurface
title="Share the meeting"
description={note ?? "Pick a recipient"}
actions={/* … */}
/>
);
}

ModalPageSurface and ModalCardSurface take header, size and chrome as ordinary runtime props — modal content is contextual, not declarative. Panel hooks (usePanelParams(meetingPanel)) work inside a modal too: it reads the owning panel’s params.

const navigator = useNavigator();
void navigator.showModal({ to: "/meeting/$id/edit", params: { id }, props: { note: "…" } });
navigator.hideModal();

props must be serializable (structured clone): they ride in history.state and are read with useModalProps() — they survive reloads and back/forward. A departing modal retains its last props while it animates out.

A global modal opens from any panel and is addressed via ?modal=<id>:

void navigator.showGlobalModal("create", { props: { prefill: "…" } });

Declare it in createApp({ modals: { create: CreateMeetingModal } }). The URL is masked back to the panel path, so a ?modal=create deep link works too. hideModal() on the first history entry (a deep-linked modal) replaces the entry with the panel.

Two rules the host guarantees:

  • Siblings replace the entry. A modal → modal transition within one panel replaces — there is no modal stack, and back returns to the panel (old-router parity).
  • Nested modals push. A target whose path starts with the open modal’s fullPath is pushed: back unwinds exactly one level before revealing the parent.

Every retarget is sequential: the old sheet gets open=false, its onClosed completes the exit, and only then the target gets open=true — never two open sheets. Re-targeting while a sheet exits cancels the exit (VKUI reverses the transition mid-flight).

A deep link into a nested modal inflates the full ancestor chain — the panel plus every modal level, outer to inner — so back walks the natural stack.

A popout is a transient overlay without a route: ScreenSpinner, Alert. The id and serializable props land in history.state, components come from the registries — the current panel’s own registry (PanelOptions.popouts) first, then the global createApp({ popouts }). An id collision between them is a dev-time console error.

void navigator.showPopout("delete", { id: "m1" });
navigator.hidePopout();

The host injects onClose — wire it into your VKUI component’s onClosed:

export function DeleteMeetingPopout({ id, onClose }: { id?: string; onClose: () => void }) {
return (
<Alert
onClosed={onClose}
actions={[
{
title: "Delete",
action: () => {
/* … */ onClose();
},
},
]}
/>
);
}

Back closes the popout; back/forward restores its props. Never store React elements in history state — ids and data only.

URL modes and host sync live in history.