A window that takes the page away until it is answered or dismissed. The most expensive
thing this system can put in front of a reader, and the one with the most ways to build it
wrong — so it is a native <dialog>, which gets four of the five hard
parts right without being asked.
Both buttons open the real .ds-modal. Try Escape, try
tabbing past the last control, and try scrolling the page behind them.
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 whose content is the point and wants the room — a figure viewer. Not for more text: a wider measure makes a paragraph harder to read, and the default width is already at a comfortable line length.
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: it takes focus, makes the rest of the page inert, and cannot be ignored. That is the whole value and the whole cost.
| Reach for a modal when | Not when |
|---|---|
| The reader must decide before anything else can happen — confirming a withdrawal, agreeing to a data policy. | The information is useful but not blocking. That is an alert in the page. |
| The content is the reader's current task and wants the whole screen — a figure at full size. | The content is a lookup they want mid-sentence. That is a popover. |
| A short form belongs to something on the page and would lose its context on a page of its own. | The form is long enough to scroll, or the reader may need to consult the page behind it. Give it a page. |
| — | Announcing something. arXiv does not interrupt readers to tell them things (Brand & vision); the announcement band exists for that and can be ignored. |
These are the five things a modal must do. The reason this component
is a <dialog> rather than a <div role="dialog"> is the
right-hand column — and hand-built modals fail them in roughly this order.
| Requirement | Native? | Why it is hard by hand |
|---|---|---|
| Focus trap | Yes | The browser makes the rest of the page inert — not just untabbable, but unreachable by a screen reader's own navigation, which a JavaScript trap cannot do. A hand-rolled trap has to enumerate every focusable element, and that list is longer than anyone's implementation. |
| Focus restore | Yes | close() returns focus to whatever opened it. Forget this and a keyboard
user is dropped at the top of the document with no idea where they were. |
| Escape closes it | Yes | Free, and it fires a cancel event first if you need to intervene —
to warn about unsaved changes, say. |
| Above everything | Yes | It renders in the top layer with no z-index, so it cannot lose to a
stacking context somewhere up the tree. This is what
DESIGN-POLICIES means by
“modal dialogs use the native <dialog> top layer”. |
| Scroll lock | No | The one gap. The page behind still scrolls, so a reader spinning the wheel loses
their place in a document they cannot see. The stylesheet closes it in CSS —
html:has(.ds-modal[open]) { overflow: hidden } — so it still needs no
JavaScript. |
prefers-reduced-motion to switch off
when nothing moves.