拟态通知

toast · neumorphic · 灵感来源:sonner(Emil Kowalski)的堆叠公式 + Neumorphism 的同色浮起

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

复用卡

Neumorphic Toast Stack (sonner-style) · 拟态通知 · 通知 / Neumorphic

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

做一个 Neumorphic Toast Stack (sonner-style)(拟态通知):
- 宽 356px,牌堆每层缩 5%、抬 14px
- 停留计时 3.5s,悬停暂停、移出继续
- 进出场分离:进场 400ms / 退场 300ms
- 卡片同色浮起(外双阴影),无边框无模糊
用这些 CSS 变量:--color-info-fg --color-success-fg --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-enter --dur-slow --ease-out --ease-standard --neuro-bg --neuro-shadow
灵感参考:sonner(Emil Kowalski)的堆叠公式 + Neumorphism 的同色浮起

参数(CSS 变量)

--color-info-fg --color-success-fg --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-enter --dur-slow --ease-out --ease-standard --neuro-bg --neuro-shadow

必缺状态

焦点

整页场景

(尚未接进整页场景)

安装(一条链接)

参考(本站可核对)

调研笔记

拟态通知

  • 与玻璃、极简通知同一套行为:356px 宽、14px 层距、每层缩 5%、3.5s 自动消失、悬停暂停、退场播完才移除
  • 卡片没有边框与模糊:--neuro-bg + --neuro-shadow 浮起,牌堆的层次由阴影叠加给出
  • 语气仍由左侧色点 + 文案共同表达;关闭钮保持小尺寸文字按钮
  • 退场沿用 --dur-slow / --ease-standard,与进场 --dur-enter / --ease-out 成对
  • 堆叠未做高度塌缩:后排比前排矮时会被盖住,demo 用等高三条演示

限制:同色阴影叠在页面底部时,连续三条以上会糊成一片;只适合单条或两条的场景。

实现

toast/neumorphic
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 NeumorphicToastStack({ 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-2xl bg-[var(--neuro-bg)]
                shadow-[var(--neuro-shadow)]
                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/neumorphic
状态
done
最后提交
2026-10-31
风格轴
neumorphic
依赖 token
--color-info-fg --color-success-fg --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-enter --dur-slow --ease-out --ease-standard --neuro-bg --neuro-shadow
在工作台打开 源码与 notes 都在 src/components/toast/neumorphic/