Actual CSS

Dialog

Centered modal overlay for focused tasks, confirmations, or forms, built on the native <dialog> element.

Related terms: modal, alert dialog, confirmation dialog.

Class reference

ClassKindDescription
.modalComponentCentered surface on the native <dialog>.
.scrollableVariantHeader and footer stay while the body scrolls.
.dialog-iconComponentCircular intent-aware icon well.
.closeCompositionIcon-only close button, at the top end.

Anatomy

.modal is the outer surface and deliberately has no inner padding. Wrap its content in a direct <form> or .stack; that wrapper owns the padding, rhythm, and overflow. Put .stack on the wrapper, never on dialog.modal itself.

For a header band, place <header> directly under dialog.modal, before the body wrapper. Put a flexible .media title region first and a .cluster of controls second. The title has the close control's minimum height; its leading icon and every trailing control share the same center axis. A wrapped title simply makes the row taller.

A direct dialog > header is dialog chrome and forms a distinct header band, painted --surface-subtle. Change the fill with a surface utility on the header (<header class="background-raised">). Put an ordinary content heading inside the padded form or .stack instead.

Keep a corner .close as a direct child of the dialog and put the title in the wrapper's direct <header>. The button then stays outside the content flow while the header reserves enough inline space to keep its title clear.

A .close placed as a direct child of the chrome header is still the corner overlay. That header reserves the same inline room for it, never below its own --modal-pad, so a wrapping title stays clear either way.

The corner close overlays the panel edge, so a body tall enough to scroll would run its scrollbar under the button. Give such a dialog the scrollable anatomy: the close then sits over the fixed header and the scrollbar starts below it.

Add .bleed to that header, or to the wrapper's footer, when it should form a full-width band. It reaches the dialog edges and corners, keeps the modal's inner padding, and takes a surface utility such as .background-subtle.

The wrapper's <footer> is a structural slot: it stays fixed under .scrollable but has no layout of its own. Compose the action row in markup — <footer class="cluster justify-content-end"> for trailing actions, .justify-content-space-between for a split pair.

Where each region lives

Two anatomies are legitimate. Pick one; each owns its scroll, footer, and close placement.

Wrapper header — a <header> inside the form/.stack body. The wrapper owns padding, rhythm, and overflow. With .scrollable the header and footer are fixed rows and the content between them scrolls. The .close stays a direct child of the dialog and the header reserves its inline room.

External header — a direct dialog > header, before the body wrapper. The header is dialog chrome outside the scroll container, so it never scrolls. The footer stays inside the wrapper and is the trailing fixed row under .scrollable. Wrap the .close in the header's trailing .cluster to keep it in flow; a .close that is a direct child of the header is still the absolute corner overlay, and the header reserves its room.

The .scrollable row template follows the regions the wrapper actually holds, so a modal without a header, without a footer, or with an external header all keep the footer reachable and the body scrollable.

Usage

Modals use the platform-native <dialog class="modal"> element with commandfor and command buttons.

Use command="show-modal" to open a modal dialog, or command="--show" to open it non-modally.

Add aria-haspopup="dialog" and aria-controls="<id>" to opening buttons so their semantics are present before either native or framework JavaScript runs. The command runtime is stateless and does not pre-scan triggers.

Use command="request-close" for cancel-style buttons so close requests go through the dialog's cancel lifecycle.

Use closedby="any" as the no-JavaScript light-dismiss path: the native dialog closes on backdrop click and Escape. Add data-dialog-dismissible so the optional runtime takes over backdrop click and closes with the dialog's transition; it rewrites closedby="any" to closedby="closerequest" so the native dialog and the runtime never double-handle. data-dialog-dismissible only gates backdrop click — Escape and explicit close requests always close, unless the application cancels the actual:dialog-cancel event.

Alert dialog

Use this shape when the dialog interrupts the flow and asks for a decision. It has no close icon and no light dismiss; the footer actions are the way out. closedby="none" keeps Escape and backdrop clicks from closing it, so a critical confirmation cannot be dismissed accidentally. With the optional runtime, backdrop clicks give a small static feedback instead of closing.

Delete project?

This action cannot be undone.

The project, saved reports, and connected automations will be removed permanently.

<button class="btn"
        type="button"
        commandfor="delete-dialog"
        command="show-modal"
        aria-haspopup="dialog"
        aria-controls="delete-dialog">
  Delete project
</button>

<dialog class="modal" id="delete-dialog" closedby="none">
  <form method="dialog">
    <header>
      <h3>Delete project?</h3>
      <p>This action cannot be undone.</p>
    </header>

    <div>
      <p>The project, saved reports, and connected automations will be removed permanently.</p>
    </div>

    <footer class="cluster justify-content-end">
      <button class="btn outline" value="cancel">Cancel</button>

      <button class="btn danger"
              value="delete">
        Delete
      </button>
    </footer>
  </form>
</dialog>

Confirmation with an icon and an action band

For a compact confirmation with a leading status icon, put a .dialog-icon and the text column in a .media row. The first heading of that column centers on the icon; following copy stays in the same column. The icon well accepts the shared intent and emphasis classes: dialog-icon danger soft is the tinted red circle.

The action band is the footer composed with existing primitives: .bleed reaches the panel edges and corners, .background-subtle paints it, and .cluster.justify-content-space-between splits the decision pair. Keep the cancel-style action first, so it holds the leading edge and never sits next to the committing one.

Deactivate account

Are you sure you want to deactivate your account? All of your data will be permanently removed. This action cannot be undone.

<button class="btn danger"
        type="button"
        commandfor="deactivate-dialog"
        command="show-modal"
        aria-haspopup="dialog"
        aria-controls="deactivate-dialog">
  Deactivate account
</button>

<dialog class="modal"
        id="deactivate-dialog"
        closedby="none"
        style="--modal-size: 40rem">
  <form method="dialog">
    <div class="media">
      <span class="dialog-icon danger soft" aria-hidden="true">
        <i class="ti ti-alert-triangle"></i>
      </span>

      <header class="stack">
        <h3>Deactivate account</h3>
        <p class="muted">Are you sure you want to deactivate your account? All of your data will be permanently removed. This action cannot be undone.</p>
      </header>
    </div>

    <footer class="bleed background-subtle cluster justify-content-space-between">
      <button class="btn outline" value="cancel">Cancel</button>
      <button class="btn danger" value="deactivate">Deactivate</button>
    </footer>
  </form>
</dialog>

For a trailing illustration, use a direct <header> as the message region and compose it as a non-wrapping split cluster. .grow keeps the text column flexible while .dialog-icon stays at the inline end.

Use the same anatomy in a .card instead when the surface belongs in page content and does not need modal focus or a backdrop.

Make this workspace your own

Choose colors and defaults now, or continue with the current settings.

<button class="btn"
        type="button"
        commandfor="customize-dialog"
        command="show-modal"
        aria-haspopup="dialog"
        aria-controls="customize-dialog">
  Customize workspace
</button>

<dialog class="modal"
        id="customize-dialog"
        closedby="none"
        aria-labelledby="customize-dialog-title">
  <form method="dialog">
    <header class="cluster"
            style="--cluster-align: start; --cluster-justify: space-between; --cluster-wrap: nowrap">
      <hgroup class="grow">
        <h3 id="customize-dialog-title">Make this workspace your own</h3>
        <p class="muted">Choose colors and defaults now, or continue with the current settings.</p>
      </hgroup>

      <span class="dialog-icon success soft" aria-hidden="true">
        <i class="ti ti-check"></i>
      </span>
    </header>

    <footer class="bleed background-subtle cluster justify-content-end">
      <button class="btn ghost" value="later">Later</button>
      <button class="btn primary" value="customize">Customize</button>
    </footer>
  </form>
</dialog>

Information modal

Use this shape for contextual information or lightweight secondary content. It has no action button; the header close button dismisses the dialog.

Release details

Changes included in this version.

The release improves dialog behavior, scroll handling, and progressive enhancement for modern browsers.

There are no decisions to make here; the content can simply be dismissed when finished.

<button class="btn"
        type="button"
        commandfor="details-dialog"
        command="show-modal"
        aria-haspopup="dialog"
        aria-controls="details-dialog">
  View details
</button>

<dialog class="modal"
        id="details-dialog"
        closedby="any"
        data-dialog-dismissible>
  <button class="close"
          type="button"
          commandfor="details-dialog"
          command="request-close"
          aria-controls="details-dialog"
          aria-label="Close dialog"></button>

  <div class="stack">
    <header>
      <hgroup>
        <h3>Release details</h3>
        <p>Changes included in this version.</p>
      </hgroup>
    </header>

    <div>
      <p>The release improves dialog behavior, scroll handling, and progressive enhancement for modern browsers.</p>
      <p>There are no decisions to make here; the content can simply be dismissed when finished.</p>
    </div>
  </div>
</dialog>

Action modal

Use this shape when contextual information leads to one non-destructive next step, such as reviewing a setup before continuing. Unlike an alert dialog, it can be dismissed without choosing the action; unlike an information modal, it has a primary action in a footer.

Use .dialog-icon with the media object for a leading title icon. An .alert belongs in the body only when the dialog contains a distinct status message; the dialog title itself is not an alert.

Finish workspace setup

Acme workspace

Team plan

Starts October 1

Confirm these settings to finish setting up the workspace.

<button class="btn primary"
        type="button"
        commandfor="setup-dialog"
        command="show-modal"
        aria-haspopup="dialog"
        aria-controls="setup-dialog">
  Continue setup
</button>

<dialog class="modal"
        id="setup-dialog"
        closedby="any"
        data-dialog-dismissible
        aria-labelledby="setup-dialog-title">
  <header>
    <div class="media items-center grow">
      <span class="dialog-icon warning soft" aria-hidden="true">
        <i class="ti ti-alert-triangle"></i>
      </span>

      <h2 id="setup-dialog-title">Finish workspace setup</h2>
    </div>

    <div class="cluster">
      <button class="close"
              type="button"
              commandfor="setup-dialog"
              command="request-close"
              aria-controls="setup-dialog"
              aria-label="Close dialog"></button>
    </div>
  </header>

  <form method="dialog" class="stack">
    <div class="card stack">
      <p><strong>Acme workspace</strong></p>
      <p>Team plan</p>
      <p>Starts October 1</p>
    </div>

    <p>Confirm these settings to finish setting up the workspace.</p>

    <footer class="cluster justify-content-end">
      <button class="btn primary inline-size-full" value="continue">Continue</button>
    </footer>
  </form>
</dialog>

Scrollable modal

Use modal scrollable when the header and footer should stay visible while the dialog body scrolls. Modal dialogs lock page scroll while a modal is open: the runtime writes html.has-modal-open, and the stylesheet applies overflow: hidden (and reserves the scrollbar gutter with scrollbar-gutter: stable when one was present). The height cap follows the small viewport, so the panel keeps a stable size while mobile browser chrome expands or collapses.

The scrolling body is a scroll container, and browsers make one keyboard-focusable so it can be scrolled without a pointer — it takes a focus ring like any other focusable element, and it can claim initial focus when nothing focusable precedes it. Mark the control you want focused autofocus.

Each non-header/footer child region is its own scrollport, so wrap the body in a single element (<div class="stack">) when there should be one scrollbar.

Terms review

Review the full text before continuing.

Actual CSS keeps long dialog content inside the dialog surface instead of letting it run past the viewport.

Section 1. The service stores project settings, interface preferences, and theme choices so teams can keep a consistent working environment.

Section 2. Administrators can invite users, remove inactive accounts, and review access periodically.

Section 3. Billing changes may affect future invoices. Existing invoices remain available from the account area.

Section 4. Export tools are provided for common formats. Large exports may take a few minutes to prepare.

Section 5. Support requests should include relevant browser, operating system, and account context.

Section 6. Experimental features can change or disappear before a stable release.

Section 7. Continued use confirms acceptance of the current terms.

<button class="btn"
        type="button"
        commandfor="scroll-dialog"
        command="show-modal"
        aria-haspopup="dialog"
        aria-controls="scroll-dialog">
  Open scrollable modal
</button>

<dialog class="modal scrollable"
        id="scroll-dialog"
        closedby="any"
        data-dialog-dismissible>
  <form method="dialog">
    <header>
      <h3>Terms review</h3>
      <p>Review the full text before continuing.</p>
    </header>

    <div class="stack">
      <p>Actual CSS keeps long dialog content inside the dialog surface instead of letting it run past the viewport.</p>
      <p>Section 1. The service stores project settings, interface preferences, and theme choices so teams can keep a consistent working environment.</p>
      <p>Section 2. Administrators can invite users, remove inactive accounts, and review access periodically.</p>
      <p>Section 3. Billing changes may affect future invoices. Existing invoices remain available from the account area.</p>
      <p>Section 4. Export tools are provided for common formats. Large exports may take a few minutes to prepare.</p>
      <p>Section 5. Support requests should include relevant browser, operating system, and account context.</p>
      <p>Section 6. Experimental features can change or disappear before a stable release.</p>
      <p>Section 7. Continued use confirms acceptance of the current terms.</p>
    </div>

    <footer class="cluster justify-content-end">
      <button class="btn outline" value="cancel">Cancel</button>

      <button class="btn primary" value="accept">
        Accept
      </button>
    </footer>
  </form>
</dialog>

An external header follows the same contract from the other side: the header is chrome outside the wrapper, the footer stays in the wrapper, and the content between them scrolls.

Release notes

Section 1. The service stores project settings, interface preferences, and theme choices.

Section 2. Administrators can invite users and review access periodically.

Section 3. Billing changes may affect future invoices.

Section 4. Export tools are provided for common formats.

Section 5. Support requests should include browser and account context.

Section 6. Continued use confirms acceptance of the current terms.

<button class="btn"
        type="button"
        commandfor="external-scroll-dialog"
        command="show-modal"
        aria-haspopup="dialog"
        aria-controls="external-scroll-dialog">
  Open external-header modal
</button>

<dialog class="modal scrollable"
        id="external-scroll-dialog"
        closedby="any"
        aria-labelledby="external-scroll-title">
  <header>
    <h3 id="external-scroll-title">Release notes</h3>

    <div class="cluster">
      <button class="close"
              type="button"
              commandfor="external-scroll-dialog"
              command="request-close"
              aria-controls="external-scroll-dialog"
              aria-label="Close dialog"></button>
    </div>
  </header>

  <form method="dialog" class="stack">
    <div class="stack">
      <p>Section 1. The service stores project settings, interface preferences, and theme choices.</p>
      <p>Section 2. Administrators can invite users and review access periodically.</p>
      <p>Section 3. Billing changes may affect future invoices.</p>
      <p>Section 4. Export tools are provided for common formats.</p>
      <p>Section 5. Support requests should include browser and account context.</p>
      <p>Section 6. Continued use confirms acceptance of the current terms.</p>
    </div>

    <footer class="cluster justify-content-end">
      <button class="btn outline" value="cancel">Cancel</button>
      <button class="btn primary" value="accept">Accept</button>
    </footer>
  </form>
</dialog>

Overlays inside modals

Flyouts and tooltips opened from inside a modal dialog stay exactly where they are authored. The runtime promotes them to the top layer instead of moving them, which is what keeps them inside every scope that reaches them by inheritance.

Author them inside the dialog. Promotion paints an element above a modal, but it does not lift it out of the modal's inertness — that is computed on the DOM, not on paint order — so an overlay authored outside the dialog would be promoted above it and still unclickable.

Modal overlays

Flyouts and tooltips remain above the dialog surface.

<button class="btn"
        type="button"
        commandfor="overlay-dialog"
        command="show-modal"
        aria-haspopup="dialog"
        aria-controls="overlay-dialog">
  Open modal overlays
</button>

<dialog class="modal"
        id="overlay-dialog"
        closedby="any"
        data-dialog-dismissible>
  <button class="close"
          type="button"
          commandfor="overlay-dialog"
          command="request-close"
          aria-controls="overlay-dialog"
          aria-label="Close dialog"></button>

  <div class="stack">
    <header>
      <hgroup>
        <h3>Modal overlays</h3>
        <p>Flyouts and tooltips remain above the dialog surface.</p>
      </hgroup>
    </header>

    <div class="cluster">
      <button class="btn"
              type="button"
              data-enhance="flyout"
              aria-expanded="false"
              aria-controls="dialog-actions-menu">
        Actions
      </button>

      <button class="btn outline"
              type="button"
              data-tooltip="Tooltip inside a dialog">
        Tooltip
      </button>
    </div>

    <menu class="flyout menu" id="dialog-actions-menu" hidden>
      <li><button class="menu-item" type="button">Archive</button></li>
      <li><button class="menu-item" type="button">Duplicate</button></li>
      <li><button class="menu-item" type="button">Share</button></li>
    </menu>
  </div>
</dialog>

JavaScript options

Add options directly on the dialog element. Without JavaScript, modern browsers still use the native dialog behavior; the runtime adds declarative command handling, focus restoration, controlled light dismiss, and optional view transitions.

Available options:

  • data-dialog-dismissible gates backdrop click only: the runtime takes over light dismiss and closes the dialog (or rewrites closedby="any" to closedby="closerequest" so the native dialog does not double-handle). It never affects Escape or explicit close requests.
  • data-dialog-modal="false" makes direct openDialog() calls use show() instead of showModal(). The show-modal and --show commands override this default.
  • data-dialog-view-transition enables a view transition that morphs the dialog to/from its trigger. Only active when the browser supports document.startViewTransition and the user allows motion.
  • closedby keeps its native meaning: "any" closes on backdrop click and Escape, "closerequest" closes on Escape only, "none" disables both. The runtime rewrites closedby="any" to closedby="closerequest" only when data-dialog-dismissible opts it into light dismiss, and never overrides closedby="none". Use closedby="none" for critical dialogs that must be closed by an explicit action.

Browser support

Actual relies on the native <dialog> API in its supported JavaScript range (Safari 17+, Firefox 125+, Chromium 116+). No dialog polyfill or fallback shim is shipped.

Animation

The base CSS gives supporting browsers small enter and exit transitions. Exit motion relies on transition-behavior: allow-discrete so the native dialog can remain in the top layer while display and overlay transition out. Browsers without that support keep native close behavior.

The open dialog root intentionally ends at transform: none; fixed flyouts and tooltips authored inside a modal dialog rely on viewport coordinates.

View transition

Add data-dialog-view-transition to morph the dialog to/from its trigger using the View Transition API. The dialog appears to grow out of the trigger on open and shrink back into it on close, communicating the relationship between the two.

The effect is progressive: it only runs when the browser supports document.startViewTransition and the user has not requested reduced motion. Otherwise the dialog simply opens and closes with the baseline dialog transition.

Title

This dialog morphs to and from the trigger button.

<button class="btn"
        type="button"
        commandfor="vt-dialog"
        command="show-modal"
        aria-haspopup="dialog"
        aria-controls="vt-dialog">
  Open modal
</button>

<dialog class="modal"
        id="vt-dialog"
        data-dialog-view-transition
        data-dialog-dismissible>
  <form method="dialog">
    <header>
      <h3>Title</h3>
      <p>This dialog morphs to and from the trigger button.</p>
    </header>

    <footer class="cluster justify-content-end">
      <button class="btn outline" value="cancel">Cancel</button>
      <button class="btn primary" value="confirm">Confirm</button>
    </footer>
  </form>
</dialog>

Notes

Web Components

The dialog runtime automatically enhances ordinary page dialogs. Dialogs inside a custom element (including nested light DOM hosts) or a Shadow DOM keep their component's native lifecycle; Actual leaves their command buttons untouched. The .modal styles remain independent of this behavior.

To delegate a light DOM dialog to Actual, add data-enhance="dialog" before inserting it, including when a custom element is only an application container:

<my-app>
  <dialog class="modal" id="preferences" data-enhance="dialog">
    <form method="dialog">…</form>
  </dialog>
</my-app>

For an existing light DOM dialog, use applyEnhancement("dialog", "dialog", scope) from actual-css/js/enhance. Document discovery does not enter shadow roots: a marked shadow dialog is wired on its first routed command. Calling openDialog() from actual-css/js/dialog also explicitly delegates to Actual, in either DOM tree; use Actual's exported close helpers for subsequent programmatic actions. Shadow-root disposal remains application-owned.

Prefer native dialog behavior whenever possible. The framework runtime should not replace the platform modal system; it should only make dialogs declarative, animation-friendly, and consistent across supported browsers.

CSS hooks

  • --modal-size — maximum dialog width. Fallback-only, so a class on the dialog or an inherited value both reach it. A dialog is shrink-to-fit: a composition that must fill this width sets inline-size as well.
  • --modal-pad — padding of the direct <form> or .stack content wrapper.
  • --dialog-viewport-gap — distance kept between the dialog and the viewport edges.
  • --dialog-icon-size — diameter of the .dialog-icon circle. The heading beside a leading icon reads the same hook to center on it, so set it on the icon's media row (or the dialog) and both inherit one value. A value on the icon alone sizes only the circle.
  • --dialog-icon-glyph-size — size of the glyph centered inside .dialog-icon; same placement as --dialog-icon-size.
  • --close-size / --close-icon-size — size of the corner .close and of its X. The header reserves the close size, its edge inset, and a content gap so the title never runs under it.
Actual CSS

Search documentation