Skip to content

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 ​

SituationUseWhy
Open a record from a list — a page's worth of form, and the list is where you returnPageOverlaykeeps the list mounted and its state intact; the surrounding chrome says you never left
Edit alongside the list, a narrow formDrawerslides in beside the content instead of covering it, and docks to a sheet on mobile
A short focused task that must interruptModalviewport-centred and blocking is the point
Confirm somethinguseConfirmModala question, not a surface
A destination people link to or navigate to directlya route + PageContenta page someone can bookmark shouldn't be a layer over something else

API ​

PageOverlay ​

PropTypeDefaultDescription
isOpenbooleanfalsev-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.
staticbooleanfalseDisable outside-click dismissal. Escape and close still work.
EventPayloadWhen
close—Any dismissal that passed beforeClose.
SlotPropsDescription
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.

PropTypeDefaultDescription
asPrimitiveProps['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.

PropTypeDefaultDescription
stickybooleanfalseOff: 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" />