Modal
FreePremium 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 demoMore examples
Danger confirmation with variant
example.htmlAngular
9 lines
<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
8 lines
<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
7 lines
<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
14 lines
<!-- 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
6 lines
// 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
8 lines
<!-- 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
6 lines
// 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
18 lines
<!-- 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
| Name | Type | Default | Description |
|---|---|---|---|
| isOpen | boolean (two-way) | false | Controls open/closed state. Use `[(isOpen)]` for two-way binding. |
| title | string | '' | Dialog title shown in the built-in header. |
| subtitle | string | '' | Optional secondary line rendered beneath the title (also used for aria-describedby). |
| variant | ModalVariant | 'default' | Semantic/visual variant. Drives the header icon badge + accent bar. |
| showHeader | boolean | true | Render the built-in header bar. |
| showHeaderDivider | boolean | true | Bottom dividing line under the header. |
| showFooterDivider | boolean | true | Top dividing line above the footer. |
| showFooter | boolean | true | Render the footer slot wrapper. |
| size | ModalSize | 'md' | Size of the dialog panel. |
| position | ModalPosition | 'center' | Placement of the dialog panel. |
| showIcon | boolean | true | Show the built-in variant icon badge in the header. |
| closeOnBackdrop | boolean | true | Close dialog when backdrop is clicked. |
| closeOnEsc | boolean | true | Close dialog on ESC key. |
| backdropBlur | boolean | false | Apply a frosted-glass blur to the backdrop. |
| showCloseButton | boolean | true | Show the built-in × close button in the header. |
| loading | boolean | false | Busy state. While `true` the panel shows a spinner overlay and all close paths (button, backdrop, ESC) are blocked so a running task can finish. |
| loadingText | string | 'Loading…' | Accessible label announced while `loading` is true. |
| closeOnNavigation | boolean | true | Does 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. |
| backCloses | boolean | false | Does 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. |
| dirty | boolean | false | Is 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) | null | null | The 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; ``` |
| unsavedTitle | string | 'Discard your changes?' | Heading of the unsaved-changes prompt. |
| unsavedMessage | string | 'This dialog has changes that have not … | Body of the unsaved-changes prompt. |
| discardLabel | string | 'Discard' | Label of the button that closes and loses the changes. |
| keepEditingLabel | string | 'Keep editing' | Label of the button that returns to the dialog. |
| pendingDismissal | SvPendingDismissal | '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. |
| slowAfterMs | number | 0 | Say "this is taking longer than usual" after this many ms of `loading`. `0` = never. |
| stalledAfterMs | number | 0 | Escalate to a stuck message after this many ms of `loading`, measured from the same start. `0` = never. |
| slowMessage | string | 'This is taking longer than usual…' | Message shown over the panel at the `slow` stage. |
| stalledMessage | string | 'This seems to be stuck.' | Message shown over the panel at the `stalled` stage. |
| cancelLoadingLabel | string | null | null | Label 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. |
| initialFocus | string | '' | CSS selector for the element to focus first when the modal opens (e.g. `'#name-input'`). Falls back to the first focusable element. |
| customClass | string | '' | Extra CSS classes merged onto the dialog panel. |
| resizable | boolean | false | Let 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`. |
| maximizable | boolean | false | Show the maximize / restore button in the header. |
| minWidth | number | 280 | Floor for a dragged resize, in pixels. |
| minHeight | number | 160 | Floor for a dragged resize, in pixels. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| discarded | ModalCloseReason | The user discarded unsaved changes; the close follows. |
| closeCancelled | ModalCloseReason | The user backed out of a close and returned to the dialog. |
| loadingCancelled | void | The 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. |
| loadingEscalated | SvWaitStage | Fires on each escalation of a `loading` wait: `'slow'`, then `'stalled'`. |
| opened | void | Emitted after the modal is opened (by any means). |
| closed | void | Emitted after the modal is closed (by any means). |
| dismissed | ModalCloseReason | Emitted 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`. |
| dismissedBy | SvCloseReason | Emitted alongside `dismissed` with the exact reason, including `'route'` (the app navigated) and `'history'` (the Back button) from |
| resized | SvModalSizePx | Emitted whenever the panel's pixel size changes — a drag or a maximize. |
| isOpenChange | boolean | Emits 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.