Skip to content

Modal routing ​

This guide walks through enabling modal routing end-to-end. The general idea: the URL always describes a single route, but <RouterView modals> can split the active route into a background and a modal layer based on the navigation that produced it.

Mental model ​

A modal navigation is a regular router.push (or router.replace) with a modal flag on RouteLocationOptions:

ts
router.push({ path: '/users/42', modal: true });

The patched router converts this into history state that records:

  • The backgroundPath — the route that was active when the modal opened.
  • The depth — how many parent records of the current route render inside the modal wrapper.

That state travels with the navigation, so a refresh while the modal is open re-opens it on top of the same background.

A <RouterView modals> reads this state and renders two trees in parallel:

  1. The background tree — uses BackgroundProvider to override useRoute() to the stored background route.
  2. The modal tree — uses ModalProvider and viewDepthKey to render the modal route at the right depth, wrapped in the configured wrapper component.

1. Configure routes ​

Decide which routes can appear as modals and either declare a per-route wrapper via meta.modal or rely on defaultModal:

ts
import { createRouter } from '@basmilius/routing';
import { createWebHistory } from 'vue-router';
import OverlayWrapper from './layout/OverlayWrapper.vue';
import LightboxWrapper from './layout/LightboxWrapper.vue';

export const router = createRouter({
    history: createWebHistory(),
    routes: [
        {
            path: '/',
            component: () => import('./pages/Home.vue')
        },
        {
            path: '/users/:id',
            component: () => import('./pages/User.vue'),
            meta: {
                modal: { component: OverlayWrapper }
            }
        },
        {
            path: '/photos/:id',
            component: () => import('./pages/Photo.vue'),
            meta: {
                modal: { component: LightboxWrapper, props: { backdrop: 'dark' } }
            }
        }
    ],
    defaultModal: { component: OverlayWrapper }
});

2. Mount a host RouterView ​

Exactly one <RouterView> needs modals. Place it where the modal layer should mount — typically just outside your top-level layout container.

vue
<template>
    <header>...</header>
    <main>
        <RouterView modals/>
    </main>
</template>

<script
    setup
    lang="ts">
    import { RouterView } from '@basmilius/routing';
</script>

The host runs the background route as a vanilla <RouterView> and renders the modal layer in a Fragment next to it. Other <RouterView> instances in the tree (e.g. nested layouts) render as vanilla.

3. Build a wrapper ​

A wrapper is a plain Vue component that consumes ModalWrapperProps and exposes a default slot for the modal's inner view. Use <ModalRouterView> when you need an animated child view inside the wrapper.

vue
<!-- OverlayWrapper.vue -->
<template>
    <Teleport to="body">
        <Transition name="overlay">
            <div
                v-if="modalActive"
                class="overlay"
                @click.self="$emit('close')">
                <div class="overlay__content">
                    <Transition name="overlay-inner">
                        <ModalRouterView v-if="modalReady"/>
                    </Transition>
                </div>
            </div>
        </Transition>
    </Teleport>
</template>

<script
    setup
    lang="ts">
    import { ModalRouterView, type ModalWrapperProps } from '@basmilius/routing';

    defineProps<ModalWrapperProps>();
    defineEmits<{
        (event: 'close'): void;
    }>();
</script>

Notes:

  • modalActive controls the outer <Transition> — keeps the wrapper mounted across the close animation.
  • modalReady is the gate the package flips one tick after modalActive becomes true. Use it to v-if the inner <ModalRouterView> so the inner <Transition> plays its enter animation.
  • modalViewKey is the key for the inner <component> when the wrapper renders the view itself through <ModalRouterView v-slot="{ Component }">. modalRoute.fullPath also changes for navigations inside the modal, so keying on it reloads the entire shell on every tab switch.
  • The wrapper receives an automatic onClose listener that calls router.back(). Bind it via @close="..." or rely on the default emit-driven flow.

4. Open modals from the UI ​

<RouterLink> accepts a modal prop:

vue
<RouterLink :to="{ path: '/users/42' }" modal>Open user</RouterLink>
<RouterLink :to="{ path: '/users/42' }" :modal="1">Open user with parent layout</RouterLink>

Or push imperatively:

ts
router.push({ path: '/users/42', modal: true });

Modifier-clicks (cmd, ctrl, middle-click) still open the URL in a new tab — the link only opts into modal navigation for primary clicks. Modal routes must therefore be valid stand-alone URLs.

Named-view layouts inside a modal ​

The depth (:modal="1", :modal="2", …) controls how many parent records render inside the wrapper. The modal renders each record's default view, but when a record declares no default — a layout registered solely under a named view — it falls back to that record's first declared view. This lets a layout that also renders outside the modal (via its own named <RouterView>) double as the modal's in-wrapper layout:

ts
{
    path: ':id',
    // No `default` — this layout is registered under `overlay`.
    components: { overlay: () => import('./RequestLayout.vue') },
    children: [
        { name: 'request', path: '', component: () => import('./Summary.vue') }
    ]
}
vue
<RouterLink :to="{ name: 'request', params: { id }, modal: 1 }">Open request</RouterLink>

:modal="1" starts the modal at the :id record, and the named-view fallback renders its overlay layout; RequestLayout.vue's own <RouterView> then renders the request child. The same overlay view can still be mounted elsewhere for non-modal navigation — the two are independent.

5. Close modals ​

The wrapper emits close when the user dismisses the modal. The default listener installed by RouterView calls router.back(). To close imperatively from anywhere:

ts
router.back();

Promoting a modal ​

Sometimes a modal should become a real page — for example, when the user requests a permalink. useRoute exposes a promote() method that replaces the current history entry with the modal URL and clears the modal state:

ts
import { useRoute } from '@basmilius/routing';

const route = useRoute();

async function permalink(): Promise<void> {
    await route.promote();
}

Navigation guards receive bare RouteLocationNormalized objects, which normally don't tell you whether a navigation is a modal. This package augments them with a reactive isModal flag, so to.isModal and from.isModal are available in guards — with the same meaning as useRoute().isModal: true when that location is shown as a modal.

ts
router.beforeEach((to, from) => {
    if (from.isModal && !to.isModal) {
        // leaving the modal layer back to a full page
    }
});

isModal is also present on router.currentRoute.value and on the route from a plain useRoute().

Inside onBeforeRouteLeave ​

from.isModal works in every guard, including onBeforeRouteLeave. For to.isModal inside onBeforeRouteLeave, import the guard from @basmilius/routing instead of vue-router — vue-router runs leave guards before any global guard could stamp to, so the package ships a thin wrapper that fills it in:

ts
import { onBeforeRouteLeave } from '@basmilius/routing';

onBeforeRouteLeave((to, from) => {
    if (from.isModal && to.isModal) {
        // moving between two modal routes
    }
});

This is Composition-API only. The options-API beforeRouteLeave component option is not wrapped and won't populate to.isModal.

See also ​