Building a toast component

A foundational overview of how to build an adaptive and accessible toast component.

In this post I want to share thinking on how to build a toast component. Try the demo.

Demo

If you prefer video, here's a YouTube version of this post:

Overview

Toasts are non-interactive, passive, and asynchronous short messages for users. Generally they are used as an interface feedback pattern for informing the user about the results of an action.

Interactions

Toasts are unlike notifications, alerts and prompts because they're not interactive; they're not meant to be dismissed or persist. Notifications are for more important information, synchronous messaging that requires interaction, or system level messages (as opposed to page level). Toasts are more passive than other notice strategies.

Markup

The <output> element is a good choice for the toast because it is announced to screen readers. Correct HTML provides a safe base for us to enhance with JavaScript and CSS, and there will be lots of JavaScript.

A toast

<output class="gui-toast">Item added to cart</output>

It can be more inclusive by adding role="status". This provides a fallback if the browser doesn't give <output> elements the implicit role per the spec.

<output role="status" class="gui-toast">Item added to cart</output>

A toast container

More than one toast can be shown at a time. In order to orchestrate multiple toasts, a container is used. This container also handles the position of the toasts on the screen.

<section class="gui-toast-group">
  <output role="status">Wizard Rose added to cart</output>
  <output role="status">Self Watering Pot added to cart</output>
</section>

Layouts

I chose to pin toasts to the inset-block-end of the viewport, and if more toasts are added, they stack from that screen edge.

GUI container

The toasts container does all the layout work for presenting toasts. It's fixed to the viewport and uses the logical property inset to specify which edges to pin to, plus a little bit of padding from the same block-end edge.

.gui-toast-group {
  position: fixed;
  z-index: 1;
  inset-block-end: 0;
  inset-inline: 0;
  padding-block-end: 5vh;
}

Screenshot with DevTools box size and padding overlayed on a .gui-toast-container element.

In addition to positioning itself within the viewport, the toast container is a grid container that can align and distribute toasts. Items are centered as a group with justify-content and individually centered with justify-items. Throw in a little bit of gap so toasts don't touch.

.gui-toast-group {
  display: grid;
  justify-items: center;
  justify-content: center;
  gap: 1vh;
}

Screenshot with the CSS grid overlay on the toast group, this time
highlighting the space and gaps between toast child elements.

GUI Toast

An individual toast has some padding, some softer corners with border-radius, and a min() function to aid in mobile and desktop sizing. The responsive size in the following CSS prevents toasts growing wider than 90% of the viewport or 25ch.

.gui-toast {
  max-inline-size: min(25ch, 90vw);
  padding-block: .5ch;
  padding-inline: 1ch;
  border-radius: 3px;
  font-size: 1rem;
}

Screenshot of a single .gui-toast element, with the padding and border
radius shown.

Styles

With layout and positioning set, add CSS that helps with adapting to user settings and interactions.

Toast container

Toasts are not interactive, tapping or swiping on them doesn't do anything, but they do currently consume pointer events. Prevent the toasts from stealing clicks with the following CSS.

.gui-toast-group {
  pointer-events: none;
}

GUI Toast

Give the toasts a light or dark adaptive theme with custom properties, HSL and a preference media query.

.gui-toast {
  --_bg-lightness: 90%;

  color: black;
  background: hsl(0 0% var(--_bg-lightness) / 90%);
}

@media (prefers-color-scheme: dark) {
  .gui-toast {
    color: white;
    --_bg-lightness: 20%;
  }
}

Animation

A new toast should present itself with an animation as it enters the screen. Accommodating reduced motion is done by setting translate values to 0 by default, but updating the motion value to a length in a motion preference media query . Everyone gets some animation, but only some users have the toast travel a distance.

Here are the keyframes used for the toast animation. CSS will be controlling the entrance, the wait, and the exit of the toast, all in one animation.

@keyframes fade-in {
  from { opacity: 0 }
}

@keyframes fade-out {
  to { opacity: 0 }
}

@keyframes slide-in {
  from { transform: translateY(var(--_travel-distance, 10px)) }
}

The toast element then sets up the variables and orchestrates the keyframes.

.gui-toast {
  --_duration: 3s;
  --_travel-distance: 0;

  will-change: transform;
  animation: 
    fade-in .3s ease,
    slide-in .3s ease,
    fade-out .3s ease var(--_duration);
}

@media (prefers-reduced-motion: no-preference) {
  .gui-toast {
    --_travel-distance: 5vh;
  }
}

JavaScript

With the styles and screen reader accessible HTML ready, JavaScript is needed to orchestrate the creation, addition, and destruction of toasts based on user events. The developer experience of the toast component should be minimal and easy to get started with, like this:

import Toast from './toast.js'

Toast('My first toast')

Creating the toast group and toasts

When the toast module loads from JavaScript, it must create a toast container and add it to the page. I chose to add the element before body, this will make z-index stacking issues unlikely as the container is above the container for all the body elements.

const init = () => {
  const node = document.createElement('section')
  node.classList.add('gui-toast-group')

  document.firstElementChild.insertBefore(node, document.body)
  return node
}

Screenshot of the toast group between the head and body tags.

The init() function is called internally to the module, stashing the element as Toaster:

const Toaster = init()

Toast HTML element creation is done with the createToast() function. The function requires some text for the toast, creates an <output> element, adorns it with some classes and attributes, sets the text, and returns the node.

const createToast = text => {
  const node = document.createElement('output')
  
  node.innerText = text
  node.classList.add('gui-toast')
  node.setAttribute('role', 'status')

  return node
}

Managing one or many toasts

JavaScript now adds a container to the document for containing toasts and is ready to add created toasts. The addToast() function orchestrates handling one or many toasts. First checking the number of toasts, and whether motion is ok, then using this information to either append the toast or do some fancy animation so the other toasts appear to "make room" for the new toast.

const addToast = toast => {
  const { matches:motionOK } = window.matchMedia(
    '(prefers-reduced-motion: no-preference)'
  )

  Toaster.children.length && motionOK
    ? flipToast(toast)
    : Toaster.appendChild(toast)
}

When adding the first toast, Toaster.appendChild(toast) adds a toast to the page triggering the CSS animations: animate in, wait 3s, animate out. flipToast() is called when there are existing toasts, employing a technique called FLIP by Paul Lewis. The idea is to calculate the difference in positions of the container, before and after the new toast has been added. Think of it like marking where the Toaster is now, where it's going to be, then animating from where it was to where it is.

const flipToast = toast => {
  // FIRST
  const first = Toaster.offsetHeight

  // add new child to change container size
  Toaster.appendChild(toast)

  // LAST
  const last = Toaster.offsetHeight

  // INVERT
  const invert = last - first

  // PLAY
  const animation = Toaster.animate([
    { transform: `translateY(${invert}px)` },
    { transform: 'translateY(0)' }
  ], {
    duration: 150,
    easing: 'ease-out',
  })
}

CSS grid does the lifting of the layout. When a new toast is added, grid puts it at the start and spaces it with the others. Meanwhile, a web animation is used to animate the container from the old position.

Putting all the JavaScript together

When Toast('my first toast') is called, a toast is created, added to the page (maybe even the container is animated to accommodate the new toast), a promise is returned and the created toast is watched for CSS animation completion (the three keyframe animations) for promise resolution.

const Toast = text => {
  let toast = createToast(text)
  addToast(toast)

  return new Promise(async (resolve, reject) => {
    await Promise.allSettled(
      toast.getAnimations().map(animation => 
        animation.finished
      )
    )
    Toaster.removeChild(toast)
    resolve() 
  })
}

I felt the confusing part of this code is in the Promise.allSettled() function and toast.getAnimations() mapping. Since I used multiple keyframe animations for the toast, to confidently know all of them have finished, each must be requested from JavaScript and each of their finished promises observed for completion. allSettled does that work for us, resolving itself as complete once all of its promises have been fulfilled. Using await Promise.allSettled() means the next line of code can confidently remove the element and assume the toast has completed its lifecycle. Finally, calling resolve() fulfills the high level Toast promise so developers can clean up or do other work once the toast has shown.

export default Toast

Last, the Toast function is exported from the module, for other scripts to import and use.

Using the Toast component

Using the toast, or the toast's developer experience, is done by importing the Toast function and calling it with a message string.

import Toast from './toast.js'

Toast('Wizard Rose added to cart')

If the developer wants to do clean up work or whatever, after the toast has shown, they can use async and await.

import Toast from './toast.js'

async function example() {
  await Toast('Wizard Rose added to cart')
  console.log('toast finished')
}

Conclusion

Now that you know how I did it, how would you‽ 🙂

Let's diversify our approaches and learn all the ways to build on the web. Create a demo, tweet me links, and I'll add it to the community remixes section below!

Community remixes