---
title: "Toast notifications"
description: "Firelight toast notifications that any code can raise with an event on the document, shown by one toaster component in a live region, with timers cleared when it leaves the page."
---

# Toast notifications

Build toast notifications that any code on the page can raise, from F# or JavaScript, and that one component shows.

- [MDN: ARIA status role](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/status_role)

Press Save, a separate component, or the page's own button, which runs plain JavaScript. Each
toast goes after five seconds, or when you dismiss it.

```fsharp
module Snippets.Toasts

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

/// Shows a toast. Any code can call it, and JavaScript can raise the same `toast` event.
let showToast (message: string) =
    document.dispatchEvent (Event.customEvent ("toast", message)) |> ignore

type Toast = { Id: int; Message: string }

/// Shows the toasts raised anywhere on the page, each for five seconds. Put one on the page.
[<AttachMembers>]
type Toaster() =
    inherit LitElement()

    let mutable nextId = 0
    let mutable timers: Map<int, float> = Map.empty
    let mutable stopListening = ignore
    /// Where the focus was before it moved onto a toast, to put it back when the last one goes.
    let mutable cameFrom: HTMLElement option = None

    static member properties =
        PropertyDeclarations.create [ "toasts", PropertyDeclaration<Toast list>(state = true) ]

    static member styles =
        css
            $$"""
        :host { display: block; }
        .stack { display: grid; gap: 0.5rem; justify-items: start; }
        .toast { display: flex; gap: 0.75rem; align-items: center; padding: 0.5rem 0.5rem 0.5rem 1rem;
                 border-radius: 0.5rem; background: var(--fg); color: var(--bg); }
        button { font: inherit; padding: 0 0.5rem; border: 0; border-radius: 0.25rem; background: none;
                 color: inherit; cursor: pointer; }
        button:focus-visible { outline: 2px solid var(--bg); }
        """

    member val toasts: Toast list = [] with get, set

    member this.Add(message: string) =
        nextId <- nextId + 1
        this.toasts <- this.toasts @ [ { Id = nextId; Message = message } ]
        this.Schedule nextId

    member this.Schedule(id: int) =
        timers <- timers.Add(id, window.setTimeout ((fun () -> this.Expire id), 5000))

    /// Time's up, unless the focus is on a toast: then wait another five seconds.
    member this.Expire(id: int) =
        if isNull this.shadowRoot.activeElement then
            this.Remove id
        else
            this.Schedule id

    member this.Remove(id: int) =
        timers.TryFind id |> Option.iter window.clearTimeout
        timers <- timers.Remove id
        this.toasts <- this.toasts |> List.filter (fun t -> t.Id <> id)

    /// Dismissed from its button: the focus moves to the next toast, or back where it came from.
    member this.Dismiss(id: int) =
        let index = this.toasts |> List.findIndex (fun t -> t.Id = id)
        this.Remove id
        let next = this.toasts |> List.tryItem (min index (this.toasts.Length - 1))

        promise {
            let! _ = this.updateComplete

            match next with
            | Some toast -> this.query<HTMLElement> $"#dismiss-{toast.Id}" |> Option.iter _.focus()
            | None -> cameFrom |> Option.iter (fun el -> el.focus ())
        }
        |> Promise.start

    member this.FocusIn(e: FocusEvent) =
        match e.relatedTarget with
        | :? Node as from when not (this.shadowRoot.contains from) -> cameFrom <- Some(from :?> HTMLElement)
        | _ -> ()

    override this.connectedCallback() =
        base.connectedCallback ()
        stopListening <-
            Ev.listen document "toast" (Ev.custom<string> (fun e -> e.detail |> Option.iter this.Add))

    override this.disconnectedCallback() =
        base.disconnectedCallback ()
        stopListening ()
        timers |> Map.iter (fun _ handle -> window.clearTimeout handle)
        timers <- Map.empty
        this.toasts <- []

    member this.ToastView(toast: Toast) =
        html
            $"""
        <div class="toast">
            {toast.Message}
            <button id="dismiss-{toast.Id}" aria-label="Dismiss" @click={fun _ -> this.Dismiss toast.Id}>×</button>
        </div>"""

    override this.render() =
        // One live region, on the page from the start, so screen readers announce each new toast.
        // aria-atomic="false": announce the toast that was added, not the whole stack again.
        html
            $"""
        <div class="stack" role="status" aria-atomic="false" @focusin={Ev.focus this.FocusIn}>
            {repeat (this.toasts, (fun t -> t.Id), this.ToastView)}
        </div>"""

/// A component far from the toaster, which raises toasts without knowing where it is.
[<AttachMembers>]
type SaveButton() =
    inherit LitElement()

    static member styles = css $$"""button { font: inherit; padding: 0.3rem 0.9rem; }"""

    override _.render() =
        html $"""<button @click={fun _ -> showToast "Saved."}>Save</button>"""

defineElement<Toaster> "my-toaster"
defineElement<SaveButton> "my-save-button"
```

```html
<div class="toast-sources">
  <my-save-button></my-save-button>
  <button id="page-toast">Raise one from JavaScript</button>
</div>
<my-toaster></my-toaster>
<style>
  .toast-sources { display: flex; gap: 0.75rem; }
  #page-toast { font: inherit; padding: 0.3rem 0.9rem; }
</style>
```



## How it works

The toaster and the code that raises a toast don't know about each other. `showToast` dispatches a
`toast` event on `document`, with the message as its `detail`, and the toaster listens there. Any
F# code can call `showToast`, and any script can dispatch the same event, as the page does.

- **Listening.** The toaster adds its listener in `connectedCallback` and removes it in
  `disconnectedCallback`, keeping the very function it added, as
  [Events: Listen outside the component](https://roboz0r.github.io/Firelight/guides/events/#listen-outside-the-component) explains.
- **Timers.** Each toast has a `setTimeout` to remove it, kept in a map by the toast's id.
  Dismissing a toast clears its timer, and leaving the page clears them all, so nothing runs on a
  removed component. While the focus is on a toast, a timer that runs out starts again instead, so
  a toast never disappears from under the keyboard.
- **The focus.** Removing the element that has the focus sends the focus to the page's start. So
  Dismiss moves it to the next toast's button, or, after the last one, back to where it was before
  it moved onto a toast, which `@focusin` noted from the event's `relatedTarget`.
- **Announcing.** The toasts render inside one `role="status"` element, which is on the page from
  the start. Screen readers announce what's added to it without moving the focus; a live region
  added along with its message is often not announced at all. A status region announces all of
  its content by default, so `aria-atomic="false"` limits that to the new toast.
- **Keyed.** [`repeat`](https://roboz0r.github.io/Firelight/guides/templates/#keyed-lists-with-repeat) keys each toast by its id, so
  dismissing one doesn't move the others' messages between elements.

In this demo the toaster sits in the flow. In an app, put one `<my-toaster>` at the end of
`<body>`, and fix it to a corner from the page's CSS: `my-toaster { position: fixed; inset: auto
1rem 1rem auto; }`.

Five seconds suits a confirmation such as "Saved". A message that someone must read or act on,
such as an error, shouldn't disappear on a timer: show it where it happened, or keep it until it's
dismissed.

An event suits messages anyone can raise. When the toasts are state that several components read,
such as a count in the header, keep them in a [signal](https://roboz0r.github.io/Firelight/packages/signals/) instead.

## Related

- [Component communication](https://roboz0r.github.io/Firelight/guides/communication/), for events and signals between components.
