Skip to content

Dialog JS

The Dialog component is a headless wrapper around the native <dialog> element. It offloads to the platform everything a modal needs: modality, focus trap, background inert, focus restore, top-layer stacking and Esc to close. It owns only what the platform does not provide: scroll-lock, an optional focus-trap for the non-modal path, and orchestrating your transitions.

It ships no markup: you author the HTML, wire the triggers with Action and describe the animations with Transition or ViewTransition children. The Dialog fans enter()/leave() out to every transition child it contains; the backdrop and the box are just two such children.

A drawer is not a separate component. It is a Dialog whose panel you anchor to an edge with your own CSS and, optionally, slide in with a transition. See Building a drawer.

Usage

Register the component, along with the Action and Transition components used by the authored HTML:

js
import { registerComponent } from '@studiometa/js-toolkit';
import { Action, Dialog, Transition } from '@studiometa/ui';

registerComponent(Action);
registerComponent(Dialog);
registerComponent(Transition);

The <dialog> becomes a transparent, full-viewport host. Inside it live an optional backdrop and the box, each a transition child with its own hidden/shown classes. Triggers are delegated to Action:

html
<!-- The trigger lives anywhere on the page. -->
<button
  type="button"
  data-component="Action"
  data-on:click="Dialog(#my-dialog)->target.open()">
  Open dialog
</button>

<dialog
  id="my-dialog"
  data-component="Action Dialog"
  data-on:cancel.prevent="Dialog.close()"
  class="fixed inset-0 m-0 p-0 w-full h-full max-w-none max-h-none bg-transparent overflow-y-auto">
  <!-- Clickable, fading backdrop -->
  <div
    data-component="Action Transition"
    data-on:click="Dialog(#my-dialog)->target.close()"
    data-option-enter-active="transition duration-300 ease-out"
    data-option-enter-from="opacity-0"
    data-option-leave-active="transition duration-200 ease-in"
    data-option-leave-to="opacity-0"
    data-option-leave-keep
    class="fixed inset-0 bg-black/50 opacity-0"></div>

  <!-- Centering layer — pointer-events-none so clicks fall through to the backdrop -->
  <div class="pointer-events-none relative flex min-h-full items-center justify-center p-4">
    <div
      data-component="Transition"
      data-option-enter-active="transition duration-300 ease-out"
      data-option-enter-from="opacity-0 scale-95"
      data-option-leave-active="transition duration-200 ease-in"
      data-option-leave-to="opacity-0 scale-95"
      data-option-leave-keep
      class="pointer-events-auto relative w-full max-w-lg p-8 rounded-lg bg-white shadow-2xl opacity-0 scale-95">
      <h2>Dialog title</h2>
      <p>Your content here.</p>
      <button
        type="button"
        data-component="Action"
        data-on:click="Dialog(#my-dialog)->target.close()">
        Close
      </button>
    </div>
  </div>
</dialog>

Because the native ::backdrop pseudo-element cannot be class-transitioned, neutralize it and let the backdrop element cover everything:

css
dialog::backdrop {
  background: transparent;
}

Triggers are delegated to Action

The Dialog class has no refs and adds no event listeners of its own. Every interaction is wired declaratively with Action:

  • Open / close / toggle: call the methods on the Dialog target: Dialog(#id)->target.open(), .close(), .toggle().
  • Esc: the <dialog> fires a native cancel event. data-on:cancel.prevent="Dialog.close()" runs your animated close instead of the instant native one. Omit it and the browser closes the dialog instantly (no leave transition).
  • Click outside: data-on:click="event.target === $el && Dialog.close()" closes when the click lands on the dialog host itself; in the backdrop-element pattern above the backdrop handles it directly with its own Action.

Any trigger you omit falls back to the platform behavior.

Transitions are fanned out

The Dialog awaits Promise.all over the enter()/leave() of every Transition and ViewTransition child:

  • enter() runs after showModal()/show(), so the dialog is already in the top layer when its children animate in.
  • leave() runs before dialog.close(), so the dialog is still painted while its children animate out.

With no transition child, Promise.all([]) resolves immediately and open/close are instant.

Building a drawer

There is no Drawer component: a drawer is a Dialog whose panel is anchored to an edge with your own CSS. Positioning and animation are entirely author-controlled; the library ships no drawer opinion.

Anchor the panel to the edge you want with layout classes and let the Dialog handle the rest:

html
<dialog id="drawer" data-component="Action Dialog" data-on:cancel.prevent="Dialog.close()">
  <!-- backdrop child … -->
  <!-- Right-anchored panel: position is just these classes. -->
  <div class="absolute inset-y-0 right-0 w-80 max-w-full bg-white">…</div>
</dialog>

Out of the box the panel appears and disappears instantly with the dialog. To slide it in, make the panel an optional transition child:

  • ViewTransition (recommended): it animates a snapshot in the view-transition overlay rather than the live element in the dialog's top layer, so the slide stays smooth in Firefox (a transform-transition inside a modal <dialog> judders there; see #532). Give the panel a unique view-transition-name and author the slide with ::view-transition-old() / ::view-transition-new() keyframes. The backdrop and panel batch into a single coordinated transition.
  • Transition / CSS: a plain Transition child with translate classes works too, as a fallback for browsers without the View Transitions API or for a non-modal (data-option-no-modal) drawer where the Firefox bug does not apply.

The slide direction is yours: it is the translate class / keyframes you choose (translate-x-full from the right, -translate-x-full from the left, translate-y-full from the bottom, and so on) paired with the matching edge-anchoring classes.

Two top-layer stacking guards

Because the modal <dialog> promotes its children to the top layer, two browser quirks are worth guarding against in your CSS — both are one-liners the drawer example applies:

  • Hide the panel with opacity-0, not just a transform. A panel that is only translate-x-full is offscreen but still fully painted, so Safari/WebKit captures it into ::view-transition-old() and the slide-out keyframes drag a ghost copy across the screen on open. Adding opacity-0 to the hidden state makes that snapshot empty. (Chromium/Firefox clip the offscreen content, so they don't show the ghost — but the opacity-0 is harmless there.)
  • Pin the group stacking with z-index. Some Chromium versions mis-order the snapshots of top-layer content, painting the semi-transparent backdrop over the panel and greying it out mid-transition. ::view-transition-group(panel) { z-index: 2 } above the backdrop keeps the panel on top everywhere.

See the drawer example for a complete right-side drawer sliding in via ViewTransition.

The <dialog> gotcha

Never put a display utility on the host <dialog>

Do not add grid, flex, block, … to the <dialog> element. Those override the user-agent rule dialog:not([open]) { display: none }, so a closed dialog stays laid out as a full-viewport fixed inset-0 layer and silently intercepts every click on the page — nothing else can be clicked and nothing opens.

Keep all layout on inner elements (the centering layer, the box) and let the dialog's native open/closed display stand.

See the examples for a live demo, and the JavaScript API for the full list of options, methods and events.