玻璃通知

toast · glass-apple · 灵感来源:sonner(Emil Kowalski)。源码级调研,非目测

3.5s 自动消失 · 点 ✕ 立即关闭 · 观察牌堆缩放

复用卡

Toast Stack (sonner-style) · 玻璃通知 · 通知 / Glass

规格 · 术语提示词(给 AI 用)

做一个 Toast Stack (sonner-style)(玻璃通知):
- 新通知在前,后排逐层缩小上移
- 停留计时 3.5s,悬停暂停、移出继续
- 进出场分离:进场 400ms / 退场 300ms
- 退场沿进场路径原路返回
用这些 CSS 变量:--color-info-fg --color-success-fg --color-surface --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-enter --dur-slow --ease-out --ease-standard --glass-border --shadow-md --shadow-sm
灵感参考:sonner(Emil Kowalski)。源码级调研,非目测

参数(CSS 变量)

--color-info-fg --color-success-fg --color-surface --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-enter --dur-slow --ease-out --ease-standard --glass-border --shadow-md --shadow-sm

必缺状态

焦点

整页场景

(尚未接进整页场景)

安装(一条链接)

参考(本站可核对)

调研笔记

玻璃通知

调研数据(sonner 源码)

  • 宽 356px、视口边距 24px、堆叠间距 GAP = 14px
  • 堆叠公式:第 n 层(0=前排)scale = n × 0.05 + 1 反向缩小 + translateY(lift × gap × n),即每压一层缩 5%、抬一个间距
  • 时序:transform --dur-enter(400ms)+ opacity --dur-slow(300ms)
  • 入场:300ms,scale 0.8→1 淡入;退出:前排滑出 100%,后排 40% 回落补位

实现取舍(零依赖简化版)

  • 保留:堆叠公式(5% 缩放 + 14px 抬升)、400ms 时序、356px 宽度、自动消失
  • 砍掉:滑动关闭、portal、高度塌缩动画(v1 用绝对定位牌堆代替)
  • 高度塌缩未做:后排卡片比前排矮时会被完全盖住,demo 用等高三条演示堆叠
  • 受控组件:toasts + onDismiss;退场动画播完才回调移除,动画不被截断
  • 浅色适配:阴影用 --shadow-md,面板白底(对应调研的 0 4px 12px 软阴影)

关键参数

  • 自动消失默认 3500ms;退场 320ms(translateY 120% + 淡出)
  • 进场曲线 --ease-out,与全站 fadeUp 一致

实现

toast/glass-apple
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { motionMs } from '~/lib/motion';

export interface ToastData {
  id: string;
  title: string;
  desc?: string;
  /** 自动消失毫秒数,默认 3500 */
  duration?: number;
  /** 语气:决定左侧色点与语义色 */
  tone?: 'default' | 'success' | 'error' | 'info';
}

/** 语气 → 左侧圆点颜色(状态不能只靠颜色表达,所以永远配文字) */
const TONE_DOT: Record<string, string> = {
  default: 'bg-[var(--color-text-subtle)]',
  success: 'bg-[var(--color-success-fg)]',
  error: 'bg-[var(--color-warning-fg)]',
  info: 'bg-[var(--color-info-fg)]',
};

interface Props {
  toasts: ToastData[];
  onDismiss: (id: string) => void;
}

const GAP = 14;          // sonner 源码值
const SHRINK = 0.05;     // 每压一层缩 5%

export function GlassAppleToastStack({ toasts, onDismiss }: Props) {
  const [entering, setEntering] = useState<Set<string>>(new Set());
  const [leaving, setLeaving] = useState<Set<string>>(new Set());
  const known = useRef<Set<string>>(new Set());
  const timers = useRef<Map<string, number>>(new Map());
  // 悬停时暂停计时:读通知的人不该被计时器赶走(剩下的时间记在这里)
  const paused = useRef<Map<string, { at: number; left: number }>>(new Map());

  // 新 toast:进场动画 + 自动消失计时
  useEffect(() => {
    for (const t of toasts) {
      if (known.current.has(t.id)) continue;
      known.current.add(t.id);
      setEntering((s) => new Set(s).add(t.id));
      // 双 rAF 保证初始样式先绘制,过渡才生效
      requestAnimationFrame(() =>
        requestAnimationFrame(() =>
          setEntering((s) => {
            const n = new Set(s);
            n.delete(t.id);
            return n;
          }),
        ),
      );
      const timer = window.setTimeout(() => dismiss(t.id), t.duration ?? 3500);
      timers.current.set(t.id, timer);
    }
  }, [toasts]);

  useEffect(() => {
    const map = timers.current;
    return () => {
      for (const t of map.values()) clearTimeout(t);
    };
  }, []);

  /** 悬停暂停 / 移出继续 */
  const pause = (id: string) => {
    const timer = timers.current.get(id);
    if (timer == null) return;
    clearTimeout(timer);
    timers.current.delete(id);
    const t = toasts.find((x) => x.id === id);
    const total = t?.duration ?? 3500;
    paused.current.set(id, { at: Date.now(), left: total });
  };

  const resume = (id: string) => {
    const p = paused.current.get(id);
    if (!p) return;
    paused.current.delete(id);
    const left = Math.max(400, p.left - (Date.now() - p.at));
    timers.current.set(id, window.setTimeout(() => dismiss(id), left));
  };

  const dismiss = (id: string) => {
    if (leaving.has(id)) return;
    const timer = timers.current.get(id);
    if (timer) clearTimeout(timer);
    timers.current.delete(id);
    paused.current.delete(id);
    setLeaving((s) => new Set(s).add(id));
    // 退场动画播完再真正移除,避免截断
    window.setTimeout(() => {
      known.current.delete(id);
      setLeaving((s) => {
        const n = new Set(s);
        n.delete(id);
        return n;
      });
      onDismiss(id);
    }, motionMs('--dur-slow', 300));
  };

  // 前排 = 最新(数组末尾);k = 距前排的层数
  const visible = toasts.filter((t) => known.current.has(t.id) || !leaving.has(t.id));
  const frontFirst = [...visible].reverse();

  // portal 到 body:避开任何 transformed 祖先(动画 fill 会捕获 fixed 定位)
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  if (!mounted) return null;

  return createPortal(
    <div className="fixed bottom-6 left-0 right-0 z-50 flex justify-center pointer-events-none">
      <div className="relative w-[356px] max-w-[92vw]">
        {frontFirst.map((t, k) => {
          const isEntering = entering.has(t.id);
          const isLeaving = leaving.has(t.id);

          let transform: string;
          let opacity: number;
          if (isEntering) {
            transform = 'translateY(10px) scale(0.92)';
            opacity = 0;
          } else if (isLeaving) {
            transform = 'translateY(120%)';
            opacity = 0;
          } else {
            transform = `translateY(${-k * GAP}px) scale(${1 - k * SHRINK})`;
            opacity = 1;
          }

          const style: CSSProperties = {
            transform,
            opacity,
            zIndex: frontFirst.length - k,
            transformOrigin: 'bottom center',
            transition: `transform var(--dur-enter) var(--ease-out), opacity var(--dur-slow) var(--ease-standard)`,
          };

          return (
            <div
              key={t.id}
              role="status"
              onMouseEnter={() => pause(t.id)}
              onMouseLeave={() => resume(t.id)}
              className="
                absolute bottom-0 w-full pointer-events-auto
                rounded-xl bg-[var(--color-surface)]
                border border-[var(--glass-border)]
                shadow-[var(--shadow-md)]
                px-4 py-3 flex items-start gap-3
              "
              style={style}
            >
              <span
                className={`mt-1.5 size-2 rounded-full shrink-0 ${TONE_DOT[t.tone ?? 'default']}`}
                aria-hidden="true"
              />
              <div className="flex-1 min-w-0">
                <p className="text-sm font-medium truncate">{t.title}</p>
                {t.desc && (
                  <p className="text-xs text-[var(--color-text-muted)] mt-0.5">{t.desc}</p>
                )}
              </div>
              <button
                type="button"
                aria-label="关闭通知"
                onClick={() => dismiss(t.id)}
                className="
                  press-feedback shrink-0 size-6 rounded-md -mr-1 -mt-0.5
                  flex items-center justify-center
                  text-[var(--color-text-subtle)] hover:text-[var(--color-text)]
                  transition-colors cursor-pointer
                "
              >
                ✕
              </button>
            </div>
          );
        })}
      </div>
    </div>,
    document.body,
  );
}

元信息

标识
toast/glass-apple
状态
done
最后提交
2026-10-31
风格轴
glass-apple
依赖 token
--color-info-fg --color-success-fg --color-surface --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-enter --dur-slow --ease-out --ease-standard --glass-border --shadow-md --shadow-sm
在工作台打开 源码与 notes 都在 src/components/toast/glass-apple/