堆叠通知
新通知从底部滑入,把旧通知推到后面,叠成一摞逐渐缩小、会自动清空的卡片。
用 React 和 CSS 制作堆叠式 toast 通知。用数组保存带唯一 id 的通知;触发按钮会添加一条,每条通知在 3s 后自动移除。最多渲染最新的三条,绝对定位在容器底部。给每条通知一个从最新开始计数的序号(0、1、2)作为 CSS 变量,设置 transform: translateY(calc(var(--i) * -10px)) scale(calc(1 - var(--i) * 0.06)),opacity 为 calc(1 - var(--i) * 0.25),并加上 360ms、cubic-bezier(0.16, 1, 0.3, 1) 的过渡,让新通知到来时旧通知向后滑。新通知用 keyframe 从 translateY(100%)、opacity 0 进入。每条通知带一个关闭按钮。容器设置 role="status",让消息被播报。组件卸载时清除所有计时器。在 prefers-reduced-motion 下去掉滑动,只保留快速淡入。消息为“修改已保存”。
源代码
'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; }
}