Toast Stack

New notifications slide up from the bottom and push older ones back into a neat, shrinking pile that clears itself.

  • CSS
  • Cards
  • Click
  • Added

AI prompt

Create a stacked toast notification system in React and CSS. Keep an array of toasts with unique ids; the trigger button pushes one and each toast removes itself after 3s. Render at most the newest three, absolutely positioned at the bottom of a container. Give each toast an index from the newest (0, 1, 2) as a CSS variable and set transform: translateY(calc(var(--i) * -10px)) scale(calc(1 - var(--i) * 0.06)) and opacity calc(1 - var(--i) * 0.25), with a 360ms cubic-bezier(0.16, 1, 0.3, 1) transition so older toasts slide back as new ones arrive. New toasts enter with a keyframe from translateY(100%) and opacity 0. Each toast has a dismiss button. Use role="status" on the container so messages are announced. Clear all timers on unmount. Under prefers-reduced-motion, remove the slide and keep a quick fade. The message reads "Changes saved".

Source

Demo.tsx
'use client'

import { useEffect, useRef, useState } from 'react'
import s from './demo.module.css'

export default function ToastStack({ labels }: { labels: Record<string, string> }) {
  const [toasts, setToasts] = useState<number[]>([])
  const nextId = useRef(0)
  const timers = useRef(new Map<number, number>())

  useEffect(() => {
    const map = timers.current
    return () => map.forEach(clearTimeout)
  }, [])

  const remove = (id: number) => {
    clearTimeout(timers.current.get(id))
    timers.current.delete(id)
    setToasts((t) => t.filter((x) => x !== id))
  }

  const add = () => {
    const id = nextId.current++
    setToasts((t) => [...t, id])
    timers.current.set(id, window.setTimeout(() => remove(id), 3000))
  }

  const visible = toasts.slice(-3)

  return (
    <div className={s.wrap}>
      <button type="button" className={s.trigger} onClick={add}>
        {labels.trigger}
      </button>
      <div className={s.area} role="status">
        {visible.map((id, n) => (
          <div key={id} className={s.toast} style={{ '--i': visible.length - 1 - n } as React.CSSProperties}>
            <span className={s.dot} aria-hidden="true" />
            <span className={s.msg}>{labels.msg}</span>
            <button type="button" className={s.close} aria-label={labels.close} onClick={() => remove(id)}>
              ×
            </button>
          </div>
        ))}
      </div>
    </div>
  )
}
demo.module.css
.wrap {
  position: absolute;
  inset: 0;
  display: grid;
  justify-items: center;
  align-content: start;
  padding-top: 36px;
}

.trigger {
  height: 40px;
  padding: 0 20px;
  border: 0;
  border-radius: var(--radius-sm);
  background: var(--stage-accent);
  color: var(--stage-accent-ink);
  font: 500 var(--text-sm) / 1 var(--font-body);
  cursor: pointer;
}

.trigger:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 3px; }

.area {
  position: absolute;
  left: 50%;
  bottom: 24px;
  width: min(260px, calc(100% - 32px));
  translate: -50% 0;
}

.toast {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  display: flex;
  align-items: center;
  gap: 10px;
  height: 48px;
  padding: 0 8px 0 14px;
  border: 1px solid var(--stage-rule);
  border-radius: 12px;
  background: var(--stage-surface);
  box-shadow: 0 10px 24px oklch(0% 0 0 / 0.35);
  transform-origin: 50% 100%;
  transform: translateY(calc(var(--i) * -10px)) scale(calc(1 - var(--i) * 0.06));
  opacity: calc(1 - var(--i) * 0.25);
  transition: transform 360ms var(--ease-out), opacity 360ms var(--ease-out);
  animation: enter 360ms var(--ease-out);
}

@keyframes enter {
  from { transform: translateY(100%); opacity: 0; }
}

@keyframes fade {
  from { opacity: 0; }
}

.dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--stage-accent);
}

.msg {
  flex: 1;
  font: 500 var(--text-sm) / 1 var(--font-body);
  color: var(--stage-ink);
}

.close {
  width: 30px;
  height: 30px;
  border: 0;
  border-radius: 8px;
  background: transparent;
  color: var(--stage-muted);
  font-size: var(--text-lg);
  cursor: pointer;
}

.close:hover { color: var(--stage-ink); background: var(--stage-bg); }
.close:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 1px; }

@media (prefers-reduced-motion: reduce) {
  .toast { transition: opacity 150ms; animation-name: fade; }
}

Suggest an idea

Seen a motion or a theme you would like to find here? Describe it, and it opens as a prefilled GitHub issue for you to post.

What are you suggesting?

Needs a GitHub account. Suggestions are public.