堆叠通知

新通知从底部滑入,把旧通知推到后面,叠成一摞逐渐缩小、会自动清空的卡片。

  • CSS
  • 卡片
  • 点击
  • 收录于

AI 提示词

用 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 下去掉滑动,只保留快速淡入。消息为“修改已保存”。

源代码

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; }
}

投稿创意

看到想收录的动效或主题?简单描述一下,会打开一个预先填好的 GitHub Issue,确认后即可提交。

你想投稿的是?

需要 GitHub 账号,投稿内容会公开。