A window that overlays the background page until it is answered or dismissed. There are many ways to build a modal wrong. The design system starts with a native <dialog>, which gets four of the five hard parts right by default. The last part is achieved with css.
Click either button below to open a real .ds-modal. Modals can be closed with Escape, the close button, or by clicking one of the action buttons.
Test keyboard-tabbing past the last control and scrolling the page behind the modal: neither will work. The modal is appropriately focus-trapped, critical for accessibility. Long content is scrollable within the modal.
A decision
<button class="ds-btn ds-btn-primary" type="button" data-open="demo-confirm">A decision</button>
<dialog class="ds-modal" id="demo-confirm" aria-labelledby="demo-confirm-title">
<div class="ds-modal-header">
<h2 class="ds-modal-title" id="demo-confirm-title">Withdraw this submission?</h2>
<button type="button" class="ds-close" data-close>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
<span class="is-sr-only">Close</span>
</button>
</div>
<div class="ds-modal-body">
<p>Withdrawing replaces the paper with a withdrawal notice. The submission stays in the record and keeps its identifier — arXiv is a permanent archive, so nothing is removed.</p>
<p>This cannot be undone from here.</p>
</div>
<div class="ds-modal-footer">
<button class="ds-btn ds-btn-text ds-modal-footer-start" type="button" data-close>Cancel</button>
<button class="ds-btn ds-btn-primary" type="button" data-close>Withdraw</button>
</div>
</dialog>
// Open and close. Focus trap, focus restore, Escape and stacking are
// the element's own doing; the scroll lock is in the stylesheet.
document.getElementById('demo-confirm').showModal();
document.getElementById('demo-confirm').close();
<dialog> element opened with
showModal(). Sized against the viewport rather than its content, because
a modal that outgrows the window puts its own actions out of reach.<dialog>, pointing at the title. It is the one
thing the native element cannot infer, and without it the dialog announces with no name
at all.aria-labelledby
points at..ds-modal-header, after the title.Long content
<button class="ds-btn" type="button" data-open="demo-long">Long content</button>
<dialog class="ds-modal" id="demo-long" aria-labelledby="demo-long-title">
<div class="ds-modal-header">
<h2 class="ds-modal-title" id="demo-long-title">What reading data is collected</h2>
<button type="button" class="ds-close" data-close>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
<span class="is-sr-only">Close</span>
</button>
</div>
<div class="ds-modal-body">
<p>The header and the actions stay put; only this region scrolls. That is the reason the dialog caps its own height — a modal that grows with its content eventually puts its own buttons below the bottom of the window, where the reader cannot reach the thing they opened it to do.</p>
<p>Which papers you open, in what order, and for how long. Nothing you type. Nothing from outside arXiv.</p>
<!-- … more paragraphs … -->
</div>
<div class="ds-modal-footer">
<button class="ds-btn ds-btn-text" type="button" data-close>No thanks</button>
<button class="ds-btn ds-btn-primary" type="button" data-close>I agree</button>
</div>
</dialog>
<dialog class="ds-modal"> and open it with showModal(). Never
use show(): it opens a non-modal dialog, with no backdrop, no inert page
and no focus trap — every guarantee quietly absent, on markup that looks correct. Never build
one from a <div role="dialog">, which starts with none of those guarantees
either.aria-labelledby on the
<dialog>, pointing at the id of its .ds-modal-title. It is the
one thing the native element cannot infer, and without it the dialog announces with no name at
all.showModal() moves focus into the
dialog, and close() returns it to the control that opened it. Do not set focus by
hand on open or on close, and always close with close(): a dialog hidden by
removing its open attribute or by CSS never gives focus back.cancel event first. Only intercept that event to warn about unsaved changes, and
then still offer a way to close..ds-close holds a
<span class="is-sr-only"> that says what it closes, such as “Close figure
viewer”. Do not use title or aria-label for this, because page
translation tools skip attributes.For a dialog where you need maximum viewing space, like a figure, add class .ds-modal--wide. Don't use the wide modifier for more text only. The default width is set to a comfortable line lenth and increasing the width reduces readability.
Where the closedby attribute is unsupported the dialog keeps its close button and Escape, which is the correct thing to fall back to.
<button class="ds-btn" type="button" data-open="demo-wide">Wide — a viewer</button>
<dialog class="ds-modal ds-modal--wide" id="demo-wide" closedby="any" aria-labelledby="demo-wide-title">
<div class="ds-modal-header">
<h2 class="ds-modal-title" id="demo-wide-title">Figure 3 — scalar perturbation growth</h2>
<button type="button" class="ds-close" data-close>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
<span class="is-sr-only">Close figure viewer</span>
</button>
</div>
<div class="ds-modal-body">
<!-- the figure, at full size -->
</div>
</dialog>
.ds-modal. The dialog takes nearly the whole viewport, and its
height is fixed rather than fitted to the content.Rarely. A modal interrupts the user experience, takes focus, makes the rest of the page inert, and cannot be ignored. Use this table to decide:
| Reach for a modal when | Not when |
|---|---|
| The user must decide before anything else can happen, for example confirming a deletion or agreeing to a policy. | The information is useful but not blocking, and the reader did not ask for it. The right options are an alert or marginalia note. |
| Displaying a full-size version of a figure. It would disrupt reading if placed directly on the page at that size. | The user needs a quick lookup related to specific content on the page. The right component is a popover. |
| The user would lose context if the content were placed directly on the page. For example, a short optional form. | The form is long enough to scroll, or the reader may need to consult the page behind it for context. The right choice is to build it out as a full page form. |
| The reader asks to look through a long list or explanation, such as every category a report covers, that would push the main content down if it were on the page. | The reader wants to keep the page in view while reading it. The right component is a popover. |
| — | Announcing something. arXiv does not interrupt readers to share news. The right components is the announcement band which can be dismissed or ignored by users and does not interrupt them. |
The reason this component is a <dialog> rather than a <div role="dialog"> are the reasons listed below. Hand-built modals often fail at least one of them.
| Requirement | Native? | What it does |
|---|---|---|
| Focus trap | Yes | The browser makes the rest of the page unreachable by a screen reader's own navigation (including keyboard tabbing) which a JavaScript trap cannot do. A hand-built trap must enumerate every focusable element and that list is pretty long. |
| Focus restore | Yes | close() returns focus to whatever opened it. If you forget this, a screen reader or keyboard-only user will be dropped at the top of the document again and lose their place. |
| Escape closes it | Yes | Default behavior, and it fires a cancel event first if you need to warn the user about unsaved changes, etc. |
| Overlays everything | Yes | It renders in the top layer with no z-index, so it cannot mistakenly lose its order to a stacking context somewhere up the tree. |
| Scroll lock | No | The one gap that <dialog> does not cover by default. The stylesheet resolves it with html:has(.ds-modal[open]) { overflow: hidden }. Still JavaScript-free. |
prefers-reduced-motion to switch off
when nothing moves.