Documentation

Documentation

Modal

Accessible dialog with centered and edge-anchored placements, animated entry and exit.

The <dsgn-modal> component wraps the native <dialog> element and showModal(), so the top layer, background inertness, and focus containment come from the platform rather than a hand-rolled focus trap. The same component covers a centered modal and a sheet/drawer entering from any viewport edge.

All production copy comes from the consumer through slots and content-bearing props under the system-wide content ownership policy. This includes the built-in close control’s accessible name, authored through close-label.

Usage

<dsgn-modal id="invite">
  <span slot="heading">Invite teammates</span>
  <span slot="description">They get access as soon as they accept.</span>
  <p>Anyone you invite can see every project in this workspace.</p>
  <div slot="footer">
    <dsgn-button appearance="outline">Cancel</dsgn-button>
    <dsgn-button>Send invites</dsgn-button>
  </div>
</dsgn-modal>

<script type="module">
  document.querySelector('#invite').show();
</script>

Import the component module in your application entrypoint:

import '@ds-gn/ui/modal';

Playground

Props

Prop Type Default Description
open boolean false Whether the modal is shown. Reflected. The component sets it back to false itself whenever the dialog closes.
placement 'center' | 'left' | 'right' | 'top' | 'bottom' 'center' Where the dialog rests. The four edges are sheets that span that edge.
motion 'inherit' | 'none' | 'center' | 'left' | 'right' | 'top' | 'bottom' 'inherit' Direction the dialog animates from. inherit follows placement; none cross-fades with no spatial motion.
size 'sm' | 'md' | 'lg' | 'xl' | 'full' 'md' Size along the placement’s growth axis — width for center/left/right, height for top/bottom. full fills the viewport.
aria-label string '' Accessible name for the case where there is no visible heading. A filled heading slot takes precedence.
close-label string 'Close' Accessible name for the built-in close control. Ignored when a control is slotted into close.
no-close-control boolean false Drops the built-in close control. A slotted close control still renders.
no-backdrop-dismiss boolean false Blocks dismissal by clicking the backdrop.
no-escape-dismiss boolean false Blocks dismissal by Escape. Always leave another visible way out.
surface 'light' | 'dark' 'light' Surface color scheme.

aria-label, close-label, no-close-control, no-backdrop-dismiss, and no-escape-dismiss are the attribute names; the matching JavaScript properties are ariaLabel, closeLabel, noCloseControl, noBackdropDismiss, and noEscapeDismiss.

Methods

Method Description
show() Opens the modal. No-op if it is already open.
close(returnValue?) Closes immediately without firing the cancelable request event — the “already decided” path. The optional return value lands on dsgn-modal-close.
requestClose(reason?) Asks to close: fires the cancelable dsgn-modal-request-close first and stays open if a listener vetoes it. Returns whether the close proceeded.

Events

Event Detail Description
dsgn-modal-open — Fired after the dialog enters the top layer.
dsgn-modal-request-close { reason } Cancelable. Fired before closing for Escape, backdrop clicks, the close slot, and requestClose(). Call preventDefault() to keep the modal open.
dsgn-modal-close { reason, returnValue } Fired once the exit animation has finished and the dialog has left the top layer.

reason is one of 'api', 'escape', 'backdrop', or 'close-control'.

Slots

Slot Description
(default) Modal body content. This is the region that scrolls.
heading Title content. Supplies the dialog’s accessible name via aria-labelledby.
description Supporting text, referenced by aria-describedby.
footer Actions, typically buttons.
close A close control. Clicking anything slotted here requests a close, so no wiring is needed.

Empty regions collapse, so a modal with no footer has no stray padding. The header is the exception: it stays visible for the built-in close control unless no-close-control is set and no heading is supplied.

The built-in close control

Every modal renders its own close control in the header, so there is always a visible way out. It is the close slot’s fallback content, which means slotting your own control replaces it — no flag to unset:

<dsgn-modal>
  <span slot="heading">Invite teammates</span>
  <dsgn-button
    slot="close"
    appearance="ghost"
    icon-name="x"
    icon-position="only"
    aria-label="Close"
  ></dsgn-button>
</dsgn-modal>

Set no-close-control when the flow supplies its own dismissal, such as a footer “Cancel”. Never combine it with no-escape-dismiss — that leaves keyboard users with no way out.

Its accessible name is authored content: pass close-label from your application’s content source the way you would any other copy. Do not treat the component’s standalone 'Close' value as a production content default.

CSS parts

Part Description
dialog The native <dialog> element — the panel, its backdrop, and its animations.
close-button The built-in close button. Not rendered when a control is slotted into close.
dsgn-modal::part(dialog) {
  border-radius: 1.25rem;
}

Examples

Sheet from the right

<dsgn-modal placement="right" size="lg">
  <span slot="heading">Activity</span>
  <p>Everything that happened today.</p>
</dsgn-modal>

Destructive confirmation

<dsgn-modal id="confirm" size="sm" no-backdrop-dismiss>
  <span slot="heading">Delete project</span>
  <span slot="description">This cannot be undone.</span>
  <p>Everyone loses access immediately.</p>
  <div slot="footer">
    <dsgn-button appearance="outline" onclick="confirm.requestClose()"
      >Cancel</dsgn-button
    >
    <dsgn-button onclick="confirm.close('deleted')">Delete project</dsgn-button>
  </div>
</dsgn-modal>

Guarding a close

modal.addEventListener('dsgn-modal-request-close', (event) => {
  if (formIsDirty) event.preventDefault();
});

Escape, the backdrop, and the close slot all route through this event, so one listener guards every dismissal path. close() deliberately bypasses it.

React

import { Modal } from '@ds-gn/ui/react';

<Modal open={open} placement="bottom" onDsgnModalClose={() => setOpen(false)}>
  <span slot="heading">Share</span>
  <p>Anyone with the link can view this.</p>
</Modal>;

Because the component closes itself, a controlled consumer has to mirror the change back in onDsgnModalClose — otherwise the two states drift on the next open.

Accessibility

  • Built on showModal(), so the dialog is in the top layer, background content is inert, and focus is contained by the platform.
  • On open, focus moves to the first [autofocus] element in the modal’s content, falling back to the dialog itself. The native autofocus algorithm is not relied on because it is inconsistent for slotted content.
  • On close, focus returns to the element that was focused when the modal opened (following shadow roots), unless it has since been removed from the document.
  • Every modal has a visible close control by default, so a pointer user is never left with only Escape or the backdrop as a way out.
  • The accessible name comes from the heading slot via aria-labelledby, or from aria-label when there is no heading. aria-describedby is only set once the description slot has content — a dangling reference would leave the dialog with no description at all.
  • The page behind the modal is pinned while it is open, so long content scrolls inside the body region rather than moving the page underneath.
  • Backdrop dismissal requires the press and the release to land on the backdrop, so a text selection that ends outside the panel does not dismiss.
  • With prefers-reduced-motion: reduce, every placement cross-fades in place instead of sliding. motion="none" opts into the same treatment for everyone.

Keyboard

Key Behavior
Escape Requests a close (cancelable). Suppressed by no-escape-dismiss.
Tab / Shift+Tab Moves through the modal’s focusable content only; the platform keeps focus inside while it is modal.

Slotted content keeps its authored order, so the keyboard path runs heading → body → footer actions.

Esc