Skip to main content

Modals

Modal dialogs built on the native <dialog> element, with a trigger button, header, scrollable body, and backdrop-click to close. Wired up to the l-ui--modal Stimulus controller.

CSS-classes example

Use the l-ui-modal classes directly when you need full control over markup.

The body scrolls when content overflows. Close by pressing Esc, clicking the close button, or clicking the backdrop.

<div data-controller="l-ui--modal">
  <button type="button" class="l-ui-button l-ui-button--outline"
          data-action="l-ui--modal#open">Open modal</button>
  <dialog class="l-ui-modal"
          data-l-ui--modal-target="dialog"
          data-action="click->l-ui--modal#closeOnBackdrop"
          aria-labelledby="modal-example-heading">
    <div class="l-ui-modal__header">
      <h3 id="modal-example-heading">Example modal</h3>
      <button class="l-ui-button l-ui-button--icon"
              data-action="l-ui--modal#close"
              aria-label="Close">
        <%= image_tag "layered_ui/icon_close.svg",
              class: "l-ui-icon l-ui-icon--sm",
              alt: "", aria: { hidden: true } %>
      </button>
    </div>
    <div class="l-ui-modal__body">
      <p>Body content.</p>
    </div>
  </dialog>
</div>

Helper

The l_ui_modal helper renders the trigger, dialog, header (with title and close button), and Stimulus wiring for you. The block's content is the modal body. Use <% (not <%=) when calling m.trigger - the builder captures its content.

Rendered by l_ui_modal. Same markup, less boilerplate.

<%= l_ui_modal(title: "Helper example") do |m| %>
  <% m.trigger(class: "l-ui-button l-ui-button--outline") do %>
    Open modal
  <% end %>
  <p>Rendered by l_ui_modal.</p>
<% end %>

Opening from elsewhere

Give the modal a known id: and add data-l-ui-modal-open="<that id>" to any button on the page. The button does not need to be inside the helper's wrapper, and no data-controller or data-action is required - the l-ui--modal controller listens at the document level for matching clicks.

Both buttons open the same dialog. The second one lives outside the helper's wrapper entirely.

Calling dialog.showModal() directly is not supported - it bypasses the l-ui--modal controller and skips scroll lock, focus restoration, open-count tracking, and the screen-reader announcement.

Helper options

l_ui_modal options
Option Description
title: Required. Heading text; also used for aria-labelledby
id: DOM id for the <dialog>; defaults to an auto-generated id
heading_level: Heading tag for the title (e.g. :h2, :h3); defaults to :h3
container: Extra HTML attributes for the wrapping <div> (e.g. class:)
m.trigger(**opts) Optional. Renders a colocated trigger <button>; opts are merged as HTML attributes. For additional or external triggers, add data-l-ui-modal-open="<id>" to any button
block content The block's output (anything outside m.trigger) is rendered inside the scrollable l-ui-modal__body
icon-only triggers If the trigger contains only an icon (no visible text), pass aria-label: so it has an accessible name

CSS classes

Modal CSS classes
Class Description
l-ui-modal Applied to the <dialog>; fullscreen on mobile, centred and capped on desktop
l-ui-modal__header Top bar containing the title and close button
l-ui-modal__body Scrollable body region; fills the remaining dialog height