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
headingslot viaaria-labelledby, or fromaria-labelwhen there is no heading.aria-describedbyis only set once thedescriptionslot 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.