Toast Stack
New notifications slide up from the bottom and push older ones back into a neat, shrinking pile that clears itself.
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
'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>
)
}
.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; }
}