@surstromming/dialog

Modal dialog, with an alert-dialog mode and sheets on the screen's edges.
1,382
Install
npm install @surstromming/dialog
Latest Version:0.1.7
Vue:3
License:MIT
Last Updated:Oct 17, 2026
Links: GitHub  ·  npm
Maintainer: evgeniy_mnatsakanov

@surstromming/dialog

A modal that takes over the screen until it's dealt with. Two modes on one component:

  • dialog (default) — a form, a detail view. An ✕, Escape and an outside click all close it.
  • alertdialog — a decision that can't be waved away (delete, discard). No dismissal; the user must choose an action. This is shadcn's AlertDialog, folded in as a role rather than a second package.

And two placements: in the middle of the screen, or flush with one of its edges as a sheet — a menu drawer on the left, details on the right, a phone's sheet from the bottom. That is shadcn's Sheet, folded in as side for the same reason: the focus trap, the scroll lock and the dismissal are the same modal's, and only where the panel sits differs.

Dependency graph

graph LR
  dialog["@surstromming/dialog"]
  backdrop["@surstromming/backdrop"]
  design["@surstromming/design"]
  icon["@surstromming/icon"]
  scroll_area["@surstromming/scroll-area"]
  util["@surstromming/util"]
  dialog --> backdrop
  backdrop --> design
  dialog --> design
  dialog --> icon
  dialog --> scroll_area
  scroll_area --> design
  scroll_area --> util

Usage

<template>
  <Button @click="open = true">Edit profile</Button>

  <Dialog v-model:open="open" title="Edit profile" description="Change your details.">
    <Input v-model="name" />
    <template #footer>
      <Button variant="outline" @click="open = false">Cancel</Button>
      <Button @click="save">Save</Button>
    </template>
  </Dialog>

  <!-- Confirmation that must be answered -->
  <Dialog v-model:open="confirm" role="alertdialog" title="Delete project?"
          description="This cannot be undone.">
    <template #footer>
      <Button variant="outline" @click="confirm = false">Cancel</Button>
      <Button variant="destructive" @click="remove">Delete</Button>
    </template>
  </Dialog>

  <!-- A menu drawer, its ✕ named in the app's language -->
  <Dialog v-model:open="menu" side="left" title="Menu" close-label="Закрыть меню">
    <nav>…</nav>
  </Dialog>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Dialog } from '@surstromming/dialog'
import { Button } from '@surstromming/button'
import { Input } from '@surstromming/input'
</script>

Props

Prop Type Default Notes
v-model:open boolean false The consumer owns the state
title string — Wires aria-labelledby
description string — Wires aria-describedby
role dialog | alertdialog dialog alertdialog removes dismissal
side center | top | right | bottom | left center An edge makes it a sheet — see below
closeLabel string Close The ✕'s accessible name, in the app's language

Dismissal follows role — a dialog closes on ✕/Escape/outside click; an alertdialog has none of those (choose it deliberately, and give the footer a Cancel). There's no dismissible override: an absent boolean prop is false, not undefined, so it can't carry a role-dependent default without lying.

Sheets

On an edge the panel runs the edge's length: full height on the left and the right, at most spacing(80) wide; full width at the top and the bottom, as tall as its content up to the screen less spacing(8). It is square, and bordered only where it meets the page. It slides in from its edge while the overlay fades, and slides back out on close; reduced motion turns both off. The sheet's rules carry no more weight than the panel's own (:where()), so a class on the dialog sets a sheet's width exactly as it sets a dialog's.

closeLabel is there because the ✕ is the component's, not the consumer's, and an English "Close" read out in a Russian interface is a label the app cannot fix from outside.

Slots

Slot Description
default The body.
header Replaces the title/description pair.
footer Actions, right-aligned. Required for alertdialog — it's the only way out.

Surface

The panel paints popover, not background. They're the same white in light mode, but in dark mode they differ (0.205 vs 0.145) — and a panel painted in the page's own colour has nothing to stand out against, however much the scrim dims what's behind it. Every other overlay here (Popover, DropdownMenu) is already on that surface; the dialog just joins them.

Anatomy

Backdrop                       // dim + blur, at `modal - 1`
div.overlay.side-*             // fixed, places the panel; outside-click target
  └─ div.panel[role]           // aria-modal, focus-trapped
       ├─ button.close         // ✕, dialog + dismissible only
       ├─ div.header           // title + description (or #header)
       ├─ ScrollArea.body      // the only part that scrolls
       │    └─ div.content     // <slot />
       └─ div.footer           // #footer

div.content exists for one reason: a scroller clips at its own padding edge, and a full-width control ends exactly there — so the focus ring design.field paints outside the border box was cut flat. The wrapper pads by spacing(1) and .body takes the same off its margin, so the content still lines up with the header and the footer.

Behavior & accessibility

  • Focus trap — opening moves focus to the first focusable inside (or the panel); Tab/Shift+Tab cycle within and can't escape. Focusable means what the browser would tab to: anything with a negative tabindex is skipped, the body scroller's own step arrows included — they sit ahead of the footer in the DOM, so before 0.1.5 every dialog with body content opened with focus on one and wrapped Tab around them. Closing returns focus to whatever was focused before, so the keyboard user lands back where they left. The return is a macrotask behind the close: restored any sooner, the closing keydown's own default action — activating the focused element — lands on the opener and clicks it, and the dialog reopens. If you wire Enter in a field to close the dialog, preventDefault() anyway (@keydown.enter.prevent): it states that the press was consumed.
  • autofocus picks the landing spot. The first focusable is the ✕, which is the wrong place to start a dialog that asks a question — Enter there dismisses it instead of answering it. Mark the control you want and it wins: <Input v-model="path" autofocus />. The first visible [autofocus] in the panel is used; with none, the first focusable is still it. A watcher of your own on open can't do this — the dialog's runs after yours and takes focus back.
  • Scroll lock — body overflow is pinned while open and restored on close.
  • role + aria-modal="true", with aria-labelledby/aria-describedby wired from title/description.
  • alertdialog ignores Escape and outside clicks by design — put a Cancel in the footer.

Notes

  • No animation-aware unmount beyond a fade+scale Transition (a fade+slide on an edge); the panel unmounts when open is false.
  • Scroll lock doesn't compensate for the scrollbar's width, so a page with a visible scrollbar can shift by a pixel or two on open. Fine for most apps; a scrollbar-gutter pass can come later if it matters.

Related Packages

@surstromming/popover

Panel anchored under a trigger; closes on outside click and Escape.

1,481 0
@surstromming/design

Everything related to design system, including colors, typography, and spacing.

1,363 0
@surstromming/scroll-area

A scroll container with an Edge-style overlay scrollbar.

1,157 0

Version History

Version Released Vue License
0.1.7 ^3.4.0 MIT
0.1.6 ^3.4.0 MIT
0.1.5 ^3.4.0 MIT
0.1.4 ^3.4.0 MIT
0.1.3 ^3.4.0 MIT