SvModal
An accessible modal dialog: a backdrop, a focus trap, Escape and backdrop close, and a scale-in animation.
SvModal centers a portalled dialog over everything, locks page scroll, and
traps focus inside for as long as it is open - all through the shared a11y
primitives, so its behaviour matches SvDrawer and nested overlays
close top-first. It can optionally be dragged by its header and resized from the
bottom-right handle. Controlled via a bindable open.
Related: SvDrawer · SvPopover · Overlays & menus overview
Installation
Add it with the CLI - this drops a ready-to-edit SvModal starter into your app:
Prefer to see it first? npx @svgrid/ui try modal opens it in a throwaway sandbox - no project needed.
Or install the package and import it directly. SvModal ships free in
@svgrid/grid (dependency-free):
The examples on this page import from @svgrid/grid:
<script lang="ts">
import { SvButton, SvModal, SvTextInput } from '@svgrid/grid'
</script>
import { SvModal } from '@svgrid/grid'
Example
<script lang="ts">
import { SvModal, SvButton } from '@svgrid/grid'
let open = $state(false)
</script>
<SvButton onclick={() => (open = true)}>Edit row</SvButton>
<SvModal bind:open title="Edit row">
<p>Body content.</p>
{#snippet footer()}<SvButton variant="primary" onclick={() => (open = false)}>Save</SvButton>{/snippet}
</SvModal>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
open |
boolean |
false |
Controlled, bindable open state. |
onClose |
() => void |
- | Called whenever the dialog closes. |
title |
string |
- | Header heading; also names the dialog for a11y. |
size |
sm | md | lg |
md |
Preset dialog width (360 / 520 / 720px). |
draggable |
boolean |
false |
Allow moving the dialog by its header. |
resizable |
boolean |
false |
Show a bottom-right handle to resize the dialog. |
closeOnBackdrop |
boolean |
true |
Close when the backdrop is clicked. |
closeOnEsc |
boolean |
true |
Close on the Escape key. |
hideClose |
boolean |
false |
Hide the header close (x) button. |
children |
Snippet |
- | Dialog body. |
footer |
Snippet |
- | Footer content, typically the action buttons. |
Examples
Draggable and resizable
Opt into a movable, resizable window for tools users want to keep open while they work elsewhere in the page:
<SvModal bind:open title="Inspector" draggable resizable size="lg">
<p>Drag by the header, resize from the corner.</p>
</SvModal>
Confirmations
For a decision the user must make, drop the dismiss escapes and the close button so the only way out is a footer action:
<SvModal bind:open title="Delete 3 rows?" size="sm"
closeOnBackdrop={false} closeOnEsc={false} hideClose>
<p>This cannot be undone.</p>
{#snippet footer()}
<SvButton onclick={() => (open = false)}>Cancel</SvButton>
<SvButton variant="danger" onclick={confirm}>Delete</SvButton>
{/snippet}
</SvModal>
Edit a grid row
The workhorse pattern: open on a row action, edit a copy of the record, commit
in the footer, and use onClose to discard the draft however the dialog leaves
(Save, Cancel, Escape, or backdrop):
<script lang="ts">
import { SvModal, SvButton, SvTextInput } from '@svgrid/grid'
let open = $state(false)
let draft = $state({ name: '', email: '' })
function editRow(row) { draft = { ...row }; open = true }
function save() { /* ...persist draft */ open = false }
function reset() { draft = { name: '', email: '' } }
</script>
<SvModal bind:open title="Edit contact" size="md" onClose={reset}>
<SvTextInput label="Name" bind:value={draft.name} />
<SvTextInput label="Email" bind:value={draft.email} />
{#snippet footer()}
<SvButton onclick={() => (open = false)}>Cancel</SvButton>
<SvButton variant="primary" onclick={save}>Save</SvButton>
{/snippet}
</SvModal>
Tip: focus is trapped inside the dialog while it is open, so Tab cycles through the form fields and buttons without escaping to the page behind. The first focusable element receives focus on open, so lead with the field the user edits.
Accessibility
- The dialog is
role="dialog"witharia-modal="true", andtitlesupplies itsaria-labelledby. - Focus is trapped inside while open and page scroll is locked; both release on close through the shared primitives.
- Escape and backdrop clicks are honoured per
closeOnEsc/closeOnBackdropand routed through the dismissable layer stack, so a nested overlay closes first.
Draggable, resizable, and the escape hatches
closeOnBackdrop and closeOnEsc are on by default. Turn them off for a
destructive confirm, where a stray click should not dismiss the question -
and then make sure the footer gives an explicit way out.
<script lang="ts">
import { SvModal, SvButton } from '@svgrid/grid'
let open = $state(false)
let confirm = $state(false)
</script>
<SvButton onclick={() => (open = true)}>Open a working modal</SvButton>
<SvButton variant="danger" onclick={() => (confirm = true)}>Delete something</SvButton>
<SvModal open={open} onClose={() => (open = false)} title="Filters" size="md" draggable resizable>
<p>Drag the header, or pull the bottom-right corner.</p>
</SvModal>
<SvModal
open={confirm}
onClose={() => (confirm = false)}
title="Delete 4 rows?"
closeOnBackdrop={false}
closeOnEsc={false}
>
<p>This cannot be undone.</p>
{#snippet footer()}
<SvButton variant="outline" onclick={() => (confirm = false)}>Cancel</SvButton>
<SvButton variant="danger" onclick={() => (confirm = false)}>Delete</SvButton>
{/snippet}
</SvModal>
See also
- Overlays overview - the whole floating-surface family.
- SvDrawer - the same dialog contract as an edge side-sheet.
- SvPopover - a lightweight, non-modal anchored panel.