弹窗入场
Modal Entrance遮罩淡入,面板轻抬缩放;关闭比打开更快
完整版:适合从零新建组件,规格、契约、验收全都在。
实时预览
布局Esc 关闭 · Tab 在弹窗内循环 · 关闭后焦点回到「打开弹窗」
参数
边调边看,提示词同步更新。
角色
你是一名资深前端工程师,工作在现有的项目中,技术栈是 React 19 + TypeScript + Tailwind CSS v4。只实现下面这一个微交互:不要加额外功能,不要顺手重构周围代码。
任务
实现弹窗入场:遮罩先用 180ms 线性淡入,同时面板从 translateY(8px)、scale(0.98) 与 opacity 0 过渡到最终态,用时 220ms,曲线 cubic-bezier(0.2, 0.8, 0.2, 1)。关闭时两者反向播放,时长压缩到 150ms;焦点必须在打开第一帧就进入面板。
文件契约
- 只新建一个文件:components/effects/ModalEntrance.tsx。
- 依赖:无需额外依赖。只用 React 与 CSS transition,不要引入动画库。
- 不要修改 package.json、全局 CSS、Tailwind 配置或任何其他已有文件。
实现要点
- 文件顶部加 "use client"。导出 ModalEntrance,props 为 open、onClose、title、description、children、overlayMs、panelMs、closeMs、offset、scale、closeLabel、className。
- 状态机 rendered → active:open 变 true 时先以关闭态挂载,读一次 offsetWidth 强制 reflow,再切到开启态,过渡才会真正发生。
- 遮罩内联 transition 为 opacity 180ms linear,面板为 opacity/transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);关闭时两者都换成 closeMs。
- 关闭在面板的 transitionend 里卸载节点;打开后的第一帧用 panel.focus() 把焦点移进面板,并把 Esc / Tab 的 document 监听立即挂上。
- 遮罩点击只在 event.target === event.currentTarget 时关闭;关闭或卸载前把焦点还给打开前的 document.activeElement。
规格 —— 精确数值,不许四舍五入或“优化”
- 遮罩:用 position: absolute; inset: 0 铺满最近的定位祖先容器,背景 rgba(7, 8, 10, 0.62);opacity 0 → 1,用时 180ms,缓动 linear,不做位移与缩放。
- 面板:opacity 0 → 1、translateY(8px) → 0、scale(0.98) → 1,用时 220ms,曲线 cubic-bezier(0.2, 0.8, 0.2, 1);面板在遮罩内水平垂直居中。
- 关闭:完全反向播放同一组属性,遮罩与面板都改用 150ms——必须比打开更短。
- 面板视觉:最大宽度 300px,背景 #12151a,1px 边框 rgba(237, 238, 240, 0.1),圆角 18px,内边距 20px,阴影 0 24px 60px rgba(0, 0, 0, 0.55)。
- 作用域:遮罩绝对定位在宿主容器内部,禁止用 position: fixed 覆盖整页;Demo 在卡片里演示这一点。
- 焦点:打开后的第一帧就把焦点移进面板(面板 tabIndex={-1})并立即启用焦点陷阱;Esc 与点击遮罩关闭;关闭后焦点回到打开前的元素。
- ARIA:role="dialog"、aria-modal="true"、aria-labelledby 指向标题、aria-describedby 指向描述(这两个节点存在时)。
- prefers-reduced-motion: reduce 时两个时长都压到 ≤1ms,当帧直接到最终态,关闭立即卸载。
- 只允许动画 transform 与 opacity。
行为
- 状态机:closed --open=true--> mounting(提交关闭态:遮罩 opacity 0,面板 opacity 0 / translateY(offset) / scale(scale))--强制 reflow--> open(遮罩与面板过渡开始,焦点移入,陷阱生效)--open=false--> closing(同一组属性反向播放,时长用 closeMs)--面板 transitionend--> closed(卸载节点,焦点归还)。
- 打开:先渲染关闭态,读一次 offsetWidth 强制 reflow,再切到开启态,过渡才会真正发生;不允许跳过这次 reflow。
- 关闭:把遮罩与面板的过渡时长都换成 closeMs 并反向写目标值;在面板的 transitionend(opacity 或 transform)里卸载。点击遮罩只在 event.target === event.currentTarget 时关闭。
- 焦点:打开后的第一帧聚焦面板(早于动画结束);Tab 在面板内首尾包裹循环;Esc 关闭;关闭后把焦点还给打开前记录的元素。
- 关闭期间保持弹窗挂载以完整播放反向过渡;prefers-reduced-motion 生效时可以立即卸载。
- 任何状态下 ARIA 都要成立:role="dialog"、aria-modal="true"、由标题 labelled、由描述 described。
- 卸载时清理监听与未完成的过渡;不要在 document 上留下监听。
禁止
- 不要改变交互语义、可访问名称,也不要改变焦点进入面板的时机——必须第一帧就移入,不能等 transitionend。
- 不要用动画改变 width / height / top / left / margin / padding / box-shadow;只动 transform 与 opacity。
- 不要用链式 setTimeout 串打开 / 关闭时序;用过渡生命周期与 transitionend。
- 遮罩不要做 translate / scale 动画,面板不要用 max-height / top 过渡。
- 不要留 TODO、不要写 // 其余代码、不要用 any、不要 console.log、不要留注释掉的实验代码。
- 不要把组件包进页面、布局、演示壳或 Provider;不要让遮罩 fixed 覆盖整个文档。
- 不要改动全局 CSS、Tailwind 配置或上面文件之外的任何文件。
- 不要引入任何动画库,CSS transition 足够。
验收 —— 回答前逐项自查
1. 遮罩 opacity 过渡 180ms linear,面板 opacity / transform 过渡 220ms cubic-bezier(0.2, 0.8, 0.2, 1);关闭两者都压缩到 150ms 并更快结束。
2. 打开后 60–80ms 采样:遮罩 opacity 严格介于 0 与 1 之间,面板 transform 处于 translateY(8px)/scale(0.98) 与归零态之间。
3. 过渡结束后面板 opacity 为 1、transform 归零,节点上没有残留的内联 left/top/height 或未清理的监听。
4. 打开、关闭、中途反向再打开时,页面容器 boundingBox 不抖动,卡片外内容不位移。
5. prefers-reduced-motion: reduce 时打开与关闭都 ≤1ms 且直接到最终态,不出现中间帧。
6. 键盘可以完成打开、面板内 Tab 循环与 Esc 关闭,关闭后焦点回到触发按钮,焦点环始终可见。
7. 控制台 0 报错或警告:无 React key/hydration 警告,无缺失 transition 目标,无未处理的 DOM 异常。
输出
- 用一个代码块输出完整文件内容,不要 diff、不要省略号,代码块之前不要解释。
- 如果缺少必要依赖,先停下来问我,不要擅自安装。
- 代码块之后最后输出一行:self-check: 1 ✓ · 2 ✓ · 3 ✓ · 4 ✓ · 5 ✓ · 6 ✓ · 7 ✓,未通过的项用 ✗ 并附一句原因。
"use client";
import {
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type MouseEvent,
type ReactNode,
type TransitionEvent,
} from "react";
import { MODAL_ENTRANCE } from "@/content/effects/modal-entrance/constants";
const EASE = MODAL_ENTRANCE.easing.css;
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
const STYLES = `
.fc-modal-panel:focus-visible {
outline: none;
}
.fc-modal-close {
transition: background-color 120ms ease, color 120ms ease;
}
.fc-modal-close:hover {
background: ${MODAL_ENTRANCE.closeButton.hoverBackground};
color: ${MODAL_ENTRANCE.panel.titleColor};
}
.fc-modal-close:focus-visible {
outline: 2px solid ${MODAL_ENTRANCE.closeButton.focusColor};
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.fc-modal-panel {
transition-duration: 0.001ms !important;
}
}
`;
function usePrefersReducedMotion(): boolean {
const [reduced, setReduced] = useState(false);
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setReduced(query.matches);
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
return reduced;
}
function getFocusable(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
}
export type ModalEntranceProps = {
open: boolean;
onClose: () => void;
title?: string;
description?: string;
children?: ReactNode;
overlayMs?: number;
panelMs?: number;
closeMs?: number;
offset?: number;
scale?: number;
closeLabel?: string;
dialogLabel?: string;
className?: string;
};
const useIsomorphicLayoutEffect =
typeof window === "undefined" ? useEffect : useLayoutEffect;
export function ModalEntrance({
open,
onClose,
title,
description,
children,
overlayMs = MODAL_ENTRANCE.defaults.overlayMs,
panelMs = MODAL_ENTRANCE.defaults.panelMs,
closeMs = MODAL_ENTRANCE.defaults.closeMs,
offset = MODAL_ENTRANCE.defaults.offset,
scale = MODAL_ENTRANCE.defaults.scale,
closeLabel = "Close dialog",
dialogLabel = "Dialog",
className,
}: ModalEntranceProps) {
const baseId = useId();
const titleId = `${baseId}-title`;
const descriptionId = `${baseId}-description`;
const reduced = usePrefersReducedMotion();
const [rendered, setRendered] = useState(open);
const [active, setActive] = useState(false);
const panelRef = useRef<HTMLDivElement | null>(null);
const scrimRef = useRef<HTMLDivElement | null>(null);
const openerRef = useRef<HTMLElement | null>(null);
const closingRef = useRef(false);
const onCloseRef = useRef(onClose);
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
const duration = (value: number) => (reduced ? 0.001 : value);
/** mount the scrim + panel first, then flip to the open state after one forced reflow */
useIsomorphicLayoutEffect(() => {
if (open && !rendered) setRendered(true);
}, [open, rendered]);
useIsomorphicLayoutEffect(() => {
if (!open || !rendered) return;
closingRef.current = false;
const panel = panelRef.current;
const scrim = scrimRef.current;
if (panel) void panel.offsetWidth;
if (scrim) void scrim.offsetWidth;
setActive(true);
}, [open, rendered]);
/** focus enters on the first rendered frame and the opener is remembered before focus moves */
useIsomorphicLayoutEffect(() => {
if (!open || !rendered) return;
const panel = panelRef.current;
if (!panel) return;
const current = document.activeElement;
if (current instanceof HTMLElement && panel.contains(current)) return;
openerRef.current = current instanceof HTMLElement ? current : null;
panel.focus();
}, [open, rendered]);
/** reverse transition on close, unmount on the panel transitionend (or immediately when reduced) */
useEffect(() => {
if (open || !rendered) return;
closingRef.current = true;
setActive(false);
const opener = openerRef.current;
openerRef.current = null;
if (opener && document.contains(opener)) opener.focus();
if (reduced) {
closingRef.current = false;
setRendered(false);
return;
}
// watchdog in case transitionend never fires (hidden tab, cancelled transition)
const timer = window.setTimeout(() => {
if (!closingRef.current) return;
closingRef.current = false;
setRendered(false);
}, closeMs + 120);
return () => window.clearTimeout(timer);
}, [open, rendered, reduced, closeMs]);
/** Escape closes, Tab wraps inside the panel — active from the first frame, not after the animation */
useEffect(() => {
if (!open || !rendered) return;
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
onCloseRef.current();
return;
}
if (event.key !== "Tab") return;
const panel = panelRef.current;
if (!panel) return;
const focusables = getFocusable(panel);
if (focusables.length === 0) {
event.preventDefault();
panel.focus();
return;
}
const first = focusables[0];
const last = focusables[focusables.length - 1];
const current = document.activeElement;
if (!first || !last || !current) return;
if (event.shiftKey) {
if (current === first || !panel.contains(current)) {
event.preventDefault();
last.focus();
}
} else if (current === last || !panel.contains(current)) {
event.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", handleKeyDown, true);
return () => document.removeEventListener("keydown", handleKeyDown, true);
}, [open, rendered]);
const handleScrimClick = (event: MouseEvent<HTMLDivElement>) => {
if (event.target !== event.currentTarget) return;
onCloseRef.current();
};
const handlePanelTransitionEnd = (event: TransitionEvent<HTMLDivElement>) => {
if (event.target !== event.currentTarget) return;
if (!closingRef.current) return;
if (event.propertyName !== "opacity" && event.propertyName !== "transform") return;
closingRef.current = false;
setRendered(false);
};
if (!rendered) return null;
const scrimMs = duration(active ? overlayMs : closeMs);
const panelMsValue = duration(active ? panelMs : closeMs);
return (
<div
ref={scrimRef}
role="presentation"
className={["fc-modal-scrim", className].filter(Boolean).join(" ")}
onClick={handleScrimClick}
style={{
position: "absolute",
inset: 0,
zIndex: 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 16,
background: MODAL_ENTRANCE.overlay.background,
opacity: active ? 1 : 0,
transition: `opacity ${scrimMs}ms ${MODAL_ENTRANCE.easing.overlay}`,
pointerEvents: active ? "auto" : "none",
}}
>
<style>{STYLES}</style>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={title ? titleId : undefined}
aria-describedby={description ? descriptionId : undefined}
aria-label={title ? undefined : dialogLabel}
tabIndex={-1}
className="fc-modal-panel"
onTransitionEnd={handlePanelTransitionEnd}
style={{
position: "relative",
width: "100%",
maxWidth: MODAL_ENTRANCE.panel.width,
boxSizing: "border-box",
background: MODAL_ENTRANCE.panel.background,
border: `1px solid ${MODAL_ENTRANCE.panel.borderColor}`,
borderRadius: MODAL_ENTRANCE.panel.radius,
padding: MODAL_ENTRANCE.panel.padding,
boxShadow: MODAL_ENTRANCE.panel.shadow,
color: MODAL_ENTRANCE.panel.titleColor,
opacity: active ? 1 : 0,
transform: active
? "translateY(0px) scale(1)"
: `translateY(${offset}px) scale(${scale})`,
transition: `opacity ${panelMsValue}ms ${EASE}, transform ${panelMsValue}ms ${EASE}`,
willChange: "transform, opacity",
}}
>
<button
type="button"
className="fc-modal-close"
aria-label={closeLabel}
onClick={() => onCloseRef.current()}
style={{
position: "absolute",
top: 10,
right: 10,
display: "grid",
placeItems: "center",
width: MODAL_ENTRANCE.closeButton.size,
height: MODAL_ENTRANCE.closeButton.size,
border: 0,
borderRadius: 8,
background: "transparent",
color: MODAL_ENTRANCE.closeButton.color,
cursor: "pointer",
}}
>
<svg width="13" height="13" viewBox="0 0 14 14" aria-hidden="true">
<path
d="M2 2l10 10M12 2L2 12"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
</button>
{title ? (
<h2
id={titleId}
style={{
margin: 0,
paddingRight: 32,
fontSize: 15,
fontWeight: 600,
lineHeight: 1.4,
color: MODAL_ENTRANCE.panel.titleColor,
}}
>
{title}
</h2>
) : null}
{description ? (
<p
id={descriptionId}
style={{
margin: "6px 0 0",
fontSize: 12.5,
lineHeight: 1.6,
color: MODAL_ENTRANCE.panel.bodyColor,
}}
>
{description}
</p>
) : null}
{children ? <div style={{ marginTop: 16 }}>{children}</div> : null}
</div>
</div>
);
}
构建时从真实文件读取 —— 这是你真的在看的那份代码,不是另写的示例。
技术规格
打开时遮罩以 180ms 线性淡入,面板从 opacity 0、translateY(8px)、scale(0.98) 过渡到最终态,用时 220ms,曲线 cubic-bezier(0.2, 0.8, 0.2, 1)。关闭时同一组属性反向播放,但压缩到 150ms,比打开更短,打断感更轻。焦点在打开后的第一帧就进入面板并立即启用键盘陷阱,不等动画;Esc 或点击遮罩关闭后,焦点回到触发按钮。动画只改 transform 与 opacity,reduced-motion 下几乎瞬时。演示把弹窗放在卡片的绝对定位容器里,不会覆盖整站。
- 遮罩时长
- 180 ms
- 面板入场时长
- 220 ms
- 关闭时长
- 150 ms
- 起始位移
- 8 px
- 起始缩放
- 0.98 ×
- 打开总时长
- 220 ms
- 关闭 / 打开
- 68%
- 起始状态
- 8px · 0.98×
验收清单
提示词要求 AI 在输出末尾逐条自检。
- 遮罩 opacity 过渡 180ms linear,面板 opacity / transform 过渡 220ms cubic-bezier(0.2, 0.8, 0.2, 1);关闭两者都压缩到 150ms 并更快结束。
- 打开后 60–80ms 采样:遮罩 opacity 严格介于 0 与 1 之间,面板 transform 处于 translateY(8px)/scale(0.98) 与归零态之间。
- 过渡结束后面板 opacity 为 1、transform 归零,节点上没有残留的内联 left/top/height 或未清理的监听。
- 打开、关闭、中途反向再打开时,页面容器 boundingBox 不抖动,卡片外内容不位移。
- prefers-reduced-motion: reduce 时打开与关闭都 ≤1ms 且直接到最终态,不出现中间帧。
- 键盘可以完成打开、面板内 Tab 循环与 Esc 关闭,关闭后焦点回到触发按钮,焦点环始终可见。
- 控制台 0 报错或警告:无 React key/hydration 警告,无缺失 transition 目标,无未处理的 DOM 异常。
待回归验证提示词与演示已同源生成;尚未在真实 Cursor / Claude Code 项目中做回归。
提示词如何还原这个效果
不是把效果描写一遍,而是把效果写成一份可执行的工程需求。
- 数字规格
- 每个像素、每个时间参数都来自同一个参数源——滑块改什么,提示词就写什么。
- 文件契约
- 只允许新建指定文件、指定依赖白名单,避免它顺手改你的全局样式或装一堆包。
- 验收清单
- 提示词末尾强制 AI 自检布局位移、键盘可用、reduced-motion 等 7 项。