浮层退场
Toast Exit进来时滑得进来,走的时候先停住再离开
完整版:适合从零新建组件,规格、契约、验收全都在。
实时预览
布局NOTIFICATION CENTER
通知中心
220ms in · 190ms out · 16px · 2600ms hold
参数
边调边看,提示词同步更新。
角色
你是一名资深前端工程师,工作在现有的项目中,技术栈是 React 19 + TypeScript + Tailwind CSS v4,进出场用纯 CSS keyframes,退场由 animationend 卸载。只实现下面这一个微交互:不要加额外功能,不要顺手重构周围代码。
任务
实现浮层堆栈:toast 从内侧 16px 处(translateX(-16px))用 220ms 淡入,停留 2600ms 后沿同一轴滑向 translateX(+16px) 并用 190ms 淡出,只有 animationend 之后才卸载。手动关闭走同一条退场路径。toast 按固定槽位堆叠,谁都不会被推着位移;同一段文案还在屏上时不会重复入栈。
文件契约
- 只新建一个文件:components/effects/ToastStack.tsx。
- 依赖:无需额外依赖:keyframes 由组件内的 <style> 注入。
- 不要修改 package.json、全局 CSS、Tailwind 配置或任何其他已有文件。
实现要点
- 文件顶部加 "use client";用 forwardRef 暴露 push(message, kind),让外部通过 ref 推送。
- 维持一份 useRef 的 toast 列表作为唯一事实源,commit() 时同步 setToasts,让定时器永远读到最新栈而不是旧渲染快照。
- 退场用 data-exiting 切换 toast-exit-out keyframes;退场开始的那一刻把文案存进 snapshot 并冻结渲染,之后触发方清空数据也不会让飞出中的 toast 变空。
- 退场时 pointer-events: none,animationend(animationName === "toast-exit-out")后从数组移除;另设 exitMs + 400ms 兜底定时器防止动画事件不触发。
- 槽位按空闲索引分配(bottom = inset + slot × (height + gap)),删除一条不会重排其它 toast;同文案还在栈中时直接忽略;卸载时清空所有 hold / exit 定时器。
规格 —— 精确数值,不许四舍五入或“优化”
- 入场:从 translateX(-16px)、opacity 0 开始,到 translateX(0)、opacity 1 结束——时长 220ms,缓动 cubic-bezier(0.16, 1, 0.3, 1),animation-fill-mode: both。
- 退场:从 translateX(0)、opacity 1 开始,到 translateX(+16px)、opacity 0 结束——时长 190ms,缓动 cubic-bezier(0.4, 0, 1, 1),animation-fill-mode: both。
- 停留:入场结束后停留 2600ms 自动退场;手动关闭立刻开始退场,不等停留结束。
- 几何:每条 toast 宽 280px、高 44px、圆角 12px、左右内边距 12px,固定在右下角、距边 12px。槽位固定:bottom = 12px + slot × 52px,同时最多 3 条。
- 退场期间 toast 必须带 data-exiting="true" 且 pointer-events: none;只有 animationend 之后才从 DOM 移除。
- 只允许动画 transform 与 opacity;绝不允许动画 width、height、top、left、margin 或堆叠位置。
行为
- 推送规则:空文本忽略;同一段文案还在屏上时忽略(不允许重复入栈);栈满时忽略。
- 槽位分给最小空闲索引。删除一条时绝不允许重排其它 toast——别的 toast 在入场 / 退场期间 boundingBox 必须完全不变。
- 退场从快照开始:退场那一刻把当前的文案冻结进 snapshot 字段,之后 owner 清空数据也不会让飞出中的 toast 变空。
- 从 animationend(判断 event.animationName)里卸载节点,不要用估算的定时器;可以加一个比退场更长的兜底定时器,只作为不触发动画事件时的保险。
- 手动关闭按钮走与自动定时器完全相同的退场路径——不要立刻卸载。
- 可访问性:文案用 role="status"(错误用 role="alert");关闭控件是真实 <button type="button"> 且带 aria-label;focus-visible 显示 2px #C9F45A、外扩 3px 的描边。
- prefers-reduced-motion: reduce:不播入场动画也不播退场动画——toast 直接以最终态出现,退场时直接消失。
- 卸载时清理:清掉所有 setTimeout;行内 <style> 保持组件内作用域。
禁止
- 不要用布局属性(width、height、top、left、margin)来移动或堆叠 toast。
- 不允许推动别的 toast:已在屏上的 toast 不得重排、不得重算槽位。
- 不要用估算动画长度的 setTimeout 卸载,退场期间 pointer-events 不能还是可点的。
- 不允许同一文案重复堆叠;退场前和退场中都不允许把文案清空。
- 不要留 TODO、不要写 // 其余代码、不要用 any、不要 console.log、不要留注释掉的实验代码。
- 不要把组件包进页面、布局、演示壳或 Provider;不要改动全局 CSS、Tailwind 配置、package.json 或上面文件之外的任何文件。
- 不要引入任何动画库;进出场必须是纯 CSS keyframes。
验收 —— 回答前逐项自查
1. 入场时长、退场时长、位移距离、停留时间都与当前参数一致(默认 220ms / 190ms / 16px / 2600ms)。
2. 在入场与退场各自的 60–80ms 采样,toast 的 opacity 与 translateX 都严格处于起止状态之间,没有跳变或先闪出最终态。
3. 退场由 animationend 触发卸载:退场期间带 data-exiting、pointer-events:none,动画结束后节点才从 DOM 移除,不残留 transform。
4. 其它 toast 的 boundingBox 在整个入场 / 退场过程中完全不变,堆叠槽位固定,没有任何一条被推着位移。
5. 同一段文案还在屏上时不会重复入栈;退场前已把文案快照下来,退场过程中文案不会变空。
6. 推送按钮与关闭按钮都能 Tab 聚焦、Enter / Space 触发;关闭按钮有 aria-label,焦点环可见(2px lime,外扩 3px)。
7. 控制台 0 报错 / 警告;prefers-reduced-motion 下不播动画、直接最终态;卸载后没有残留定时器。
输出
- 用一个代码块输出完整文件内容,不要 diff、不要省略号,代码块之前不要解释。
- 如果缺少必要依赖,先停下来问我,不要擅自安装。
- 代码块之后最后输出一行:self-check: 1 ✓ · 2 ✓ · 3 ✓ · 4 ✓ · 5 ✓ · 6 ✓ · 7 ✓,未通过的项用 ✗ 并附一句原因。
"use client";
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
type CSSProperties,
} from "react";
import { TOAST_EXIT } from "@/content/effects/toast-exit/constants";
const T = TOAST_EXIT.toast;
/**
* Two keyframes: the toast enters from the left of its resting spot (the
* "right side" is expressed by moving in from -travel and leaving towards
* +travel) and leaves through the same axis in the opposite direction.
*/
const STYLES = `@keyframes toast-exit-in {
from {
opacity: 0;
transform: translateX(calc(-1 * var(--toast-travel, ${TOAST_EXIT.defaults.travel}px)));
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes toast-exit-out {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(var(--toast-travel, ${TOAST_EXIT.defaults.travel}px));
}
}
.toast-exit-item {
animation: toast-exit-in var(--toast-enter-duration, ${TOAST_EXIT.defaults.enterMs}ms) ${TOAST_EXIT.enterEase} both;
}
.toast-exit-item[data-exiting="true"] {
pointer-events: none;
animation: toast-exit-out var(--toast-exit-duration, ${TOAST_EXIT.defaults.exitMs}ms) ${TOAST_EXIT.exitEase} both;
}
@media (prefers-reduced-motion: reduce) {
.toast-exit-item {
animation: none !important;
opacity: 1 !important;
transform: none !important;
}
.toast-exit-item[data-exiting="true"] {
animation: toast-exit-out 1ms linear both !important;
}
}`;
type CSSVarStyle = CSSProperties & Record<`--${string}`, string>;
export type ToastKind = "success" | "error";
export type ToastStackHandle = {
/** Push a toast. Repeated text that is already on screen is ignored. */
push: (message: string, kind?: ToastKind) => void;
};
export type ToastStackProps = {
enterMs?: number;
exitMs?: number;
travel?: number;
/** How long a toast stays before leaving on its own, in ms. */
holdMs?: number;
/** Maximum number of simultaneous toasts. */
max?: number;
/** Accessible label of every close button. */
closeLabel?: string;
className?: string;
};
type ToastEntry = {
id: number;
message: string;
/** Frozen copy of the text, taken the moment the exit starts. */
snapshot: string | null;
kind: ToastKind;
slot: number;
exiting: boolean;
};
function ToastIcon({ kind }: { kind: ToastKind }) {
const color =
kind === "error" ? "border-[#ff8b7a]/40 text-[#ff8b7a]" : "border-[#c9f45a]/40 text-[#c9f45a]";
return (
<span
aria-hidden
className={`grid shrink-0 place-items-center rounded-full border ${color}`}
style={{ width: T.icon, height: T.icon }}
>
<svg
viewBox="0 0 16 16"
className="h-3 w-3"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
>
{kind === "error" ? (
<>
<path d="M8 4.4v4.2" />
<circle cx="8" cy="11.4" r="0.9" fill="currentColor" stroke="none" />
</>
) : (
<path d="M3.6 8.4l2.8 2.8 6-6" />
)}
</svg>
</span>
);
}
export const ToastStack = forwardRef<ToastStackHandle, ToastStackProps>(
function ToastStack(
{
enterMs = TOAST_EXIT.defaults.enterMs,
exitMs = TOAST_EXIT.defaults.exitMs,
travel = TOAST_EXIT.defaults.travel,
holdMs = TOAST_EXIT.defaults.holdMs,
max = TOAST_EXIT.maxStack,
closeLabel = "Dismiss",
className,
},
ref,
) {
const [toasts, setToasts] = useState<ToastEntry[]>([]);
// One mutable list + one React state: holds and exits run from timers and
// must always read the latest stack, never a stale render snapshot.
const listRef = useRef<ToastEntry[]>([]);
const nextId = useRef(0);
const holdTimers = useRef(new Map<number, number>());
const exitTimers = useRef(new Map<number, number>());
const commit = (next: ToastEntry[]) => {
listRef.current = next;
setToasts(next);
};
const clearHold = (id: number) => {
const timer = holdTimers.current.get(id);
if (timer !== undefined) {
window.clearTimeout(timer);
holdTimers.current.delete(id);
}
};
const clearExit = (id: number) => {
const timer = exitTimers.current.get(id);
if (timer !== undefined) {
window.clearTimeout(timer);
exitTimers.current.delete(id);
}
};
const remove = (id: number) => {
clearHold(id);
clearExit(id);
const list = listRef.current;
if (!list.some((toast) => toast.id === id)) return;
commit(list.filter((toast) => toast.id !== id));
};
const beginExit = (id: number) => {
const list = listRef.current;
const toast = list.find((entry) => entry.id === id);
if (!toast || toast.exiting) return;
clearHold(id);
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reduced) {
// Direct final state, no animation at all.
remove(id);
return;
}
// Snapshot the text first: whatever the owner does to its data after
// this point cannot empty the row while it flies out.
commit(
list.map((entry) =>
entry.id === id
? { ...entry, exiting: true, snapshot: entry.message }
: entry,
),
);
// The animationend handler unmounts normally; this timer is only a
// safety net for environments that never fire animation events.
const fallback = window.setTimeout(() => remove(id), exitMs + 400);
exitTimers.current.set(id, fallback);
};
const push = (message: string, kind: ToastKind = "success") => {
const text = message.trim();
if (!text) return;
const list = listRef.current;
if (list.length >= max) return;
if (list.some((toast) => toast.message === text)) return;
const used = new Set(list.map((toast) => toast.slot));
let slot = 0;
while (used.has(slot) && slot < max) slot += 1;
if (slot >= max) return;
const id = nextId.current;
nextId.current += 1;
commit([
...list,
{ id, message: text, snapshot: null, kind, slot, exiting: false },
]);
const timer = window.setTimeout(() => {
holdTimers.current.delete(id);
beginExit(id);
}, holdMs);
holdTimers.current.set(id, timer);
};
useImperativeHandle(ref, () => ({ push }));
useEffect(() => {
return () => {
holdTimers.current.forEach((timer) => window.clearTimeout(timer));
holdTimers.current.clear();
exitTimers.current.forEach((timer) => window.clearTimeout(timer));
exitTimers.current.clear();
};
}, []);
return (
<div
className={`pointer-events-none absolute inset-0 overflow-hidden ${className ?? ""}`}
>
<style>{STYLES}</style>
{toasts.map((toast) => {
const vars: CSSVarStyle = {
"--toast-travel": `${travel}px`,
"--toast-enter-duration": `${enterMs}ms`,
"--toast-exit-duration": `${exitMs}ms`,
};
const error = toast.kind === "error";
return (
<div
key={toast.id}
role={error ? "alert" : "status"}
data-toast={toast.kind}
data-exiting={toast.exiting ? "true" : "false"}
className={`toast-exit-item pointer-events-auto absolute flex items-center gap-2.5 border shadow-[0_12px_28px_rgba(0,0,0,0.35)] ${
error
? "border-[#ff8b7a]/25 bg-[#1a1213]/95"
: "border-[#c9f45a]/25 bg-[#12151a]/95"
}`}
style={{
...vars,
width: T.width,
height: T.height,
maxWidth: "calc(100% - 24px)",
right: T.inset,
bottom: T.inset + toast.slot * (T.height + T.gap),
borderRadius: T.radius,
paddingInline: T.paddingX,
}}
onAnimationEnd={(event) => {
if (event.animationName === "toast-exit-out") remove(toast.id);
}}
>
<ToastIcon kind={toast.kind} />
<p
className="min-w-0 flex-1 truncate text-[#edeef0]"
style={{ fontSize: T.titleSize }}
>
{toast.snapshot ?? toast.message}
</p>
<button
type="button"
aria-label={closeLabel}
onClick={() => beginExit(toast.id)}
className="grid h-6 w-6 shrink-0 place-items-center rounded-full text-[#9a9ca1] transition-colors hover:bg-white/10 hover:text-[#edeef0]"
>
<svg
viewBox="0 0 16 16"
className="h-3 w-3"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
aria-hidden
>
<path d="M4.2 4.2l7.6 7.6M11.8 4.2l-7.6 7.6" />
</svg>
</button>
</div>
);
})}
</div>
);
},
);
构建时从真实文件读取 —— 这是你真的在看的那份代码,不是另写的示例。
技术规格
右下角的浮动提示:入场从停靠点内侧 translateX(-16px) 处淡入并滑到位置(220ms,cubic-bezier(0.16, 1, 0.3, 1));停留 2600ms 后自动退场,或点关闭立刻退场。退场前先给当前文案拍快照,再沿同一轴滑向 translateX(+16px) 并淡出(190ms,cubic-bezier(0.4, 0, 1, 1)),期间 pointer-events:none 且带 data-exiting,animationend 之后才真正卸载。最多 3 条垂直堆叠,每条占据固定槽位,谁也不会把别人推着位移;同一段文案还在屏上时不会重复入栈。
- 入场时长
- 220 ms
- 退场时长
- 190 ms
- 位移距离
- 16 px
- 停留时长
- 2600 ms
- 入场 / 退场位移
- -16px → 0 → +16px
- 单条生命周期
- 3010ms
- 堆叠上限 / 槽距
- 3 / 52px
- 缓动
- cubic-bezier(0.16, 1, 0.3, 1) · cubic-bezier(0.4, 0, 1, 1)
验收清单
提示词要求 AI 在输出末尾逐条自检。
- 入场时长、退场时长、位移距离、停留时间都与当前参数一致(默认 220ms / 190ms / 16px / 2600ms)。
- 在入场与退场各自的 60–80ms 采样,toast 的 opacity 与 translateX 都严格处于起止状态之间,没有跳变或先闪出最终态。
- 退场由 animationend 触发卸载:退场期间带 data-exiting、pointer-events:none,动画结束后节点才从 DOM 移除,不残留 transform。
- 其它 toast 的 boundingBox 在整个入场 / 退场过程中完全不变,堆叠槽位固定,没有任何一条被推着位移。
- 同一段文案还在屏上时不会重复入栈;退场前已把文案快照下来,退场过程中文案不会变空。
- 推送按钮与关闭按钮都能 Tab 聚焦、Enter / Space 触发;关闭按钮有 aria-label,焦点环可见(2px lime,外扩 3px)。
- 控制台 0 报错 / 警告;prefers-reduced-motion 下不播动画、直接最终态;卸载后没有残留定时器。
待回归验证提示词与演示已同源生成;尚未在真实 Cursor / Claude Code 项目中做回归。
提示词如何还原这个效果
不是把效果描写一遍,而是把效果写成一份可执行的工程需求。
- 数字规格
- 每个像素、每个时间参数都来自同一个参数源——滑块改什么,提示词就写什么。
- 文件契约
- 只允许新建指定文件、指定依赖白名单,避免它顺手改你的全局样式或装一堆包。
- 验收清单
- 提示词末尾强制 AI 自检布局位移、键盘可用、reduced-motion 等 7 项。