Toast
The element is the container, not the message. You add it once in the app and call show() when there is something to report.
The container is a role="status" region, not role="alert". A message that appears in the corner should not interrupt what the screen reader is doing. If the message is important enough to interrupt, it belongs in an Alert in the page itself.
// main.ts, runs once when the app startsimport { defineLtToast } from "@lo-tek/interfaces/toast"import "@lo-tek/interfaces/tokens.css"import "@lo-tek/interfaces/toast.css"
defineLtToast()How to use it
Section titled “How to use it”The container is added once, and the messages come from your code. That is the difference from the other components: here you call a method instead of setting an attribute.
<!-- The stylesheets the page needs. Without a build step it is `<link>`, not `import`: a browser does not resolve a package name. --><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/tokens/tokens.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/standalone/toast/toast.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/css/button/button.css"><!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/lotek.css --><lt-toast id="messages" class="lt-toast" role="status" aria-live="polite" aria-atomic="false" aria-label="Messages" data-ignore-morph></lt-toast>
<button class="lt-button" id="save" type="button">Save application</button>
<!-- Once for the whole app, in the page template. Not once per component. --><script type="module"> import { defineLtToast } from "https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/components/standalone/toast/lt-toast.js"
defineLtToast()</script>
<script type="module"> const messages = document.getElementById("messages")
document.getElementById("save").addEventListener("click", async () => { try { await save() messages.show("Your application has been saved", { color: "success" }) } catch { messages.show("Could not save. Try again.", { color: "danger", duration: 0 }) } })</script>An error message stays until the user closes it, hence duration: 0.
// src/lotek.d.ts, gives <lt-toast> types in JSXimport "@lo-tek/interfaces/react-jsx"// main.tsx, runs once when the app startsimport { defineLtToast } from "@lo-tek/interfaces/toast"import "@lo-tek/interfaces/toast.css"import "@lo-tek/interfaces/button.css"
defineLtToast()Put the container in the root component, and share the reference through a context:
import { createContext, useContext, useRef } from "react"import { lt } from "@lo-tek/interfaces/react"import { type LtToast, showToast } from "@lo-tek/interfaces/toast"import "@lo-tek/interfaces/tokens.css"
const toasts = lt.toast({ label: "Messages" })
const Messages = createContext<(text: string, color?: "success" | "danger") => void>( () => {},)
export function MessagesProvider({ children }: { children: React.ReactNode }) { const queue = useRef<LtToast>(null)
const notify = (text: string, color?: "success" | "danger") => { if (!queue.current) return showToast(queue.current, text, { color, duration: color === "danger" ? 0 : undefined }) }
return ( <Messages.Provider value={notify}> {children} <lt-toast ref={queue} {...toasts.host} /> </Messages.Provider> )}
export const useMessages = () => useContext(Messages)const notify = useMessages()
<button {...lt.button()} onClick={() => notify("Your application has been saved", "success")}> Save application</button>The messages live in the DOM, not in React state. That is deliberate: they should not disappear because a component re-rendered. showToast() waits until defineLtToast() has run, so a call made before registration is carried out as soon as the element is registered, instead of failing.
---import { lt } from "@lo-tek/interfaces"import "@lo-tek/interfaces/tokens.css"import "@lo-tek/interfaces/toast.css"
// Put the container in the page layout, so it exists on every page
const toasts = lt.toast({ label: "Messages" })
---
<slot />
<lt-toast id="messages" {...toasts.host}></lt-toast>
<script> import { defineLtToast } from "@lo-tek/interfaces/toast" defineLtToast()
// Make the queue available to the islands on the page window.notify = (text, options) => document.getElementById("messages").show(text, options)</script>The container is empty from the server, and is filled only by code running in the browser. It therefore costs nothing on a page that does not use it.
<!-- Datastar itself. Without it the `data-` attributes below do nothing. --><script type="module" src="https://cdn.jsdelivr.net/gh/starfederation/datastar@v1.0.4/bundles/datastar.js"></script>
<!-- The stylesheets the page needs. Without a build step it is `<link>`, not `import`: a browser does not resolve a package name. --><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/tokens/tokens.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/standalone/toast/toast.css"><link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/src/components/css/button/button.css"><!-- Or everything in one file: https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/lotek.css -->
<lt-toast id="messages" class="lt-toast" role="status" aria-live="polite" aria-atomic="false" aria-label="Messages" data-ignore-morph></lt-toast>
<button class="lt-button" type="button" data-on:click="document.getElementById('messages').show('Your application has been saved', { color: 'success' })"> Save application</button>
<!-- Once for the whole app, in the page template. Not once per component. --><script type="module"> import { defineLtToast } from "https://cdn.jsdelivr.net/npm/@lo-tek/interfaces@0.36.0/dist/components/standalone/toast/lt-toast.js"
defineLtToast()</script>The messages are not state to be mirrored in a signal. They are something that happens, which is why data-on:click calls the method directly.
The markup
Section titled “The markup”A toast has three parts, and lt.toast() gives one attribute set for each. It is the same toasts as in the React and Astro recipes above:
const toasts = lt.toast({ label: "Messages" })| Set | Which part | Who writes it |
|---|---|---|
toasts.host | The container, that is <lt-toast> | You, once in the app |
toasts.message | The message itself | The component, when you call show() |
toasts.close | The close button in the message | The component |
That is why you spread host and not the whole object. The container is the only element you write:
<lt-toast {...toasts.host} />If you spread the whole of toasts, you set three attributes called host, message and close, none of which the browser knows. The container then has no role="status" until defineLtToast() has run, because the component fills in the role when it is missing. Until then no screen reader announces what appears in it, and data-ignore-morph never arrives, since that is the one thing the component does not set itself.
The other two sets are names, not markup. The component knows them and uses them on what it creates itself. You need them only when someone else writes the message: a Datastar app where the server patches a message straight into the region has to write lt-toast__message and lt-toast__close itself, and then they should have the same names as the ones the component uses.
Without a build step, you write the same set out as plain attributes, as the “Plain HTML” and “Datastar” recipes do:
<lt-toast id="messages" class="lt-toast" role="status" aria-live="polite" aria-atomic="false" aria-label="Messages" data-ignore-morph></lt-toast>data-ignore-morph is the most important of them, and the only one the component does not set itself.
data-ignore-morph is not optional. The component creates and removes the messages inside the region itself, and without the attribute an update from the server tears them away, because the server’s version of the region is empty. See Markup and behaviour.
Attributes and methods
Section titled “Attributes and methods”| Name | What it is |
|---|---|
duration | Default lifetime in milliseconds. Without the attribute it is 6000. 0 leaves the messages in place. |
label | Text that says what the region is. Becomes aria-label. Default: “Notifications” |
close-label | What the close button in each message is called for the screen reader. Default: “Close message”. lt.toast({ closeLabel }) writes it |
show(text, options) | Shows a message and returns the element |
dismiss(message) | Removes one message, and dispatches toast-dismiss. If the message had focus, focus moves to the message next to it |
clear() | Removes all of them, without dispatching anything |
showToast(element, text, options) from @lo-tek/interfaces/toast does the same as show(), but first waits until the element is registered. Use it where the call can come before defineLtToast(), as in a module loaded alongside the registration.
The options for show():
| Option | Default | Effect |
|---|---|---|
color | neutral | success, warning or danger gives a coloured border |
duration | from the element | Milliseconds before the message disappears |
closeLabel | from close-label | What the close button is called for the screen reader, for this one message |
Both texts can be translated. See Translation.
Messages that disappear
Section titled “Messages that disappear”A message that disappears by itself is an accessibility trap: someone who reads slowly, uses magnification or a screen reader may not have time to read it. The component does two things about that:
- The close button is always there.
- The timer stops while the mouse is over the message or focus is inside it, and only runs again when both are outside.
If the message needs to be readable later, it does not belong here. A receipt belongs in the page.
Classes you can style
Section titled “Classes you can style”| Class | What it is |
|---|---|
.lt-toast | The region the messages are in, that is the host. It carries the placement in the corner |
.lt-toast__message | One message. The colour comes from data-color. |
.lt-toast__close | The close button |
| Variable | Default |
|---|---|
--lt-toast-space | var(--lt-spacing-4) |
--lt-toast-width | min(24rem, calc(100vw - var(--lt-spacing-8))) |
--lt-toast-padding | var(--lt-spacing-3) var(--lt-spacing-4) |
--lt-toast-radius | var(--lt-spacing-1) |
--lt-toast-layer | 60 |
The container lets clicks through when it is empty, so it does not block buttons underneath it.
See Customising for how the variables and the layer work.
Accessibility
Section titled “Accessibility”- One thing at a time. Five messages on top of each other are read out one after another, and none of them gets attention.
- Do not put important actions in a message that disappears. The undo button belongs in the page.
- Errors that stop the user belong in an Alert or an Error Summary, not in the corner.
- The text should say what happened, not just “Done”.
- Not while a modal dialog is open. Everything outside a modal dialog is inert: the message sits under the backdrop, the close button cannot be pressed, and the screen reader probably does not announce it. If the message concerns something the user is doing in the dialog, show it in the dialog, for example with an Alert, or wait until the dialog is closed.