Toast
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>One toaster, toasts from the server
Section titled “One toaster, toasts from the server”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()From an htmx response: HX-Trigger
Section titled “From an htmx response: HX-Trigger”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, notHX-Trigger-After-Swapor-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-RedirectorHX-Locationloads another page, and the toast with it is gone. Put it in a flash attribute instead (next section).
After a redirect: a flash attribute
Section titled “After a redirect: a flash attribute”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.
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.
Out of band
Section titled “Out of band”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).
From your own script
Section titled “From your own script”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.
Variants
Section titled “Variants”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.
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>Timing, pausing and closing
Section titled “Timing, pausing and closing”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.
Several at once
Section titled “Several at once”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.
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
Section titled “Position”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"/>Above a modal dialog
Section titled “Above a modal dialog”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.
Announcement
Section titled “Announcement”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.
Focus after the thing is gone
Section titled “Focus after the thing is gone”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.
No action button
Section titled “No action button”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".
Left out
Section titled “Left out”toast()in the browser. The server sends toasts; a script can dispatchsl-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,offsetandgapas props: the skins set the look, and the CSS custom properties--toaster-offsetand--toast-gapon.toaster-viewportthe spacing. - Updating a toast by id.
Without JavaScript
Section titled “Without JavaScript”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.
Attributes
Section titled “Attributes”sl:toaster
Section titled “sl:toaster”| Attribute | Values | Default | Description |
|---|---|---|---|
id | string | toaster | The toaster's id, which an out-of-band swap targets (hx-swap-oob="beforeend:#toaster"). |
position | top-left top-center top-right bottom-left bottom-center bottom-right | bottom-right | The corner or edge the toasts show in. right and left are the end and the start of a line. |
duration | number | 5000 | How 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-toasts | number | 3 | How 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.
sl:toast
Section titled “sl:toast”| Attribute | Values | Default | Description |
|---|---|---|---|
title | string | required | What happened, in a few words: "Member deleted". |
description | string | — | More about it, under the title. |
variant | default success info warning error | default | The 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. |
duration | number | — | 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.
JavaScript
Section titled “JavaScript”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-dataoff<sl:toaster>. An element has onex-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. |