Modal

Renders a modal dialog for content such as forms and informational panels.

Read more Read less

This component is appropriate for non-critical interactions. For dialogs requiring immediate user response, such as confirmations or warnings, use .alert_dialog/1 instead.

Maturity: Developing

Usage

The dialog is opened with showModal() in one of three ways: from the URL, with the show_modal/1 and hide_modal/1 functions, or with a button that uses the Invoker Commands API.

With URL

To toggle the modal visibility based on the URL:

  1. Use the :if attribute to conditionally render the modal when a specific live action matches.
  2. Set the on_cancel attribute to patch back to the original URL when the user chooses to close the modal.
  3. Set the open attribute to declare the modal's initial visibility state.

Example

<.modal
  :if={@live_action == :show}
  id="pet-modal"
  on_cancel={JS.patch(~p"/pets")}
  open
>
  <:title>Show pet</:title>
  <p>My pet is called Johnny.</p>
  <:footer>
    <.link phx-click={JS.exec("data-cancel", to: "#pet-modal")}>
      Close
    </.link>
  </:footer>
</.modal>

To open the modal, patch or navigate to the URL associated with the live action.

<.link patch={~p"/pets/#{@id}"}>show</.link>

With JS commands

To toggle the modal visibility dynamically:

  1. Omit the open attribute in the template.
  2. Use the show_modal/1 and hide_modal/1 functions to change the visibility.

Example

<.modal id="pet-modal">
  <:title>Show pet</:title>
  <p>My pet is called Johnny.</p>
  <:footer>
    <.link phx-click={JS.exec("data-cancel", to: "#pet-modal")}>
      Close
    </.link>
  </:footer>
</.modal>

To open the modal, use the show_modal/1 function.

<.button
  phx-click={Doggo.show_modal("pet-modal")}
  aria-haspopup="dialog"
>
  show
</.button>

With HTML attributes

command and commandfor are the Invoker Commands API. Unlike the other two ways, this API needs no JavaScript at all.

<.button command="show-modal" commandfor="pet-modal">show</.button>

Both attributes are recent, so the hook handles them if the browser doesn't support them.

Closing

Four things close the dialog, and all of them run on_cancel:

  • the close button the component renders, which uses command="close"
  • Esc and a click outside, unless dismissable is set to false
  • hide_modal/1
  • JS.exec("data-cancel", to: "#pet-modal")

Semantics

The dialog is opened with showModal(), so the browser puts it in the top layer, draws ::backdrop, makes the rest of the document inert and keeps the focus inside. aria-modal is not rendered, because showModal() already marks the component as a modal.

Focus

showModal() moves the focus into the dialog to the first element with the autofocus attribute, or the first focusable element if no element has it.

In a dismissable modal, the close button comes first in the markup, so it receives the focus if no autofocus attribute is present. This is rarely desired.

Set autofocus on the element that should receive the focus:

<.modal id="edit-dog">
  <:title>Edit dog</:title>
  <form>
    <input type="text" name="name" autofocus />
  </form>
</.modal>

The most appropriate element to focus depends on the dialog:

  • If the reader has to work through the content, focus a static element at the top. Opening a modal announces its title and the focused element, but not the body. If a control is focused, the content remains unread until the reader starts looking for it. Focusing a control also scrolls it into view, which can push the beginning of a long body out of sight.
  • If the dialog has focusable elements in the body, such as a form, set the focus to the first such element (e.g. the first input).
  • If the dialog only informs or continues a process, set the focus to the OK or Continue button.
  • If the dialog completes a step that is not easily reversible, set the focus to the least destructive action.

To focus a static element, set both tabindex="-1" and autofocus:

<.modal id="terms">
  <:title>Terms of service</:title>
  <p tabindex="-1" autofocus>Read the following before continuing.</p>
  <h3>Eligibility</h3>
  ...
</.modal>

If the body is short, it can be announced when the modal opens instead by setting aria-describedby to the id of the content element (modal id plus -content suffix):

<.modal id="delete-dog" aria-describedby="delete-dog-content">
  <:title>Delete Bella?</:title>
  <p>This cannot be undone.</p>
</.modal>

A description is announced as a single run of text. Don't set aria-describedby if the content has a structure to navigate, such as a form, a table, or multiple paragraphs.

See also ARIA Authoring Practices.

CSS

A dialog is hidden until it is opened, so no rule is needed for that. Style the backdrop with dialog.modal::backdrop.

Caveats

Setting dismissable={false} removes the close button and renders closedby="none", which leaves no way to dismiss the dialog from the component. Provide your own control in the :footer slot when you do that.

Keyboard

  • Esc - close the dialog, unless dismissable is set to false.

Opening the dialog moves the focus to the first focusable element inside it, and closing it returns the focus to the element that opened it. The focus stays within the dialog while it is open. A dialog with nothing focusable in it leaves the focus outside.

Doggo.show_modal/1 opens the dialog, which works on every browser and needs the hook. The close button in the footer uses JS.exec("data-cancel").

<div>
  <.button type="button" phx-click={Doggo.show_modal("modal-single-default")}>Open modal</.button>
  <.modal id="modal-single-default">
    <:title>Show pet</:title>
    <p>My pet is called Johnny.</p>
    <:footer>
      <.button autofocus phx-click={JS.exec("data-cancel", to: "#modal-single-default")}>
        Close
      </.button>
    </:footer>
  </.modal>
</div>

The button has command and commandfor attributes, which are part of the Invoker Commands API. This works with only HTML attributes without any JavaScript. If the browser doesn't support it, the hook fills the functionality.

<div>
  <.button type="button" command="show-modal" commandfor="modal-single-without-javascript">Open modal</.button>
  <.modal id="modal-single-without-javascript">
    <:title>Show pet</:title>
    <p>My pet is called Johnny.</p>
    <:footer>
      <.button autofocus phx-click={JS.exec("data-cancel", to: "#modal-single-without-javascript")}>
        Close
      </.button>
    </:footer>
  </.modal>
</div>
<div>
  <.button type="button" phx-click={Doggo.show_modal("modal-single-long-content")}>Open modal</.button>
  <.modal id="modal-single-long-content">
    <:title>Show pet</:title>
    <p tabindex="-1" autofocus>Johnny was rehomed in 2011. He is house trained, walks well on a lead, and is happiest with a garden and someone at home during the day. He does not get on with cats.</p>
    <p>Johnny was rehomed in 2012. He is house trained, walks well on a lead, and is happiest with a garden and someone at home during the day. He does not get on with cats.</p>
    <p>Johnny was rehomed in 2013. He is house trained, walks well on a lead, and is happiest with a garden and someone at home during the day. He does not get on with cats.</p>
    <p>Johnny was rehomed in 2014. He is house trained, walks well on a lead, and is happiest with a garden and someone at home during the day. He does not get on with cats.</p>
    <p>Johnny was rehomed in 2015. He is house trained, walks well on a lead, and is happiest with a garden and someone at home during the day. He does not get on with cats.</p>
    <p>Johnny was rehomed in 2016. He is house trained, walks well on a lead, and is happiest with a garden and someone at home during the day. He does not get on with cats.</p>
    <p>Johnny was rehomed in 2017. He is house trained, walks well on a lead, and is happiest with a garden and someone at home during the day. He does not get on with cats.</p>
    <p>Johnny was rehomed in 2018. He is house trained, walks well on a lead, and is happiest with a garden and someone at home during the day. He does not get on with cats.</p>
    <p>Johnny was rehomed in 2019. He is house trained, walks well on a lead, and is happiest with a garden and someone at home during the day. He does not get on with cats.</p>
    <p>Johnny was rehomed in 2020. He is house trained, walks well on a lead, and is happiest with a garden and someone at home during the day. He does not get on with cats.</p>
    <p>Johnny was rehomed in 2021. He is house trained, walks well on a lead, and is happiest with a garden and someone at home during the day. He does not get on with cats.</p>
    <p>Johnny was rehomed in 2022. He is house trained, walks well on a lead, and is happiest with a garden and someone at home during the day. He does not get on with cats.</p>
    <p>Johnny was rehomed in 2023. He is house trained, walks well on a lead, and is happiest with a garden and someone at home during the day. He does not get on with cats.</p>
    <p>Johnny was rehomed in 2024. He is house trained, walks well on a lead, and is happiest with a garden and someone at home during the day. He does not get on with cats.</p>
    <p>Johnny was rehomed in 2025. He is house trained, walks well on a lead, and is happiest with a garden and someone at home during the day. He does not get on with cats.</p>
    <p>Johnny was rehomed in 2026. He is house trained, walks well on a lead, and is happiest with a garden and someone at home during the day. He does not get on with cats.</p>
    <p>Johnny was rehomed in 2027. He is house trained, walks well on a lead, and is happiest with a garden and someone at home during the day. He does not get on with cats.</p>
    <p>Johnny was rehomed in 2028. He is house trained, walks well on a lead, and is happiest with a garden and someone at home during the day. He does not get on with cats.</p>
    <p>Johnny was rehomed in 2029. He is house trained, walks well on a lead, and is happiest with a garden and someone at home during the day. He does not get on with cats.</p>
    <p>Johnny was rehomed in 2030. He is house trained, walks well on a lead, and is happiest with a garden and someone at home during the day. He does not get on with cats.</p>
    <:footer>
      <.button phx-click={JS.exec("data-cancel", to: "#modal-single-long-content")}>
        Close
      </.button>
    </:footer>
  </.modal>
</div>

The :close slot replaces the label text of the close button with other content, usually an icon. close_label still gives the button its accessible name.

<div>
  <.button type="button" phx-click={Doggo.show_modal("modal-single-close-icon")}>Open modal</.button>
  <.modal id="modal-single-close-icon" close_label="Close">
    <:title>Show pet</:title>
    <p>My pet is called Johnny.</p>
    <:footer>
      <.button autofocus phx-click={JS.exec("data-cancel", to: "#modal-single-close-icon")}>
        Close
      </.button>
    </:footer>
    <:close><svg
      xmlns="http://www.w3.org/2000/svg"
      width="24"
      height="24"
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      stroke-width="2"
      stroke-linecap="round"
      stroke-linejoin="round"
      class="lucide lucide-x"
      aria-hidden="true"
    >
      <path d="M18 6 6 18" />
      <path d="m6 6 12 12" />
    </svg>
    </:close>
  </.modal>
</div>

dismissable={false} renders closedby="none" and no close button, so neither Esc nor a click outside closes it. The control in the footer is the only way out.

<div>
  <.button type="button" phx-click={Doggo.show_modal("modal-single-not-dismissable")}>Open modal</.button>
  <.modal id="modal-single-not-dismissable">
    <:title>Show pet</:title>
    <p>My pet is called Johnny.</p>
    <:footer>
      <.button autofocus phx-click={JS.exec("data-cancel", to: "#modal-single-not-dismissable")}>
        Close
      </.button>
    </:footer>
  </.modal>
</div>