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 create ship it built-in; in most cases you don't need to add it manually. SwModalHost only serves useModal() 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

加载中...

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.

SwModalHost is an infrastructure component. It accepts no props and needs to be placed only once in app.vue to 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-width inside the content.

Dismiss behavior (dismissible / closable)

Locked modal + bare-content modal

加载中...

Props

NameTypeRequiredDefaultDescription
openboolean✓—Controls visibility; use with v-model:open
titlestring—Modal title text; can be overridden by #title slot
descriptionstring—Description text; rendered only when there is no default slot
confirmTextstring—Confirm button text; footer is shown only when this is set
cancelTextstring'取消'Cancel button text; pass an empty string to hide it
closablebooleantrueShow the close ✕; false + no title → bare content modal (no header/✕)
dismissiblebooleantrueAllow closing by clicking the overlay / pressing Esc; false locks it (only ✕ / buttons / code can close)

Events

EventPayloadDescription
update:openbooleanFired when visibility changes
confirmvoidFired when the confirm button is clicked
cancelvoidFired when the cancel button is clicked

Slots

NameDescription
defaultMain modal body content
titleCustom title area
footerCustom 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"> and useModal().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 of app.vue (Rule SM-002) — without it useModal().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 it console.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.css by 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 :deep piercing needed), and only reach for framework.styles.modal = false when you truly need to draw everything yourself.