---
title: "A modal dialog"
description: "A Firelight confirmation dialog built on the native <dialog> element: showModal through a ref, a form with method=dialog for the answer, Escape to cancel, and focus handled by the browser."
---

# A modal dialog

Build a confirmation dialog on the browser's `<dialog>` element, and read which button closed it.

- [MDN: The dialog element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog)

Press Delete report.pdf…, then answer with a button, or press Escape to cancel. Focus goes to
Keep it when the dialog opens, and back to the button that opened it when it closes.

```fsharp
module Snippets.ConfirmDialog

open Fable.Core
open Browser.Types
open Firelight
open type Firelight.Lit

[<AttachMembers>]
type ConfirmDelete() =
    inherit LitElement()

    let dialog = createRef<HTMLDialogElement> ()

    static member properties =
        PropertyDeclarations.create [ "answer", PropertyDeclaration<string>(state = true) ]

    static member styles =
        css
            $$"""
        :host { display: grid; gap: 0.5rem; justify-items: start; }
        button { font: inherit; padding: 0.3rem 0.9rem; }
        p { margin: 0; }
        dialog { border: 1px solid var(--border); border-radius: 0.5rem; background: var(--bg); color: var(--fg);
                 padding: 1rem 1.25rem; max-width: 20rem; }
        dialog::backdrop { background: rgb(0 0 0 / 0.4); }
        dialog h2 { margin: 0 0 0.5rem; font-size: 1.1rem; }
        .actions { display: flex; gap: 0.5rem; justify-content: end; margin-top: 1rem; }
        """

    member val answer = "" with get, set

    member this.Ask() =
        dialog.value
        |> Option.iter (fun d ->
            // Escape closes without a button, and leaves returnValue as it was: clear it first.
            d.returnValue <- ""
            d.showModal ()
        )

    member this.Closed() =
        dialog.value
        |> Option.iter (fun d ->
            this.answer <-
                match d.returnValue with
                | "delete" -> "Deleted report.pdf."
                | _ -> "Kept report.pdf."
        )

    override this.render() =
        html
            $"""
        <button @click={fun _ -> this.Ask()}>Delete report.pdf…</button>
        <p role="status">{if this.answer <> "" then html $"{this.answer}" else nothing}</p>
        <dialog {ref dialog} aria-labelledby="confirm-title" @close={fun _ -> this.Closed()}>
            <form method="dialog">
                <h2 id="confirm-title">Delete report.pdf?</h2>
                <p>You can't undo this.</p>
                <div class="actions">
                    <button value="keep" autofocus>Keep it</button>
                    <button value="delete">Delete</button>
                </div>
            </form>
        </dialog>"""

defineElement<ConfirmDelete> "my-confirm-delete"
```

```html
<my-confirm-delete></my-confirm-delete>
```

## How it works

The browser does most of the work. `showModal ()` opens the dialog above everything else, makes the
rest of the page inert, so Tab stays inside the dialog, and closes it on Escape. When it closes, the
focus returns to the element that had it before.

- **Opening.** A template can't call a method, so a [`ref`](https://roboz0r.github.io/Firelight/guides/templates/#directives) reaches
  the `<dialog>`, and `Ask` calls `showModal` on it. [Fable](https://fable.io/)'s browser bindings type it as
  `HTMLDialogElement`, with `showModal`, `close` and `returnValue`.
- **The answer.** A `<form method="dialog">` doesn't submit anywhere: pressing one of its buttons
  closes the dialog and sets its `returnValue` to that button's `value`. The `@close` handler reads
  it.
- **Cancelling.** Escape closes the dialog without touching `returnValue`, so it would still hold
  the last answer. `Ask` clears it before opening, so Escape reads as `""`.
- **First focus.** `autofocus` marks Keep it, the safe choice, as the element to focus when the
  dialog opens. Without it, the browser focuses the first focusable element, which changes when
  the content does.
- **A name.** `aria-labelledby` points at the dialog's heading, which screen readers announce as
  the dialog opens.

The dialog lives in the component's shadow root, so the component's styles reach it, including
`::backdrop`. A closed `<dialog>` isn't displayed, so the [prerendered](https://roboz0r.github.io/Firelight/guides/prerendering/) HTML
shows only the button.

To close the dialog when the backdrop is clicked too, add `closedby="any"` to it. Browsers that don't
support the attribute ignore it, and Escape and the buttons still work.

## Related

- [Events](https://roboz0r.github.io/Firelight/guides/events/), for `@close` and the events a component raises.
- [Using web component libraries](https://roboz0r.github.io/Firelight/guides/component-libraries/), whose dialogs, such as
  `<wa-dialog>`, add animation and styling on top of the same idea.
