Skip to content

Toast

ToastThe button sends an sl-toast event; the toaster shows it, and a screen reader announces it.
Thymeleaf source
<sl:button variant="outline" x-data
x-on:click="$dispatch('sl-toast', {title: 'Event has been created', description: 'Monday, January 3rd at 6:00pm'})">
Show toast
</sl:button>
<sl:toaster id="toaster-default"/>
Rendered HTML
<button class="button" type="button" data-variant="outline" x-data x-on:click="$dispatch('sl-toast', {title: 'Event has been created', description: 'Monday, January 3rd at 6:00pm'})">Show toast</button>
<div class="toaster" x-data="slToaster" id="toaster-default">
<div class="toaster-viewport" popover="manual" role="region" aria-label="Notifications (Alt+T)">
<ol class="toaster-list"></ol>
<div class="toaster-announcer sl-sr-only" role="status" aria-live="polite"></div>
<div class="toaster-announcer sl-sr-only" role="alert" aria-live="assertive"></div>
</div>
<template class="toaster-template" data-variant="default">
<li class="toast" tabindex="-1">
<div class="toast-content">
<div class="toast-title"></div>
<div class="toast-description"></div>
</div>
<button class="button toast-close" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</li>
</template>
<template class="toaster-template" data-variant="success">
<li class="toast" tabindex="-1" data-variant="success">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="10"></circle>
<path d="m16 9-5.5 5.5L8 12"></path>
</svg>
<div class="toast-content">
<div class="toast-title"></div>
<div class="toast-description"></div>
</div>
<button class="button toast-close" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</li>
</template>
<template class="toaster-template" data-variant="info">
<li class="toast" tabindex="-1" data-variant="info">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
<div class="toast-content">
<div class="toast-title"></div>
<div class="toast-description"></div>
</div>
<button class="button toast-close" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</li>
</template>
<template class="toaster-template" data-variant="warning">
<li class="toast" tabindex="-1" data-variant="warning">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path>
<path d="M12 9v4"></path>
<path d="M12 17h.01"></path>
</svg>
<div class="toast-content">
<div class="toast-title"></div>
<div class="toast-description"></div>
</div>
<button class="button toast-close" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</li>
</template>
<template class="toaster-template" data-variant="error">
<li class="toast" tabindex="-1" data-variant="error">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="m15 9-6 6"></path>
<path d="M2.586 16.726A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2h6.624a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586z"></path>
<path d="m9 9 6 6"></path>
</svg>
<div class="toast-content">
<div class="toast-title"></div>
<div class="toast-description"></div>
</div>
<button class="button toast-close" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</li>
</template>
</div>

A toast says what just happened, such as “Member deleted”, and goes away by itself. shadcn/ui uses Sonner for it, with a <Toaster /> in the layout and toast("...") calls in the browser. In Shadleaf the layout has one <sl:toaster>, and the toasts come from the server, in one of three ways:

Where the message comes from How it gets to the toaster
An htmx request The response header HX-Trigger sends the sl-toast event with the toast as its detail.
A full page load, e.g. after a redirect The page renders <sl:toast>s in the toaster, from a flash attribute.
An htmx response that renders markup anyway An out-of-band swap puts <sl:toast>s into the toaster.

Put the toaster at the end of the layout’s <body>, and let it render the toasts of a flash attribute:

<body>
...
<sl:toaster>
<sl:toast th:each="toast : ${toasts}" th:title="${toast.title}" th:description="${toast.description}"
th:variant="${toast.variant}" th:duration="${toast.duration}"/>
</sl:toaster>
</body>

Toast (io.github.wimdeblauwe.shadleaf.toast.Toast) is what a controller writes for either path: a title, an optional description, a variant and an optional duration.

Toast.success("Member deleted")
Toast.error("Could not reach the mail server").withDescription("Your message was saved as a draft.")
Toast.of("Copied").withDuration(Duration.ofSeconds(2))
Toast.warning("Your session ends in 5 minutes").untilClosed()

This is what the toast is for: a controller answering an htmx request says what happened, whatever the response swaps. With htmx-spring-boot:

@HxRequest
@DeleteMapping("/members/{id}")
public String delete(@PathVariable long id, HtmxResponse htmxResponse, Model model) {
Member member = memberService.delete(id);
htmxResponse.addTrigger("sl-toast", Toast.success("%s was deleted".formatted(member.name())));
return "members :: deleted";
}

The response gets HX-Trigger: {"sl-toast": {"title": "Ada Lovelace was deleted", "description": null, "variant": "success", "duration": null}}; htmx dispatches the event when the response arrives, and the toaster shows it. Several toasts in one response are a list: htmxResponse.addTrigger("sl-toast", List.of(first, second)).

  • The text is only ever text. The toaster puts the title and description in with textContent, so a name such as <b>Ada</b> shows as typed and can never inject markup.
  • Use HX-Trigger, not HX-Trigger-After-Swap or -After-Settle: those are dispatched on the element that sent the request, and when the swap removed it (a deleted row), the event reaches no one.
  • Not with a redirect. An HX-Redirect or HX-Location loads another page, and the toast with it is gone. Put it in a flash attribute instead (next section).

A plain form post that redirects (post/redirect/get) puts the toast in a flash attribute, which the toaster in the layout renders on the page the redirect loads:

@PostMapping("/contact")
public String send(@Valid ContactForm form, BindingResult bindingResult, RedirectAttributes redirectAttributes) {
if (bindingResult.hasErrors()) {
return "contact";
}
contactService.send(form);
redirectAttributes.addFlashAttribute("toasts", List.of(
Toast.success("Message sent").withDescription("We answer within one working day.")));
return "redirect:/contact";
}

The page the redirect loads then renders this toast in the layout’s toaster (the th:each above), as below. Only here in the docs does it stay (duration="0"), so you can look at it; in your application it closes after the toaster’s duration like any other.

Rendered by the serverA toast the page renders, such as a flash attribute after a redirect. duration="0" keeps this one here.
Thymeleaf source
<sl:toaster id="toaster-server">
<sl:toast variant="success" title="Message sent" description="We answer within one working day."
duration="0"/>
</sl:toaster>
Rendered HTML
<div class="toaster" x-data="slToaster" id="toaster-server">
<div class="toaster-viewport" popover="manual" role="region" aria-label="Notifications (Alt+T)">
<ol class="toaster-list">
<li class="toast" tabindex="-1" data-variant="success" data-duration="0">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="10"></circle>
<path d="m16 9-5.5 5.5L8 12"></path>
</svg>
<div class="toast-content">
<div class="toast-title">Message sent</div>
<div class="toast-description">We answer within one working day.</div>
</div>
<button class="button toast-close" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</li>
</ol>
<div class="toaster-announcer sl-sr-only" role="status" aria-live="polite"></div>
<div class="toaster-announcer sl-sr-only" role="alert" aria-live="assertive"></div>
</div>
<template class="toaster-template" data-variant="default">
<li class="toast" tabindex="-1">
<div class="toast-content">
<div class="toast-title"></div>
<div class="toast-description"></div>
</div>
<button class="button toast-close" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</li>
</template>
<template class="toaster-template" data-variant="success">
<li class="toast" tabindex="-1" data-variant="success">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="10"></circle>
<path d="m16 9-5.5 5.5L8 12"></path>
</svg>
<div class="toast-content">
<div class="toast-title"></div>
<div class="toast-description"></div>
</div>
<button class="button toast-close" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</li>
</template>
<template class="toaster-template" data-variant="info">
<li class="toast" tabindex="-1" data-variant="info">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
<div class="toast-content">
<div class="toast-title"></div>
<div class="toast-description"></div>
</div>
<button class="button toast-close" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</li>
</template>
<template class="toaster-template" data-variant="warning">
<li class="toast" tabindex="-1" data-variant="warning">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path>
<path d="M12 9v4"></path>
<path d="M12 17h.01"></path>
</svg>
<div class="toast-content">
<div class="toast-title"></div>
<div class="toast-description"></div>
</div>
<button class="button toast-close" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</li>
</template>
<template class="toaster-template" data-variant="error">
<li class="toast" tabindex="-1" data-variant="error">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="m15 9-6 6"></path>
<path d="M2.586 16.726A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2h6.624a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586z"></path>
<path d="m9 9 6 6"></path>
</svg>
<div class="toast-content">
<div class="toast-title"></div>
<div class="toast-description"></div>
</div>
<button class="button toast-close" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</li>
</template>
</div>

A screen reader announces such a toast a moment after the page loads (see Announcement), but that moment is also when it starts reading the new page, and not every screen reader lets one interrupt the other. When the outcome is the whole point of the page, say it in the page too: in the title, or in an alert.

A response that renders markup anyway can put a toast into the toaster itself, whose id is toaster unless you give it another. Wrap the toasts in an element whose children a beforeend swap appends (an ol suits li toasts):

<th:block th:fragment="invited">
<form th:replace="~{members :: invite-form}"></form>
<ol hx-swap-oob="beforeend:#toaster">
<sl:toast variant="success" th:title="|Invitation sent to ${member.name}|"
description="They get an email with a link to join the team."/>
</ol>
</th:block>

A toast rendered this way can hold markup of its own, such as a link: content in <sl:toast> replaces the description. Remember that the toast goes away (see No action button).

The toaster listens for the event on the document, so your own Alpine markup (or any script) can send one too:

<sl:button x-data x-on:click="$dispatch('sl-toast', {title: 'Link copied'})">Copy link</sl:button>

The detail is a toast ({title, description, variant, duration}), a list of them, or a string, the title.

The variant only changes the icon, as in shadcn: none, a check (success), an i (info), a triangle (warning) and an x (error), whose icon takes the destructive colour. A screen reader announces an error at once, interrupting what it was reading; the others when it is done. Give a toast a title that says what happened on its own (“Could not save”), since the icon is decoration and is not read out.

VariantsThe variant sets the icon; a screen reader announces an error at once.
Thymeleaf source
<sl:button variant="outline" x-data x-on:click="$dispatch('sl-toast', {title: 'Event has been created'})">
Default
</sl:button>
<sl:button variant="outline" x-data
x-on:click="$dispatch('sl-toast', {title: 'Event has been created', variant: 'success'})">
Success
</sl:button>
<sl:button variant="outline" x-data
x-on:click="$dispatch('sl-toast', {title: 'Be at the area 10 minutes before the event time', variant: 'info'})">
Info
</sl:button>
<sl:button variant="outline" x-data
x-on:click="$dispatch('sl-toast', {title: 'Event start time cannot be earlier than 8am', variant: 'warning'})">
Warning
</sl:button>
<sl:button variant="outline" x-data
x-on:click="$dispatch('sl-toast', {title: 'Event has not been created', variant: 'error'})">
Error
</sl:button>
<sl:toaster id="toaster-variants"/>
Rendered HTML
<button class="button" type="button" data-variant="outline" x-data x-on:click="$dispatch('sl-toast', {title: 'Event has been created'})">Default</button>
<button class="button" type="button" data-variant="outline" x-data x-on:click="$dispatch('sl-toast', {title: 'Event has been created', variant: 'success'})">Success</button>
<button class="button" type="button" data-variant="outline" x-data x-on:click="$dispatch('sl-toast', {title: 'Be at the area 10 minutes before the event time', variant: 'info'})">Info</button>
<button class="button" type="button" data-variant="outline" x-data x-on:click="$dispatch('sl-toast', {title: 'Event start time cannot be earlier than 8am', variant: 'warning'})">Warning</button>
<button class="button" type="button" data-variant="outline" x-data x-on:click="$dispatch('sl-toast', {title: 'Event has not been created', variant: 'error'})">Error</button>
<div class="toaster" x-data="slToaster" id="toaster-variants">
<div class="toaster-viewport" popover="manual" role="region" aria-label="Notifications (Alt+T)">
<ol class="toaster-list"></ol>
<div class="toaster-announcer sl-sr-only" role="status" aria-live="polite"></div>
<div class="toaster-announcer sl-sr-only" role="alert" aria-live="assertive"></div>
</div>
<template class="toaster-template" data-variant="default">
<li class="toast" tabindex="-1">
<div class="toast-content">
<div class="toast-title"></div>
<div class="toast-description"></div>
</div>
<button class="button toast-close" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</li>
</template>
<template class="toaster-template" data-variant="success">
<li class="toast" tabindex="-1" data-variant="success">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="10"></circle>
<path d="m16 9-5.5 5.5L8 12"></path>
</svg>
<div class="toast-content">
<div class="toast-title"></div>
<div class="toast-description"></div>
</div>
<button class="button toast-close" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</li>
</template>
<template class="toaster-template" data-variant="info">
<li class="toast" tabindex="-1" data-variant="info">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
<div class="toast-content">
<div class="toast-title"></div>
<div class="toast-description"></div>
</div>
<button class="button toast-close" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</li>
</template>
<template class="toaster-template" data-variant="warning">
<li class="toast" tabindex="-1" data-variant="warning">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path>
<path d="M12 9v4"></path>
<path d="M12 17h.01"></path>
</svg>
<div class="toast-content">
<div class="toast-title"></div>
<div class="toast-description"></div>
</div>
<button class="button toast-close" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</li>
</template>
<template class="toaster-template" data-variant="error">
<li class="toast" tabindex="-1" data-variant="error">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="m15 9-6 6"></path>
<path d="M2.586 16.726A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2h6.624a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586z"></path>
<path d="m9 9 6 6"></path>
</svg>
<div class="toast-content">
<div class="toast-title"></div>
<div class="toast-description"></div>
</div>
<button class="button toast-close" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</li>
</template>
</div>

An icon of your own replaces the variant’s:

<sl:toast title="Invitation sent">
<sl:slot name="icon"><sl:icon name="mail"/></sl:slot>
</sl:toast>

A toast closes after 5 seconds (duration on the toaster, or on one toast; 0 keeps it until it is closed). The time stops while the pointer is on the toasts, while one of them has the focus and while the page is in another tab, and at least a second is left afterwards, so nobody loses a toast while reading it. Choose a longer duration, or 0, for a toast with a long description.

  • The close button closes one toast. It needs Alpine, so it only shows once Alpine runs; its label comes from the message sl.toast.close.
  • Alt+T moves the focus to the newest toast, as in Sonner, from anywhere on the page; the toaster’s name, “Notifications (Alt+T)” (message sl.toaster.label), tells screen reader users so. Tab then reaches the close buttons.
  • Escape closes the toast that has the focus, and nothing else: an open dialog around it stays open.
  • When the toast with the focus closes, the focus moves to the next toast, and after the last one back to where it was before.

Newer toasts stack in front of older ones, as Sonner’s do; hovering or focusing them spreads them out. At most three show (visible-toasts); the older ones wait, with their time stopped, and show again when a newer one closes.

Several at onceNewer toasts stack in front; hover or focus them to spread them out, which also stops their time.
Thymeleaf source
<sl:button variant="outline" x-data
x-on:click="$dispatch('sl-toast', [{title: 'Invitation sent to Ada'}, {title: 'Invitation sent to Grace'}, {title: 'Invitation sent to Alan', description: 'They get an email with a link to join the team.'}])">
Invite three members
</sl:button>
<sl:toaster id="toaster-stack"/>
Rendered HTML
<button class="button" type="button" data-variant="outline" x-data x-on:click="$dispatch('sl-toast', [{title: 'Invitation sent to Ada'}, {title: 'Invitation sent to Grace'}, {title: 'Invitation sent to Alan', description: 'They get an email with a link to join the team.'}])">Invite three members</button>
<div class="toaster" x-data="slToaster" id="toaster-stack">
<div class="toaster-viewport" popover="manual" role="region" aria-label="Notifications (Alt+T)">
<ol class="toaster-list"></ol>
<div class="toaster-announcer sl-sr-only" role="status" aria-live="polite"></div>
<div class="toaster-announcer sl-sr-only" role="alert" aria-live="assertive"></div>
</div>
<template class="toaster-template" data-variant="default">
<li class="toast" tabindex="-1">
<div class="toast-content">
<div class="toast-title"></div>
<div class="toast-description"></div>
</div>
<button class="button toast-close" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</li>
</template>
<template class="toaster-template" data-variant="success">
<li class="toast" tabindex="-1" data-variant="success">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="10"></circle>
<path d="m16 9-5.5 5.5L8 12"></path>
</svg>
<div class="toast-content">
<div class="toast-title"></div>
<div class="toast-description"></div>
</div>
<button class="button toast-close" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</li>
</template>
<template class="toaster-template" data-variant="info">
<li class="toast" tabindex="-1" data-variant="info">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="10"></circle>
<path d="M12 16v-4"></path>
<path d="M12 8h.01"></path>
</svg>
<div class="toast-content">
<div class="toast-title"></div>
<div class="toast-description"></div>
</div>
<button class="button toast-close" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</li>
</template>
<template class="toaster-template" data-variant="warning">
<li class="toast" tabindex="-1" data-variant="warning">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path>
<path d="M12 9v4"></path>
<path d="M12 17h.01"></path>
</svg>
<div class="toast-content">
<div class="toast-title"></div>
<div class="toast-description"></div>
</div>
<button class="button toast-close" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</li>
</template>
<template class="toaster-template" data-variant="error">
<li class="toast" tabindex="-1" data-variant="error">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="m15 9-6 6"></path>
<path d="M2.586 16.726A2 2 0 0 1 2 15.312V8.688a2 2 0 0 1 .586-1.414l4.688-4.688A2 2 0 0 1 8.688 2h6.624a2 2 0 0 1 1.414.586l4.688 4.688A2 2 0 0 1 22 8.688v6.624a2 2 0 0 1-.586 1.414l-4.688 4.688a2 2 0 0 1-1.414.586H8.688a2 2 0 0 1-1.414-.586z"></path>
<path d="m9 9 6 6"></path>
</svg>
<div class="toast-content">
<div class="toast-title"></div>
<div class="toast-description"></div>
</div>
<button class="button toast-close" type="button" data-variant="ghost" data-size="icon-xs" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</li>
</template>
</div>

Where the user prefers reduced motion, toasts only fade in and out; they do not slide.

position puts the toasts in a corner or at the middle of the top or bottom edge: top-left, top-center, top-right, bottom-left, bottom-center or bottom-right (the default, as Sonner’s). Right and left are the end and the start of a line, so they swap on a right-to-left page. On a narrow screen the toasts take the full width.

<sl:toaster position="top-center"/>

A modal dialog makes the rest of the page inert: it can be seen but not clicked, focused or read by a screen reader, and the dialog is drawn above everything else. The toasts are a popover (in the top layer, above everything), and while a modal dialog is open they move into it, so a toast about something done in a dialog that stays open shows above it, can be closed and is announced. When the dialog closes they move back. A response that closes the dialog and shows a toast (HX-Trigger: {"sl-dialog-close": null, "sl-toast": {...}}) works whichever event comes first.

The toasts themselves are not a live region, so a screen reader does not read them twice. The toaster has two empty live regions from the start, one polite (role="status") and one assertive (role="alert", for errors), and writes each toast’s title and description into one of them when it arrives. A live region has to be on the page before its content changes for a screen reader to notice, which is why they are in the toaster’s markup rather than added with the toast; a toast rendered with the page is written into them a moment after it loads. The messages are removed from the live regions after a few seconds, so reading the page later finds each toast once, in the “Notifications” region, while it shows. The region is only a landmark while it holds toasts.

A toast must not take the focus: it goes away, and the focus would fall to the top of the page with it. When an action removes the element that had the focus, such as a Delete button in a table row, move the focus to what comes next and let the toast say what happened. The alert dialog’s htmx example moves it to the next row.

Sonner’s toast can hold an action button (“Undo”). Shadleaf’s has none: a toast closes by itself, and whatever it offers is gone with it, for someone who reads slowly, uses a screen reader or looked away. WCAG asks that a time limit on something the user has to do can be turned off or extended. Put the action in the page (an Undo in the row’s place, a link in the confirmation), and use the toast to say what happened. If a toast you render out of band holds a link, the same must be reachable elsewhere, or give it duration="0".

  • toast() in the browser. The server sends toasts; a script can dispatch sl-toast (see above).
  • Action and cancel buttons, and promise and loading toasts: see above; a loading state belongs on the button that started the request.
  • Swipe to dismiss. The close button, Escape and the time do it.
  • Rich colours (richColors), expand (always spread out), invert, offset and gap as props: the skins set the look, and the CSS custom properties --toaster-offset and --toast-gap on .toaster-viewport the spacing.
  • Updating a toast by id.

Without Alpine a toast rendered with the page still shows, in its corner, but it has no close button and does not go away; events and out-of-band toasts need htmx or a script anyway.

AttributeValuesDefaultDescription
idstringtoasterThe toaster's id, which an out-of-band swap targets (hx-swap-oob="beforeend:#toaster").
positiontop-left top-center top-right bottom-left bottom-center bottom-rightbottom-rightThe corner or edge the toasts show in. right and left are the end and the start of a line.
durationnumber5000How long a toast shows, in milliseconds, unless it sets its own. The time stops while the pointer is on the toasts, while one of them has the focus and while the page is hidden. 0 keeps toasts until they are closed.
visible-toastsnumber3How many toasts show at once, the newest ones. Older ones wait, with their time stopped, and show again when a newer one closes.

Every prop can also be set from an expression: th:id="${...}". Any other attribute passes through to the rendered element, and class is appended.

AttributeValuesDefaultDescription
titlestringrequiredWhat happened, in a few words: "Member deleted".
descriptionstring—More about it, under the title.
variantdefault success info warning errordefaultThe kind of message, shown by its icon: none, a check, an i, a warning triangle or an x. A screen reader announces an error at once, interrupting what it reads; the others when it is done.
durationnumber—How long this toast shows, in milliseconds, instead of the toaster's duration. 0 keeps it until it is closed.

Every prop can also be set from an expression: th:title="${...}". Any other attribute passes through to the rendered element, and class is appended.

A small Alpine component, slToaster, is on <sl:toaster>: it renders as <div class="toaster" x-data="slToaster">.

  • It needs no Alpine plugin. If you load Alpine yourself (shadleaf.assets.alpine=external), plain Alpine is enough. See Alpine.
  • Keep your own x-data off <sl:toaster>. An element has one x-data.
  • One toaster per page. A second one works, but the first takes every event.
  • htmx history: the toaster removes its toasts before htmx saves the page in its history cache, so going back never shows an old toast again.
Event On Effect
sl-toast anything (it bubbles to the document), or the document Shows the toast in its detail: {title, description, variant, duration}, a list of them, or a string.