Toast

This pattern shows how to build an adaptive and accessible toast component.

Full article · Video on YouTube · Source on Github

HTML

<button id="spells">
  Cast Spell
</button>
<button id="actions">
  Mock User Action
</button>

CSS


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

  display: grid;
  justify-items: center;
  justify-content: center;
  gap: 1vh;

  /* optimizations */
  pointer-events: none;
}

.gui-toast {
  --_duration: 3s;
  --_bg-lightness: 90%;
  --_travel-distance: 0;

  font-family: system-ui, sans-serif;
  color: black;
  background: hsl(0 0% var(--_bg-lightness) / 90%);
  
  max-inline-size: min(25ch, 90vw);
  padding-block: .5ch;
  padding-inline: 1ch;
  border-radius: 3px;
  font-size: 1rem;

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

  @media (--dark) {
    color: white;
    --_bg-lightness: 20%;
  }

  @media (--motionOK) {
    --_travel-distance: 5vh;
  }
}

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

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

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

JS


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

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

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

  return node
}

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

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

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() 
  })
}

// https://aerotwist.com/blog/flip-your-animations/
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',
  })

  animation.startTime = document.timeline.currentTime
}

const Toaster = init()
export default Toast