toast · neumorphic · 灵感来源:sonner(Emil Kowalski)的堆叠公式 + Neumorphism 的同色浮起
3.5s 自动消失 · 点 ✕ 立即关闭 · 观察牌堆缩放
Neumorphic Toast Stack (sonner-style) · 拟态通知 · 通知 / Neumorphic
做一个 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 的同色浮起--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
焦点
(尚未接进整页场景)
/r/toast--neumorphic.json
免外壳的 markdown 版:/llms/components/toast--neumorphic.md--neuro-bg + --neuro-shadow 浮起,牌堆的层次由阴影叠加给出--dur-slow / --ease-standard,与进场 --dur-enter / --ease-out 成对限制:同色阴影叠在页面底部时,连续三条以上会糊成一片;只适合单条或两条的场景。
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,
);
}
src/components/toast/neumorphic/