Modal

Free

Premium dialog overlay with 6 semantic variants, 7 sizes, bottom-sheet & loading states, slot-based projection, focus trap and ESC/backdrop close.

</>sv-modal modaldialogoverlayvariantbottom-sheet
import { SvModalComponent } from '@styloviz/modal';

Demo & usage

Basic usage

Usage example — open the gallery for the full interactive demo.

An interactive demo for this component lives in the gallery.

Open live demo

More examples

Danger confirmation with variant

example.htmlAngular
<sv-modal
  [(isOpen)]="open"
  variant="danger"
  title="Delete workspace"
  subtitle="This permanently removes all data"
  size="sm"
  (dismissed)="onClose($event)">
  <ng-container slot="body">Are you sure?</ng-container>
</sv-modal>

Bottom sheet with blur

example.htmlAngular
<sv-modal
  [(isOpen)]="open"
  position="bottom"
  variant="info"
  [backdropBlur]="true"
  title="Share">
  <ng-container slot="body">…</ng-container>
</sv-modal>

Loading state during async save

example.htmlAngular
<sv-modal
  [(isOpen)]="open"
  [loading]="saving()"
  loadingText="Saving…"
  title="Edit profile">
  <ng-container slot="body">…</ng-container>
</sv-modal>

Guard an unsaved form

example.htmlAngular
<!-- The backdrop is the whole screen around the panel and Escape is one
     key; both used to discard everything the user typed. [dirty] makes the
     dialog ASK instead — one prompt, from the same primitive the Drawer and
     Confirm Dialog use, so the three behave identically. A programmatic
     close() still closes: your own code has already decided. -->
<sv-modal
  [(isOpen)]="open"
  title="Edit profile"
  [dirty]="form.dirty"
  unsavedMessage="Your profile changes have not been saved."
  (discarded)="log('discarded via ' + $event)"
  (closeCancelled)="log('stayed')">
  <ng-container slot="body">…</ng-container>
</sv-modal>

Close it when the route changes

example.htmlAngular
// One line in the app, and every overlay stops outliving its page.
// Without it nothing changes — "a modal now closes on navigation" is a
// behaviour change, so the app asks for it rather than inheriting it.
bootstrapApplication(App, {
  providers: [provideRouter(routes), provideSvOverlayRouteClose()],
});

Let Back close a dialog that is a destination

example.htmlAngular
<!-- Off by default: Back closing a transient question eats a press the
     user meant for the page. Turn it on when the dialog is somewhere the
     user navigated TO — a details panel, a wizard step — and on a phone
     the system back gesture does the right thing too. The URL is
     untouched; this is a marked history entry, not a route. -->
<sv-modal [(isOpen)]="open" [backCloses]="true" title="Order details">
  <ng-container slot="body">…</ng-container>
</sv-modal>

Veto a close for your own reasons

example.htmlAngular
// Return false to refuse, or nothing to have no opinion — so a guard
// that only cares about one path needs one if. A veto is silent: it does
// NOT show the unsaved-changes prompt, because you refused for your own
// reasons and own the messaging.
protected readonly notWhileUploading = (reason: ModalCloseReason) =>
  reason === 'backdrop' ? !this.uploading() : undefined;

A loading state the user can get out of

example.htmlAngular
<!-- Every close path is blocked while loading, which is right for a
     two-second save and wrong for a request that has hung. These say so
     explicitly: the overlay explains itself after 10s, offers a way out
     at 30s, and pendingDismissal="abort" lets the user leave — the modal
     emits (loadingCancelled) first, because the host started the work and
     is the only one who can stop it. -->
<sv-modal
  [(isOpen)]="open"
  [loading]="saving()"
  loadingText="Saving…"
  pendingDismissal="abort"
  [slowAfterMs]="10000"
  [stalledAfterMs]="30000"
  cancelLoadingLabel="Stop saving"
  (loadingCancelled)="abortSave()"
  title="Edit profile">
  <ng-container slot="body">…</ng-container>
</sv-modal>

When to use

  • Interrupting the flow for a focused task or message that needs attention.
  • Forms, details or confirmations that benefit from a focus trap.
  • Semantic confirmations (delete, warning, success) — reach for the matching `variant`.
  • For a yes/no decision specifically, the Confirm Dialog is more concise.

Async states

  • loading

Inputs

NameTypeDefaultDescription
isOpenboolean (two-way)falseControls open/closed state. Use `[(isOpen)]` for two-way binding.
titlestring''Dialog title shown in the built-in header.
subtitlestring''Optional secondary line rendered beneath the title (also used for aria-describedby).
variantModalVariant'default'Semantic/visual variant. Drives the header icon badge + accent bar.
showHeaderbooleantrueRender the built-in header bar.
showHeaderDividerbooleantrueBottom dividing line under the header.
showFooterDividerbooleantrueTop dividing line above the footer.
showFooterbooleantrueRender the footer slot wrapper.
sizeModalSize'md'Size of the dialog panel.
positionModalPosition'center'Placement of the dialog panel.
showIconbooleantrueShow the built-in variant icon badge in the header.
closeOnBackdropbooleantrueClose dialog when backdrop is clicked.
closeOnEscbooleantrueClose dialog on ESC key.
backdropBlurbooleanfalseApply a frosted-glass blur to the backdrop.
showCloseButtonbooleantrueShow the built-in × close button in the header.
loadingbooleanfalseBusy state. While `true` the panel shows a spinner overlay and all close paths (button, backdrop, ESC) are blocked so a running task can finish.
loadingTextstring'Loading…'Accessible label announced while `loading` is true.
closeOnNavigationbooleantrueDoes a navigation close this dialog? A MODAL THAT OUTLIVES ITS ROUTE FLOATS OVER AN UNRELATED PAGE. The audit calls it the most common overlay bug in shipped dashboards, and the mechanism is simple — a link inside the dialog, a redirect from a guard, a host that navigates on success — after which the panel is still up, still modal, still describing a page that is no longer underneath it. IT ONLY HAPPENS IF THE APP ASKED. `provideSvOverlayRouteClose()` from `@styloviz/layout` is what tells the overlay layer a navigation happened; an app that wires nothing keeps today's behaviour exactly. Set this false for a dialog that belongs to the shell rather than to the page.
backClosesbooleanfalseDoes the browser Back button close this dialog? Back is the gesture users reach for to undo "I opened this", and on a phone it is the system gesture — a dialog that ignores it sends the user off the page instead, losing whatever they were doing. OFF BY DEFAULT, AND THAT IS THE OPPOSITE DECISION FROM THE ONE ABOVE. Back closing a dialog is right when the dialog is a destination the user navigated TO — a details panel, a wizard step — and wrong when it is a transient question, where it eats a Back press meant for the page. Only the host knows which this is. The mechanism is a marked `history` entry, not a route: the URL is untouched and the host's own state is preserved. Giving a dialog its own URL is a routing decision, and leaves that to the host.
dirtybooleanfalseIs there unsaved work in this dialog? A FORM IN A MODAL CAN BE THROWN AWAY BY A MISSED CLICK. The backdrop is the whole screen around the panel, Escape is one key, and both discard everything the user typed with no warning and no undo. It is the single most-reported overlay complaint in any dashboard, and the reason "are you sure?" exists. SET THIS AND THE DIALOG ASKS instead of closing — once, in one place, from `createCloseGuard`, so Modal, Drawer and Confirm Dialog answer it identically. A prompt written four times behaves four ways. A PROGRAMMATIC `close()` STILL CLOSES. The prompt is for accidental dismissal; your own code calling `close()` has already decided, and a component that second-guesses its own API is unusable. See `createCloseGuard`'s `alwaysAllow`.
canClose((reason: ModalCloseReason) => boolean | void) | nullnullThe host's own veto, consulted before anything else. Return `false` to refuse a close; return nothing to have no opinion, so a guard that only cares about the backdrop needs one `if`. A veto is silent: it does NOT raise the unsaved-changes prompt, because the host refused for its own reasons and owns its own messaging. ```html <sv-modal [canClose]="notWhileUploading" /> ``` ```ts protected readonly notWhileUploading = (reason: ModalCloseReason) => reason === 'backdrop' ? !this.uploading(): undefined; ```
unsavedTitlestring'Discard your changes?'Heading of the unsaved-changes prompt.
unsavedMessagestring'This dialog has changes that have not …Body of the unsaved-changes prompt.
discardLabelstring'Discard'Label of the button that closes and loses the changes.
keepEditingLabelstring'Keep editing'Label of the button that returns to the dialog.
pendingDismissalSvPendingDismissal'block'May the user dismiss the modal while `loading` is true? IS TWO COMPLAINTS AND THIS IS THE FIRST: "no way out". Every close path — the × button, the backdrop, Escape — is blocked while `loading`, silently and with no stated policy. That is a defensible answer for a two-second save and the wrong one for a request that has hung: the modal simply stops responding, and the user's remaining move is to reload the page. `'block'` keeps exactly today's behaviour and is the default. `'abort'` lets the close through and emits `loadingCancelled` first, so the host can abort the work it started. The modal does not own the request — `loading` is an input — so the host has to do the aborting; what changes is that it is now told to.
slowAfterMsnumber0Say "this is taking longer than usual" after this many ms of `loading`. `0` = never.
stalledAfterMsnumber0Escalate to a stuck message after this many ms of `loading`, measured from the same start. `0` = never.
slowMessagestring'This is taking longer than usual…'Message shown over the panel at the `slow` stage.
stalledMessagestring'This seems to be stuck.'Message shown over the panel at the `stalled` stage.
cancelLoadingLabelstring | nullnullLabel for a cancel button in the loading overlay, shown once `stalled`. `null` = none — and an offer with nothing behind it is worse than none, so set this only when `loadingCancelled` is wired to something that stops the work.
initialFocusstring''CSS selector for the element to focus first when the modal opens (e.g. `'#name-input'`). Falls back to the first focusable element.
customClassstring''Extra CSS classes merged onto the dialog panel.
resizablebooleanfalseLet the user drag the panel's bottom-right corner to resize it. THE DRAG IS AN ENHANCEMENT, NOT THE ONLY WAY IN. A pointer-only affordance is unusable by keyboard, so turning this on also turns on the maximize/restore button unless `maximizable` is explicitly false — the button is the keyboard-reachable half of the same capability, and the panel can always be sized declaratively through `size`.
maximizablebooleanfalseShow the maximize / restore button in the header.
minWidthnumber280Floor for a dragged resize, in pixels.
minHeightnumber160Floor for a dragged resize, in pixels.

Outputs

EventPayload typeDescription
discardedModalCloseReasonThe user discarded unsaved changes; the close follows.
closeCancelledModalCloseReasonThe user backed out of a close and returned to the dialog.
loadingCancelledvoidThe user asked to stop the work: they pressed the cancel offer, or they dismissed the modal with `pendingDismissal="abort"`. Emitted BEFORE the close, so a host that aborts synchronously is not racing the teardown.
loadingEscalatedSvWaitStageFires on each escalation of a `loading` wait: `'slow'`, then `'stalled'`.
openedvoidEmitted after the modal is opened (by any means).
closedvoidEmitted after the modal is closed (by any means).
dismissedModalCloseReasonEmitted alongside `closed`, describing what triggered the close. A close caused by a navigation or a Back press reports `'programmatic'` here — the published union cannot grow — and its exact reason on `dismissedBy`.
dismissedBySvCloseReasonEmitted alongside `dismissed` with the exact reason, including `'route'` (the app navigated) and `'history'` (the Back button) from
resizedSvModalSizePxEmitted whenever the panel's pixel size changes — a drag or a maximize.
isOpenChangebooleanEmits the new `isOpen` whenever it changes — the output half of `[(isOpen)]`.

Customization & theming

  • Six semantic variants with icon badge + accent bar; toggle the badge via `showIcon`.
  • Seven sizes (xs–2xl, full) and three positions (center, top, bottom-sheet).
  • Backdrop blur, ESC/backdrop close toggles, and a spinner `loading` state.
  • Rendered into `document.body` via a portal, so it always covers the full viewport and is never clipped by a parent `overflow` or `transform`.
  • Scroll-locking with scrollbar-width compensation; `customClass` on the panel.
  • Route and history integration: `[closeOnNavigation]` (default on) closes the dialog when the app navigates, once `provideSvOverlayRouteClose()` is wired; `[backCloses]` (default off) pushes a marked history entry so the browser Back button — and the phone back gesture — closes it, and pops that entry again on any other close so the history does not grow junk. Both compose with the unsaved-changes prompt: a navigation still asks before discarding.
  • A controlled-close policy: `[dirty]` raises one shared unsaved-changes prompt (over the panel, focused on Keep editing) instead of discarding; `[canClose]` is your own veto, consulted before anything else and silent by design; `(discarded)` and `(closeCancelled)` report which way it went, and the eventual `(dismissed)` carries the reason the USER acted on rather than `programmatic`. Shared with Drawer and Confirm Dialog — `createCloseGuard` in `@styloviz/core`.
  • A loading state with a way out: `pendingDismissal` (`block` · `abort`) decides whether the user may leave mid-flight, `[slowAfterMs]` / `[stalledAfterMs]` escalate the overlay from "taking longer than usual" to "seems to be stuck" (both timed from the moment `loading` goes true, not from when the modal opened), `cancelLoadingLabel` adds an in-overlay cancel once stuck, and `(loadingCancelled)` / `(loadingEscalated)` report it. Shared with the Spinner and Skeleton Loader — `createWaitEscalation` in `@styloviz/core`.

Best practices

  • Do: Match the `variant` to the intent — `danger` for destructive actions, `success` for confirmations.
  • Do: Use `[loading]` to block accidental dismissal while an async action is in flight.
  • Do: Call `provideSvOverlayRouteClose()` once in your app (from `@styloviz/layout`). Without it a modal outlives its route and floats over an unrelated page — the most common overlay bug in shipped dashboards. Individual dialogs opt out with `[closeOnNavigation]="false"` when they belong to the shell rather than the page.
  • Do: Set `[dirty]` on any dialog containing a form. The backdrop is the whole screen around the panel and Escape is one key — without it, a missed click discards everything the user typed, with no warning and no undo. The prompt, its copy and its focus behaviour are shared with the Drawer, so the two cannot drift.
  • Do: Decide what happens when that request hangs. `pendingDismissal` states it: `block` (the default) refuses every close path as before; `abort` lets the user out and emits `(loadingCancelled)` first, so you can stop the work you started. Add `[slowAfterMs]` / `[stalledAfterMs]` and the overlay explains itself instead of spinning silently.
  • Do: Restore focus to the trigger on close (handled) and label the dialog via `title`.
  • Do: Keep modal content short; deep flows belong on a page or in a Drawer.
  • Don't: Don't stack modals — close one before opening the next.

Accessibility

  • `role="dialog"` + `aria-modal` live on the panel, labelled via `aria-labelledby` (title) and `aria-describedby` (subtitle).
  • Focus is trapped while open and moved to `initialFocus` (or the first focusable element), then restored to the trigger on close — or, if the trigger is gone by then, to the nearest part of the page that outlived it. ⚠️ An `initialFocus` selector that matches nothing falls back to the first focusable element and warns in dev mode: the fallback looks like success, so nothing else would tell you the selector was never used.
  • `opened`/`closed` fire for every transition; `dismissed` reports the reason (backdrop / escape / close-button / programmatic). `aria-busy` is set while loading.
  • An escalation message is announced from the loading overlay's own `role="status"` region, which already exists when the text appears — one wait, one voice. Escape is not consumed while dismissal is refused: the modal has nothing to do with the keystroke, so it does not take it.

Feedback on Modal

Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.

Loads giscus.app and GitHub — nothing is fetched until you click.