PageOverlay
A list is where you came from and where you'll go back to. PageOverlay opens a record on top of it — a card covering the page content area, with the app rail, top bar and section sidebar still visible around it. Unlike Modal and Drawer, which are fixed inset-0 on the viewport, this one is scoped to the PageContent it sits in, so it clips to the app card's rounded corners and tracks the rail-expand animation without being told to.
ts
import { PageOverlay, PageHeaderBack, PageActionBar } from '@scaler-tech/aurora/layout'Measures
BMS Upgrade — 2027
Insulation Retrofit — 2026
Rooftop PV — 2024
Use when
| Situation | Use | Why |
|---|---|---|
| Open a record from a list — a page's worth of form, and the list is where you return | PageOverlay | keeps the list mounted and its state intact; the surrounding chrome says you never left |
| Edit alongside the list, a narrow form | Drawer | slides in beside the content instead of covering it, and docks to a sheet on mobile |
| A short focused task that must interrupt | Modal | viewport-centred and blocking is the point |
| Confirm something | useConfirmModal | a question, not a surface |
| A destination people link to or navigate to directly | a route + PageContent | a page someone can bookmark shouldn't be a layer over something else |
API
PageOverlay
| Prop | Type | Default | Description |
|---|---|---|---|
isOpen | boolean | false | v-model:is-open for local state, or :is-open + @close when a route owns it. |
inset | 'none' | 'sm' | 'sm' | How much page shows around the card. sm leaves an 8px gap for the shadow; none runs flush. |
beforeClose | () => boolean | Promise<boolean> | — | Runs before every dismissal. Return false to cancel. |
static | boolean | false | Disable outside-click dismissal. Escape and close still work. |
| Event | Payload | When |
|---|---|---|
close | — | Any dismissal that passed beforeClose. |
| Slot | Props | Description |
|---|---|---|
| default | { close } | The card's contents. close is the guarded close — your Cancel button must call it, not set isOpen directly, or it skips beforeClose. |
The card is a fixed-height column, exactly like PageContent: put a PageHeader, one PageBody, and optionally a PageActionBar inside it, and the body stays the single scroll region. See Page structure.
PageHeaderBack
Back navigation for a PageHeader — a chevron plus the name of the place you return to. Click and href wiring falls through.
| Prop | Type | Default | Description |
|---|---|---|---|
as | PrimitiveProps['as'] | 'button' | The rendered element — 'button', 'a', or a RouterLink component. |
PageActionBar
Action bar for the foot of a page or overlay. Lays children out justify-between, the shape DrawerFooter uses; class falls through (justify-end to push one cluster right). To line the buttons up with a narrower content column, wrap them in the same max-w-* mx-auto box the content uses.
| Prop | Type | Default | Description |
|---|---|---|---|
sticky | boolean | false | Off: a pinned sibling below PageBody, always visible, always with its divider. On: the last child inside PageBody — it scrolls with short content and shows no divider; once the content outgrows the body it pins to the body's bottom edge and gains the divider. |
vue
<PageBody>
<FormSection … />
<PageActionBar sticky>…</PageActionBar>
</PageBody>Examples
Local state — the simplest case:
vue
<PageContent class="relative">
<PageBody><!-- the list --></PageBody>
<PageOverlay v-model:is-open="isOpen">
<template #default="{ close }">
<PageHeader><PageHeaderBack @click="close()">Measures</PageHeaderBack></PageHeader>
<PageBody><!-- the record --></PageBody>
</template>
</PageOverlay>
</PageContent>Route-driven — deep-linkable, survives refresh, browser-back works. Prefer this whenever the thing being opened has an identity:
vue
<PageOverlay
:is-open="!!route.params.measure_id"
@close="router.push({ name: 'improve.measures' })"
>Guarded — a form with unsaved changes:
vue
<PageOverlay v-model:is-open="isOpen" :before-close="confirmDiscard">
<template #default="{ close }">
<PageBody><MeasureForm v-model:dirty="isDirty" /></PageBody>
<PageActionBar>
<Button variant="secondary" danger leading-icon="trash">Delete</Button>
<ButtonGroup>
<Button variant="secondary" @click="close()">Cancel</Button>
<Button @click="save()">Save</Button>
</ButtonGroup>
</PageActionBar>
</template>
</PageOverlay>Navigating away is an exit the overlay can't see — guard it too, in the page:
ts
onBeforeRouteLeave(() => (isOpen.value ? confirmDiscard() : true))Hazards
Both of these are the same shape — a click reaching a handler that the click itself created — and both were live bugs before the component absorbed them.
A guard dialog that dismisses itself. A route guard runs while the click that triggered it is still propagating, so a confirmation opened synchronously catches the tail of that click on its own overlay and closes. The exit is cancelled with no visible prompt — the user is stuck with no explanation. PageOverlay awaits beforeClose on a macrotask boundary so your dialog can't be hit by it. If you open a dialog from onBeforeRouteLeave yourself, defer it the same way.
Two exits, one dialog container. Clicking a sidebar link is both an outside click and a route change, so a naive guard runs twice. useConfirmModal renders into a single shared container, and the second call overwrites the first's vnode tree — both dialogs vanish and the first promise never settles. Reuse the in-flight promise:
ts
let pending: Promise<boolean> | null = null
function confirmDiscard() {
if (!isDirty.value) return true
if (!pending) {
pending = confirm({ title: 'Discard changes?' }).finally(() => { pending = null })
}
return pending
}Incorrect / Correct
Incorrect (no relative, so the overlay escapes to the nearest positioned ancestor and covers the wrong region):
vue
<PageContent>
<PageOverlay v-model:is-open="isOpen">…</PageOverlay>
</PageContent>Correct (PageContent is the containing block):
vue
<PageContent class="relative">
<PageOverlay v-model:is-open="isOpen">…</PageOverlay>
</PageContent>Incorrect (Cancel bypasses the guard — unsaved work disappears silently):
vue
<Button variant="secondary" @click="isOpen = false">Cancel</Button>Correct (the slot's close runs beforeClose first):
vue
<template #default="{ close }">
<Button variant="secondary" @click="close()">Cancel</Button>
</template>Incorrect (a parent v-if unmounts the card before its leave transition can run):
vue
<PageOverlay v-if="isOpen" :is-open="true" />Correct (the component owns its own mount/unmount):
vue
<PageOverlay v-model:is-open="isOpen" />