SwModal
SwModal wraps reka-ui Dialog and uses DialogPortal to teleport modal content under <body>, avoiding CSS stacking context issues. It provides a complete structure with title, description, body, and footer — all customizable via slots.
Prerequisite: SwModalHost
The framework also provides a SwModalHost container component — the mount point for useModal imperative dialogs. Place it once at the top of app.vue:
<!-- app.vue -->
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<SwModalHost />
</template>
Projects created by
sw createship it built-in; in most cases you don't need to add it manually.SwModalHostonly servesuseModal()imperative dialogs; declarative<SwModal>mounted directly in the component tree doesn't depend on this container.
Declarative Usage
Bind visibility with v-model:open, and use title, description, and confirm-text props to quickly build a confirmation dialog.
Basic Usage
Custom Footer
Use the #footer slot to fully replace the default confirm/cancel buttons — useful for multi-button layouts or conditional disabling.
Custom Footer
Imperative Usage (useModal)
useModal().open() → Promise<boolean>
In addition to declarative templates, the framework provides an imperative API to await modal results directly in business logic — ideal for delete confirmations, alerts, and similar flows.
Step 1: Place <SwModalHost /> once in app.vue (projects generated by sw create include this by default):
<!-- app.vue -->
<template>
<NuxtLayout><NuxtPage /></NuxtLayout>
<SwModalHost />
<SwToast />
</template>
Step 2: Call useModal().open() anywhere in your components:
<script setup lang="ts">
const modal = useModal()
async function handleDelete() {
const confirmed = await modal.open({
title: 'Confirm Delete',
description: 'This action cannot be undone. Continue?',
confirmText: 'Delete',
cancelText: 'Cancel',
})
if (confirmed) await deleteItem()
}
// Alert-only (hide cancel button)
async function handleAlert() {
await modal.open({
title: 'Notice',
description: 'The event has ended.',
confirmText: 'OK',
cancelText: '',
})
}
</script>
open() returns Promise<boolean>: resolves true when the user confirms, false when they cancel or close. Only one imperative modal is shown at a time — multiple calls overwrite the previous one.
SwModalHostis an infrastructure component. It accepts no props and needs to be placed only once inapp.vueto cover the entire application.
Default skin & token overrides
SwModal ships a default skin (framework-injected modal.css): the --sw-modal-* variables map to semantic tokens by default and follow light/dark themes automatically. Override them at a top-level :root in your overrides.css (the dialog teleports to body, so scoped overrides cannot reach it); the whole skin can be disabled via framework.styles.modal = false.
The dialog stacking level is controlled by --sw-modal-z (default 1000, the fw#332 z knob) — adjust it when the dialog collides with another overlay instead of reaching in with :deep.
Skin token overrides (radius / width / brand color)
Geometry: viewport-safe defaults + per-dialog customization
Dialog geometry is viewport-safe by default: width: min(--sw-modal-width(28rem), 100dvw - 2rem) and max-height: min(--sw-modal-max-height(85dvh), 100dvh - 2rem) — a 1rem margin is preserved on narrow screens, and long content automatically gets the "pinned header/footer, scrollable body" layout instead of overflowing the viewport.
class / attrs fall through to .sw-modal-content (the dialog content root). This is the official per-dialog customization channel — define --sw-modal-width / --sw-modal-max-height inside your class to size a single dialog (redefining at :root affects every dialog site-wide):
<SwModal v-model:open="visible" class="wide-dialog" title="Bulk edit">…</SwModal>
<style>
/* Not scoped: the content teleports to body, scoped attributes cannot reach it */
.wide-dialog {
--sw-modal-width: 44rem;
}
</style>
When content is too wide, the body shows a horizontal scrollbar (an intentional exposure signal — silent clipping hides bugs). The fix is to give that dialog a proper width via the class channel, not to prop up
min-widthinside the content.
Dismiss behavior (dismissible / closable)
Locked modal + bare-content modal
Props
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
open | boolean | ✓ | — | Controls visibility; use with v-model:open |
title | string | — | Modal title text; can be overridden by #title slot | |
description | string | — | Description text; rendered only when there is no default slot | |
confirmText | string | — | Confirm button text; footer is shown only when this is set | |
cancelText | string | '取消' | Cancel button text; pass an empty string to hide it | |
closable | boolean | true | Show the close ✕; false + no title → bare content modal (no header/✕) | |
dismissible | boolean | true | Allow closing by clicking the overlay / pressing Esc; false locks it (only ✕ / buttons / code can close) |
Events
| Event | Payload | Description |
|---|---|---|
update:open | boolean | Fired when visibility changes |
confirm | void | Fired when the confirm button is clicked |
cancel | void | Fired when the cancel button is clicked |
Slots
| Name | Description |
|---|---|
default | Main modal body content |
title | Custom title area |
footer | Custom footer — replaces the default confirm/cancel buttons |
Caveats
- Don't mix controlled and imperative usage in the same file (Rule SM-001) —
<SwModal v-model:open="visible">anduseModal().open({...})both mutate global dialog state; mixing them inside one component causes state corruption (one action opens two dialogs, or they close each other). Pick one: controlled for local dialogs, imperative for global confirmations. <SwModalHost />must be mounted once at the root ofapp.vue(Rule SM-002) — without ituseModal().open()renders nothing; mounted inside a deep route component it gets destroyed on navigation and imperative dialogs stop working. The framework has a runtime safety net: on the client itconsole.warns once when it detects the host is missing (deduped via useState), in both dev and prod.- Focus management is built in — don't roll your own overlay — built on reka-ui Dialog: focus-on-open, focus trap, Esc to close, and focus restore all come for free (Rule UX-005). A hand-written
<div v-if="open">overlay leaves keyboard and screen-reader users stuck. - This is not a zero-styled component — the framework injects
modal.cssby default (frosted-glass overlay plus a complete card look), so it already looks usable with zero custom CSS in an applier. Don't approach it as an "unstyled modal" and write a skin from scratch — see Default skin & token overrides above: override the--sw-modal-*variables first (no:deeppiercing needed), and only reach forframework.styles.modal = falsewhen you truly need to draw everything yourself.