Native HTML element `<dialog>` modal opening/closing

There were a couple of posts about how to close dialogs by clicking outside them, but these post were about custom dialogs, not the native HTML <dialog> element and its modal opening that can also be closed by the Escape key.

There was a blog post by Lindsay Wardell about how to use native <dialog> with ports. I tried it and it works well but:

  • It uses one port.
  • You cannot close the modal by clicking outside it.
  • You cannot replay the modal opening in the debugger (opening does not depend on the model, although you could easily fix that).

So I ended up using the following custom element:

/* index.html */

<style>
  dialog {
    padding: 1rem;
  }
  /* .modal-filler first dialog child needed to prevent closing on dialog padding */
  dialog:modal > .modal-filler {
    opacity: 0;
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
    z-index: -10;
  }
  /* scrim */
  dialog::backdrop {
    background: rgb(0 0 0 / 0.5);
  }
}
</style>

<script>
customElements.define('modal-dialog',
    class extends HTMLElement {
        static observedAttributes = [ 'open']
        #isConnected = false
        connectedCallback() {
            this.#setContent()
            this.#isConnected = true
        }
        attributeChangedCallback() { if (this.#isConnected) this.#setContent() }
        #setContent() {
            const isOpen = this.getAttribute('open') !== null
            const dialog = this.querySelector('dialog')
            if (dialog) {
                if (isOpen) {
                    dialog.onclick = (event) => {
                        // closing only if clicked on scrim, not on the filler
                        if (event.currentTarget === event.target) dialog.close()
                    }
                    dialog.showModal()
                } else {
                    dialog.close()
                }
            }
        }
    }
)
</script>

along with the following Elm helper:

import Html exposing (Html, Attribute, div, node)
import Html.Attributes exposing (class, attribute)
import Html.Events exposing (on)
import Json.Decode as D


onClose : msg -> Attribute msg
onClose message =
  on "close" (D.succeed message)

modalDialog : Bool -> msg -> List (Attribute msg) -> List (Html msg) -> Html msg
modalDialog isOpen closeMsg attributes children =
    node "modal-dialog" (if isOpen then [ attribute "open" ""] else [])
        [ node "dialog" ((onClose closeMsg) :: attributes) <|
            (div [ class "modal-filler"] []) :: children
        ]

Now I can simply use native HTML <dialog> modals in Elm like this:

modalDialog (model.isMyModalOpen) ToggleIsMyModalOpen []
    [ p []
        [ text "My modal!" ]
    , button [ onClick ToggleIsMyModalOpen ]
        [ text "Close" ]
    ]

Let me know if you have another way to use native dialog elements as modals! :eyes:

10 Likes

We use pretty much an identical solution. Works pretty well I’d say.

1 Like

By the way the popover API will be available in Safari 17. It is already available on Chrome/Edge (under a flag in Firefox).

It can be used for dialogs but also for menus, tooltips, toasts… I will definitively use it when it is a bit more widespread.

1 Like

I thought the advice was to not use the popover API for modals and instead use dialog because of focus trapping and other a11y concerns?

For light-dismiss dialogs (closing when clicking outside) like in the custom element above, it looks like popovers work fine. But I never used them yet, so I cannot be too affirmative…

The popover API documentation suggests that it and <dialog> are complementary technologies and can be used together.

1 Like

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.