Press / to search

    No results

    Search failed

    npm GitHub

    <modal-elemental>

    A <dialog> opened as a modal, per the APG Modal Dialog pattern โ€” and mostly by the browser, which is the point. Light DOM, no shadow root, nothing moved: the dialog stays where your markup put it and every attribute on it is still the platform's own.

    Nothing up my sleeve

    Escape closes it, and so does the cross. A click outside does not โ€” that is closedby="any", and it is opt-in.

    <button type="button" command="show-modal" commandfor="hello">Open a modal</button>
    
    <modal-elemental>
      <dialog id="hello">
        <h2>Nothing up my sleeve</h2>
        <form method="dialog"><button type="submit">Close</button></form>
      </dialog>
    </modal-elemental>

    What the browser already does

    Nearly all of it, and better than script can. showModal() puts the dialog in the top layer โ€” above every z-index, out of every overflow: hidden ancestor โ€” makes the rest of the page inert, moves focus in, brings it back on close, and closes on Esc. Nesting is part of that: a second modal is a second entry in the top layer, and the browser computes inertness from the topmost one, so the dialog underneath goes quiet on its own. No focus trap, no z-index arithmetic, no two libraries arguing over who owns the page.

    What is left over is this element:

    The gap Why the platform leaves it What this does
    an exit animation a closing dialog leaves the top layer in the same frame, so it vanishes mid-fade. Deferring that is the overlay property โ€” Chromium only, no Firefox, no Safari holds the dialog open until its animation ends, then closes it
    a click on the backdrop closedby="any" is Chrome 134, Firefox 141, and not in Safari or iOS at all the same three values, implemented here, in every browser
    the page not scrolling inert stops a click and a Tab. It never stopped a wheel overflow: hidden on the root while any modal is open, and a reserved gutter so the page does not jump when the scrollbar goes
    stacked backdrops every modal paints its own, so three open is three sheets of dim numbers them, and only the bottom one dims
    a name on the dialog a <dialog> takes no name from its contents, so an unlabelled one is announced as "dialog" and nothing more points aria-labelledby at the first heading inside
    a close button the APG strongly recommends a visible one; HTML gives you <form method="dialog"> and leaves the rest writes the cross in the corner, unless closedby="none"

    Everything else is the browser's, unchanged and unwrapped.

    Prior art

    How it nests Who contains focus
    a11y-dialog supported, and called "a questionable design pattern" โ€” the dialogs have to be nested in the DOM as well, or VoiceOver on Safari loses the inner one the library
    modally supported, with its own parent-child detection the library, through a focus trap it writes
    this supported, in the DOM or not, because the top layer is not the DOM the browser, through inert

    Containing focus is the line worth drawing. A focus trap in script is a keydown handler racing everything else on the page; showModal() makes the rest of the document inert, which takes it out of the tab order and out of the accessibility tree, so a screen reader's own cursor cannot wander out either.

    Note

    This element replaces modally, which is now deprecated. Nesting, closeOthers and hash-driven opening are all here; the width and alignment options are CSS custom properties, since that is what they always were. The YouTube and Vimeo helpers are not โ€” an embed is markup you write once, and the examples below are that markup.

    Usage

    import 'book-of-elementals/modal';
    @use "book-of-elementals/modal/style.scss"; // structure, motion, and the sheet of dim
    @use "book-of-elementals/modal/theme.scss"; // the look, optional

    Or the single-element bundle โ€” no build step, no script to write:

    <script src="https://unpkg.com/book-of-elementals/dist/elementals/modal.min.js"></script>
    <link rel="stylesheet" href="https://unpkg.com/book-of-elementals/dist/elementals/modal.min.css" />
    <link rel="stylesheet" href="https://unpkg.com/book-of-elementals/dist/elementals/modal-theme.min.css" />

    The <dialog> is the element's own direct child, and it needs an id โ€” that is what a trigger points at. Without one, the element writes a generated id, and the modal can only be opened from script.

    Opening and closing it

    The vocabulary is HTML's own: invoker commands, command on the button and commandfor naming the dialog.

    Markup What it does
    command="show-modal" commandfor="id" opens it
    command="request-close" commandfor="id" closes it
    command="close" commandfor="id" the same here โ€” both are animated
    <form method="dialog"> closes it on submit, and the button's value lands in returnValue
    <a href="#id"> opens it too, and is the one that still works with no script
    dialog.showModal(), dialog.show() from your own script, on the <dialog> โ€” the element picks it up

    The element handles those clicks itself rather than leaving them to the browser, which is what makes the close animated โ€” and what makes the markup work in browsers with no invoker commands of their own. Write it the same way either side of that line.

    Every one of them is remote: the trigger is matched by id on the document, so it can sit anywhere โ€” a toolbar at the top of the page, a row in a table, another modal. Nothing has to be inside the <modal-elemental> except the <dialog> itself.

    That last row is the one worth knowing about. document.getElementById("feedback") hands you the dialog, not the element around it, so showModal() on it is the call most people reach for โ€” and it works: the element notices, animates it in, counts its backdrop with the rest and locks the page's scroll, exactly as though a button had opened it. A show() gets the visible state and nothing else, because a non-modal dialog sits in the page rather than over it. el.show() on the <modal-elemental> is the same open with close-others honoured.

    <button type="button" command="show-modal" commandfor="feedback">Send feedback</button>
    
    <modal-elemental>
      <dialog id="feedback">
        <h2>Send feedback</h2>
        <form method="dialog">
          <p><label>What happened? <input name="what" autofocus /></label></p>
          <button type="submit" value="cancel">Cancel</button>
          <button type="submit" value="send">Send</button>
        </form>
      </dialog>
    </modal-elemental>
    /* room for the dialog: the preview frame is measured from the sample, and the sample is a button */
    body { min-block-size: 20rem; }

    What closes it

    closedby is the platform's attribute, with the platform's three values and the platform's default. Write it on the <modal-elemental>; written on the <dialog> it is moved up on upgrade, because a browser that supports it natively would light-dismiss the modal itself โ€” instantly, with the fade cut off, and with a cancel event that cannot be prevented.

    Value Esc Click outside For
    closerequest (default) closes no a form, anything with unsaved input
    any closes closes a lightbox, a menu, anything glanced at
    none no no a decision that has to be made

    The default is the one to reach for when a stray click must not throw work away: write no closedby at all and Escape is the only key out, plus the cross. any is the opt-in, and the only one of the three that watches the backdrop.

    none is not a lock. A close watcher only argues once, so a second Esc closes the dialog anyway โ€” that is the platform refusing to trap a reader, and it is not something this element should undo. Use it to catch a stray press, not to hold someone hostage.

    The cross in the corner

    Every modal gets one, written by the element โ€” a <button command="request-close"> as the dialog's first child, so it is the first thing focus lands on and the first thing a screen reader reaches, in the corner it is drawn in. Give it a name in your own language with close-text:

    <modal-elemental closedby="any" close-text="Zatvori">
      <dialog id="โ€ฆ">โ€ฆ</dialog>
    </modal-elemental>

    Three things follow from where it sits:

    • It is absolute, against the dialog. A modal dialog is position: fixed by the browser's own rule, so it is already the containing block โ€” nothing has to be positioned to make the corner work. Being out of the flow, it moves nothing; theme.scss gives the element right after it room on that side, so a heading does not run under it.

    • A picture or a film right after it gets no room, and the cross lands over it โ€” which is where every lightbox puts one. Reserving the gutter there would cost the image a strip of dialog surface down one edge, and on a media box sized width: 100% without a border-box reset it is 100% plus the gutter, which is a dialog that scrolls sideways. What the corner costs instead is a repaint: the cross inherits the dialog's text colour, and no colour is safe over a picture nobody chose.

      dialog#lightbox .modal-elemental-close { color: white; background: rgb(0 0 0 / 40%); }
    • A dialog long enough to scroll takes the cross with it. That is the cost of the corner: an absolutely positioned box scrolls with the content it is positioned against. Give a long modal a close button of its own at the end of the content, where the reader ends up.

    • closedby="none" gets no cross. That value is a dialog to be answered rather than dismissed, and a cross in the corner is a dismissal with a different shape. Write your own button there, saying what taking it means.

    Focus starts on the cross, which is right for a dialog that is read and wrong for one that is filled in. autofocus moves it:

    <dialog id="feedback">
      <h2>Send feedback</h2>
      <input name="what" autofocus>
    </dialog>

    Nesting

    Open a modal from inside a modal. There is nothing to turn on: the second showModal() stacks, the first goes inert underneath it, Esc closes the innermost, and focus walks back out the way it came in.

    <button type="button" command="show-modal" commandfor="settings">Settings</button>
    
    <modal-elemental closedby="any">
      <dialog id="settings">
        <h2>Settings</h2>
        <p>Two devices are signed in.</p>
        <button type="button" command="show-modal" commandfor="confirm">Sign out everywhere</button>
      </dialog>
    </modal-elemental>
    
    <modal-elemental>
      <dialog id="confirm">
        <h2>Sign out everywhere?</h2>
        <p>You will have to sign in again on both devices.</p>
        <form method="dialog">
          <button type="submit" value="cancel">Cancel</button>
          <button type="submit" value="ok">Sign out</button>
        </form>
      </dialog>
    </modal-elemental>
    /* room for the dialog: the preview frame is measured from the sample, and the sample is a button */
    body { min-block-size: 20rem; }

    The confirmation is a sibling, not a child. Either works โ€” the browser does not care where in the document a dialog sits, since the top layer is not the DOM โ€” but a modal that lives outside the one that opens it can be opened from anywhere else too, and it keeps the markup of each one to the thing it says.

    Only the bottom modal dims the page. Each dialog carries data-depth, and the stylesheet paints the backdrop of data-depth="1" alone, so a stack of three is one sheet of dim with three boxes on it rather than a page that gets darker the deeper you go.

    Opening one instead of the others, rather than on top of them, is close-others:

    <modal-elemental close-others>
      <dialog id="terms">โ€ฆ</dialog>
    </modal-elemental>

    A modal whose id is the fragment of the URL opens itself. That is one mechanism doing three jobs โ€” an <a href="#id"> that opens it, a link someone else can paste, and the back button closing it, which is what a reader on a phone will press whether or not anyone told them to.

    <a href="#pricing-details">What is included?</a>
    
    <modal-elemental closedby="any">
      <dialog id="pricing-details">โ€ฆ</dialog>
    </modal-elemental>

    Closing it takes the fragment back off: the entry the link pushed is spent with history.back(), so the next press does not reopen what was just closed. A modal opened by a button does not touch the URL at all โ€” the link is the thing that puts it there.

    Video and lightboxes

    There is no video option, no lightbox mode and no player integration. A modal holds markup, and markup is what an image or an embed already is. The one thing the element does for them is the thing a modal has to: when it closes, what was playing stops. A closed dialog is display: none, which pauses nothing โ€” the sound would carry on over a page the reader has already gone back to.

    Inside the dialog On close
    <video>, <audio> paused where it was, so reopening carries on from there
    <iframe> parked at about:blank, since a cross-origin player cannot be paused from here โ€” the framed document is discarded, so it reopens at the start

    Starting it is the other half, and that one is yours: modal-toggle says when a modal opened and hands you the dialog, play() is the platform's, and a player in an iframe takes its instruction as a query parameter. Both are below.

    A lightbox

    <button type="button" class="thumb" command="show-modal" commandfor="lightbox">
      <img src="https://picsum.photos/id/136/320/200" alt="Tall sandstone towers rising out of pine woods" width="320" height="200">
    </button>
    
    <modal-elemental closedby="any">
      <dialog id="lightbox" aria-label="Tall sandstone towers rising out of pine woods">
        <img src="https://picsum.photos/id/136/1280/800" alt="Tall sandstone towers rising out of pine woods" width="1280" height="800">
      </dialog>
    </modal-elemental>
    body { margin: 0; padding: 1rem; min-block-size: 32rem; font: 1rem/1.5 system-ui, sans-serif; }
    
    .thumb { padding: 0; border: 0; background: none; cursor: pointer; }
    .thumb img { display: block; border-radius: 0.25rem; }
    
    /* the theme's box, opened out for a picture: no padding, no surface, wider */
    modal-elemental > dialog#lightbox {
      --modal-elemental-max-width: 60rem;
      padding: 0;
      background: none;
      box-shadow: none;
      overflow: visible;
    }
    
    /* the picture is sized against the viewport, not against the box: the box is
       `fit-content` around the picture, so a percentage of it would be measuring itself.
       `object-fit` is for the short viewport, where the height cap is what bites */
    #lightbox img {
      display: block;
      inline-size: min(60rem, calc(100vw - 3rem));
      block-size: auto;
      max-block-size: calc(100dvh - 3rem);
      object-fit: contain;
      border-radius: 0.5rem;
    }
    
    /* the element's own cross, repainted for a photograph: it inherits the page's text
       colour, and no page colour is safe over a picture nobody chose */
    #lightbox .modal-elemental-close {
      color: white;
      background: rgb(0 0 0 / 40%);
    }
    
    #lightbox .modal-elemental-close:hover { background: rgb(0 0 0 / 65%); }

    The dialog is named with aria-label here because there is no heading to point at โ€” a picture is the whole of it. The close button is not in the markup because the element writes it; the CSS above only repaints it, since a cross in the page's own text colour is a cross nobody finds against a photograph.

    Photograph by Marcin Czerwinski, from Unsplash, served here through Lorem Picsum.

    A video

    <button type="button" command="show-modal" commandfor="clip">Watch the clip</button>
    
    <modal-elemental closedby="any">
      <dialog id="clip" aria-label="A film clip, captioned">
        <video controls playsinline width="640" crossorigin="anonymous"
          src="https://mdn.github.io/shared-assets/videos/friday.mp4">
          <track kind="captions" label="English" srclang="en" default
            src="https://mdn.github.io/shared-assets/misc/friday.vtt">
        </video>
      </dialog>
    </modal-elemental>
    body { margin: 0; padding: 1rem; min-block-size: 20rem; font: 1rem/1.5 system-ui, sans-serif; }
    
    modal-elemental > dialog#clip { --modal-elemental-max-width: 40rem; padding: 0.5rem; }
    #clip video { display: block; width: 100%; height: auto; }
    
    /* the element's own cross, repainted for a film: it inherits the page's text colour, and a
       picture that moves has no colour that stays safe under it */
    #clip .modal-elemental-close { color: white; background: rgb(0 0 0 / 40%); }
    #clip .modal-elemental-close:hover { background: rgb(0 0 0 / 65%); }
    // One listener for every modal on the page: `modal-toggle` bubbles, and its detail carries
    // the dialog that opened.
    document.addEventListener('modal-toggle', (e) => {
      if (!e.detail.open) return;
      const video = e.detail.dialog.querySelector('video');
      if (!video) return;
      // A rejected promise here is the browser's autoplay policy, not a bug: a modal opened
      // from the URL arrives with no click behind it, and sound needs one.
      video.play().catch(() => {});
    });

    Open it and it plays; close it and it stops at the second the reader left it; reopen, and it carries on from there. The pause is the element's, the play is that listener, and nothing else is needed on either end.

    Two things make it work. The listener runs inside the click that opened the modal, so the gesture the autoplay policy asks for is still the browser's to spend โ€” checked in Chrome on this page: the promise resolves and the clip runs, with nothing muted to get it there. And autoplay on the <video> is not the same thing and not what you want here: the attribute fires when the page loads, not when the modal opens, on a video nobody has asked for yet.

    The <track> is not decoration and not this element's doing โ€” WCAG 2.2 SC 1.2.2 asks for captions on anything prerecorded that has audio, and a video in a modal is still a video. crossorigin="anonymous" is what makes it work from another origin: a caption file is fetched under CORS, and without that attribute the track loads as nothing at all, silently. Same origin, drop it.

    The clip and its caption file are MDN's, from mdn/shared-assets.

    YouTube and Vimeo

    The same markup, with the player's own iframe in it. Autoplay is a query parameter rather than a listener, because the frame is not loaded until the modal is opened โ€” autoplay=1 is read at that load, and the click that opened the dialog is the gesture behind it, handed to the player by allow="autoplay". Checked in Chrome on this page: opening the modal starts the film. A browser that blocks autoplay with sound โ€” Firefox does, by default โ€” loads the same frame and leaves it on its own play button, which is the failure worth having.

    <button type="button" command="show-modal" commandfor="talk">Watch the film</button>
    
    <modal-elemental closedby="any">
      <dialog id="talk" aria-label="Big Buck Bunny">
        <iframe
          src="https://www.youtube-nocookie.com/embed/aqz-KE-bpKQ?autoplay=1"
          title="Big Buck Bunny, a Blender Foundation short film"
          width="560" height="315" loading="lazy"
          allow="accelerometer; autoplay; clipboard-write; encrypted-media; picture-in-picture"
          allowfullscreen
        ></iframe>
      </dialog>
    </modal-elemental>
    body { margin: 0; padding: 1rem; min-block-size: 20rem; font: 1rem/1.5 system-ui, sans-serif; }
    
    modal-elemental > dialog#talk { --modal-elemental-max-width: 40rem; padding: 0.5rem; }
    #talk iframe { display: block; width: 100%; aspect-ratio: 16 / 9; height: auto; border: 0; }

    Big Buck Bunny ยฉ Blender Foundation, peach.blender.org, CC BY 3.0 โ€” played here from YouTube, which is a third party this page hands nothing to until you press the button.

    Vimeo is the same shape, with the parameter it uses for the same thing:

    <modal-elemental closedby="any">
      <dialog id="reel" aria-label="The reel">
        <iframe
          src="https://player.vimeo.com/video/VIDEO_ID?autoplay=1"
          title="The reel"
          width="640" height="360" loading="lazy"
          allow="autoplay; fullscreen; picture-in-picture"
          allowfullscreen
        ></iframe>
      </dialog>
    </modal-elemental>

    Both players are sized by ratio rather than by height, which is the aspect-ratio line in the sample's CSS: an iframe's height attribute is a starting number, and a width in percent with a height in pixels is a letterbox at every size but one.

    Four things that are not optional on an embed. title, because an iframe with no title is announced as "frame" and nothing else. youtube-nocookie.com over youtube.com, which is the same player without the tracking cookie. allowfullscreen, since a video in a modal is exactly where someone will reach for it. And loading="lazy", which is what holds the fetch until the modal is opened โ€” without it the player is loaded, and told who is reading, on every page view that never opens the dialog. Checked in Chromium: a plain iframe in a closed <dialog> requests its source immediately, a lazy one waits for showModal().

    Closing the modal points the frame at about:blank, which is what stops the player: the document that was playing is discarded, and the src and loading the markup above wrote are put back on the next open.

    Reloading the frame in place โ€” setting src to the value it already had โ€” is the version that looks right and is not. A loading="lazy" frame inside a closed dialog is display: none, so that navigation is deferred until the frame is on screen again, and the player goes on running behind the dialog the reader has dismissed. Measured with this demo's markup on a page of its own: closing the modal left the YouTube document loaded in Chromium 151 and Firefox 153, where parking leaves the frame at about:blank in both. Firefox defers a lazy navigation to about:blank too, which is why loading is eager for that one hop and back to what the author wrote afterwards โ€” a parked frame reads src="about:blank" loading="eager" for as long as its modal is closed.

    Counting requests, with a frame of one's own in the player's place, in Chromium 151, Firefox 153 and WebKit 26.5: one when the modal is opened, none when it is closed, one more on each reopen. What a reader gets from that is the video from the beginning each time, which is the price of a player that only takes instructions from its own origin.

    Degrading

    Missing What you get
    the script never loads <a href="#id"> still reaches the dialog: style.scss shows it in the page, in flow, not modal. No cross either โ€” the element writes that โ€” so what closes it is whatever you wrote yourself
    the script never loads, and the browser has invoker commands command="show-modal" opens it natively โ€” modal, and without the animation
    prefers-reduced-motion no transition either way. The element waits for an animation that is not there and closes at once
    the theme is not imported the browser's own dialog box, with the backdrop and the motion from style.scss

    That first row is why the fragment is worth supporting at all: a modal is usually a piece of the page, and a piece of the page a reader cannot get to is a piece of the page that is gone.

    Note

    On iOS, touch scrolling gets past overflow: hidden on the root, so the page behind can still be dragged. overscroll-behavior: contain on the dialog and its backdrop is what closes that, and it is in style.scss already โ€” Chrome 144 was the first to honour it there, and the rest will follow.

    The page does not jump when a modal opens

    Locking the scroll takes the scrollbar away with it, and on every platform that draws a classic one โ€” Windows, most Linux, macOS set to always show โ€” the page behind is handed that width back as content and shifts sideways under the backdrop. The old fix was to measure the scrollbar in script and pad the body by it. style.scss reserves the space in CSS instead:

    html:has(modal-elemental) {
      scrollbar-gutter: stable;
    }

    scrollbar-gutter: stable holds the gutter whether the box overflows or not, and holds it under overflow: hidden as well, so opening a modal changes no width at all. It is on the page from the first paint rather than added with the modal, because a gutter that arrives on open is the same jump in the other direction on a page too short to have had a scrollbar โ€” and it is scoped to a page that has a <modal-elemental> in it, so a page that can never lock keeps its layout untouched.

    What it costs: on those same classic-scrollbar platforms a page short enough not to scroll still shows the empty gutter. That is the trade โ€” a strip that is always there against a layout that moves โ€” and if you would rather have neither, html { scrollbar-gutter: auto } in your own stylesheet puts the jump back.

    Two things worth knowing. Safari before 18.2 does not implement the property and shifts as it always did; its scrollbars overlay the content by default, so there is usually nothing there to shift. And a <modal-elemental> appended to the page later brings the gutter with it, which is one jump at insertion instead of one at open โ€” put the dialog in the markup, or set scrollbar-gutter: stable on html yourself, if a page builds its modals on the fly.

    API

    Attributes

    Attribute Type Default Description
    closedby enum closerequest any, closerequest or none. Moved up from the <dialog> if written there
    close-others boolean โ€” Opening this one closes every modal already open, instead of stacking
    close-text string Close The close button's accessible name

    Properties

    Property Type Description
    dialog HTMLDialogElement Read-only. The direct child it upgrades
    open boolean Read-only. True until the closing animation is over
    closedBy string Read-only. What closedby resolves to

    Methods

    Method Description
    show() Opens it, honouring close-others
    close(value?) Animates it out, then closes with that returnValue

    Calling close() on the <dialog> itself still works โ€” the platform performs it immediately, without the animation, and the element tidies up after it. showModal() and show() on the dialog are picked up in full, animation included.

    Events

    Event Detail
    modal-toggle open โ€” the new state. dialog โ€” the dialog. depth โ€” how deep in a stack it sits, 0 once closed

    The dialog's own close and cancel events are untouched and still fire, on the <dialog>, where the platform puts them.

    Styling hooks

    modal-elemental > dialog[data-state="open"] {
    } /* on screen, or arriving */
    modal-elemental > dialog[data-state="closing"] {
    } /* leaving, and still in the top layer */
    modal-elemental > dialog[data-depth="1"] {
    } /* the bottom modal โ€” the one that dims the page */
    modal-elemental:not(:defined) {
    } /* before upgrade */
    .modal-elemental-close {
    } /* the cross the element writes */

    data-state is the animation's hook, and [open] is not: the dialog is still open through the whole of its exit, which is what keeps it in the top layer while it fades.

    The look

    style.scss is structure, motion and the sheet of dim โ€” the last of which is not decoration: the APG only lets a dialog call itself modal when the content behind it is obscured as well as inert. theme.scss is the box, and is optional.

    Property Default Description
    --modal-elemental-duration 200ms Both ends of the animation. 0s closes instantly
    --modal-elemental-easing ease Both ends
    --modal-elemental-backdrop rgb(0 0 0 / 50%) The sheet over the page
    --modal-elemental-max-width 32rem Capped again at 100% - 2rem, so a phone keeps its gutter
    --modal-elemental-padding 1.5rem Inside the box
    --modal-elemental-radius 0.75rem Its corners
    --modal-elemental-close-size 2rem The cross in the corner, both axes. Its glyph is sized from it
    --modal-elemental-close-inset 0.75rem How far the cross sits from the box's corner
    --modal-elemental-margin-block auto Where it sits: auto centres, 5vh auto pins it near the top
    <button type="button" command="show-modal" commandfor="options-dialog">Open it</button>
    
    <modal-elemental closedby="any">
      <dialog id="options-dialog">
        <h2>Turn the knobs</h2>
        <p>The Options tab restyles this box. Copy the rule out of the bottom of the panel.</p>
        <form method="dialog"><button type="submit">Close</button></form>
      </dialog>
    </modal-elemental>
    /* room for the dialog: the preview frame is measured from the sample, and the sample is a button */
    body { min-block-size: 20rem; }

    A dialog whose height changes while it is being read โ€” a search, a form that reveals a field โ€” is the case for --modal-elemental-margin-block: 5vh auto. Centred, the box moves under the reader every time it grows.

    Two more things the theme does that are worth knowing before you restyle it. The element right after the cross โ€” usually the heading โ€” is given room on that side, since the cross is out of the flow and would otherwise sit on top of the last word; a dialog with no cross reserves nothing. And the close waits for the animation the stylesheet describes: the element reads how long each transition says it takes and gives up on one that overruns it, up to two seconds, so a transition that stalls can never leave a modal that cannot be closed.

    Wanted Element
    a window that takes over the page until it is answered this
    a panel that shows and hides in place <disclosure-elemental>
    a drawer that is a panel at one breakpoint and a sheet at another <disclosure-elemental media>
    a list of actions hanging off a button <menu-elemental>
    a hint on hover and focus <tooltip-elemental>

    A modal is the heaviest thing on this list: it takes the page away, and everything else on it has to wait. Reach for one when the answer is genuinely needed before anything else can happen โ€” and when it is not, a disclosure in the flow of the page is the thing that does not interrupt.