Modals With <.portal/> in Phoenix LiveView

The deal with modals is you usually want them to live on the first floor inside <body>. In most cases, not all (we'll talk about one). Reasons for that being a risk of modal getting clipped out of view due to setup of parent elements, e.g. overflow: hidden; position: relative; transform: <...>. Even though position: fixed is the remedy for the most part, but hey, there's this transform dude...

I will not cover strategies for showing, hiding a modal, full semantics or accessibility but rather focus on LiveView's built-in <.portal />:

# core_components.ex

attr :id, :string, required: true
slot :inner_block, required: true

def modal(assigns) do
  ~H"""
  <.portal id={"#{@id}-portal"} target="body">
    <dialog>
      {@inner_block}
    </dialog>
  </.portal>
  """
end
# live/home_live.ex

def render(assigns) do
  ~H"""
  <Layouts.app>
    <div class="very">
      <div class="deeply">
        <div class="nested">
          <div style="
            width: 10em; 
            height: 6em; 
            overflow: hidden; 
            position: relative; 
            transform: translateZ(0);
          ">
            <button type="button" phx-click="show_modal">
              Show modal
            </button>

            <.modal :if={@show_modal} id="hello-modal">
              <p>Hello!</p>
            </.modal>
          </div>
        </div>
      </div>
    </div>
  </Layouts.app>
  """
end

Thanks to portal the dialog element is inserted right before </body>:

    <dialog phx-id="123">...</dialog>
  </body>
</html>

Most importantly you still get all the LiveView dynamic parts functioning despite the fact that from server side the dialog element comes wrapped in <template> and only then it is appended to DOM's body:

<template>
  <dialog>...</dialog>
</template>
<.modal :if={@show_modal} id="form-modal">
  <.form phx-submit="submit_form">
    <p>Submitted email: {@email}</p>

    <input type="email" name="email" />
  </.form>
</.modal>

Another use case of portal is for nesting forms which is not semantic and any child forms get stripped by browsers, meaning they won't work anyways, unless we do this:

<div class="post">
  <.form>
    <h2>Post form</h2>
    ...

    <.modal :if={@show_modal} id="gallery-modal">
      <.form>
        <h2>Images form</h2>
        ...
      </.form>
    </.modal>
  </.form>
</div>

Result:

<body>
  <div class="post">
    <form>
      <h2>Post form</h2>
      ...
    </form>
  </div>

  <dialog>
    <form>
      <h2>Images form</h2>
      ...
    </form>
  </dialog>
</body>

When You Don't Need Portal

You have a huge form with many inputs and want to offload the less important ones into modals for the sake of better UX. Now portal would do you a disservice putting these inputs outside the form element. However, making portal optional is a nice workaround for situations like this:

# core_components.ex

attr :id, :string, required: true
attr :portal, :boolean, default: true
slot :inner_block, required: true

def modal(assigns) do
  ~H"""
  <.portal :if={@portal} id={"#{@id}-portal"} target="body">
    <.do_modal {assigns}>
      {render_slot(@inner_block)}
    </.do_modal>
  </.portal>

  <.do_modal :if={!@portal} {assigns}>
    {render_slot(@inner_block)}
  </.do_modal>
  """
end

def do_modal(assigns) do
  ~H"""
  <dialog>
    {@inner_block}
  </dialog>
  """
end
# live/home_live.ex

<.form>
  <input type="text" name="firstname" />
  <input type="text" name="lastname" />

  <button type="button" phx-click="show_email_modal">
    Change email
  </button>

  <.modal :if={@show_email_modal} id="email-modal" portal={false}>
    <input type="email" name="email" />
  </.modal>
</.form>

Result:

<form>
  <input type="text" name="firstname" />
  <input type="text" name="lastname" />

  <button type="button" phx-click="show_email_modal">
    Change email
  </button>

  <dialog>
    <input type="email" name="email" />
  </dialog>
</form>

May Elixir prolong your life!

&