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

Open the live example: Overlays: popover, tooltip, modal (Layout)

<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

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