diw_craft 卡片点击展开为模态弹窗的 FLIP 动画实现:双 Context 架构、阶段机、弹簧配置、骨架屏、无障碍与移动端策略
本文档记录 diw_craft 中卡片点击展开为模态弹窗的 FLIP 过渡动画系统的完整实现方案。
系统采用三层架构:CardModalContext(状态管理)、FeedCard(卡片入口)、CardModal(弹窗渲染)。Framer Motion 的 layoutId 机制自动计算两个 DOM 元素之间的 FLIP 过渡,零手动位置计算。
| 文件 | 职责 | 关键导出 |
|---|---|---|
| CardModalContext.tsx | 双 Context 状态管理 | useOpenCardModal, useCardModalState, CardModalProvider |
| FeedCard.tsx | 三种卡片变体 + 点击入口 | FeedCard(image-text / image-only / text-only) |
| CardModal.tsx | 弹窗渲染 + 阶段机 | CardModal(MorphSkeleton, BodySkeleton) |
| body-prefetch.ts | LRU 缓存 + 悬停预取 | fetchBodyData, prefetchItem |
| body-scroll-lock.ts | 引用计数滚动锁 | lockBodyScroll, unlockBodyScroll |
| focus-trap.ts | Tab 循环焦点陷阱 | createFocusTrap |
为了避免 FeedCard 在弹窗打开/关闭时不必要地重渲染,状态管理拆分为两个独立 Context:
open() 函数引用。FeedCard 订阅此 Context,永不因弹窗状态变化而重渲染。selectedSlug、selectedData、close。仅 CardModal 订阅此 Context。// Context 1: 稳定的 open() 函数
const OpenCardModalContext = createContext<(data: ModalData) => void>(() => {});
// Context 2: 响应式弹窗状态
interface CardModalState {
selectedSlug: string | null;
selectedData: ModalData | null;
close: (opts?: { skipHistoryBack?: boolean }) => void;
}
const CardModalStateContext = createContext<CardModalState>({...});
额外提供 useCardModalSlug() hook,供 FeedCard 读取当前激活的 slug(用于隐藏被展开的卡片),只在 open/close 时触发重渲染。
handleClick 构建 ModalData 对象,调用 openModal(data)open() 设置 selectedSlug + selectedData,调用 lockBodyScroll(),pushState 推入浏览器历史AnimatePresence 检测到 isOpen 为 true → 挂载 motion.div(带 layoutId)layoutId → 自动执行 FLIP 过渡动画(~400ms)onLayoutAnimationComplete 触发 → setContentVisible(true) → 标题/摘要/封面 stagger 淡入fetchBodyData(slug) 并行加载正文 MDXhandleClose()setContentVisible(false) → 内容消失,容器变空白close() 清除 selectedSlug/selectedData,unlockBodyScroll(),history.back()AnimatePresence 检测到 isOpen 为 false → 空白容器 FLIP 缩回卡片位置onExitComplete → 清理 bodyState(此时 DOM 已安全卸载)检测方式:window.matchMedia("(hover: none) and (pointer: coarse)")
移动端不调用 e.preventDefault(),直接走原生 <a> 导航到 /items/{slug} 页面。返回时浏览器 bfcache 恢复完整页面状态(DOM、图片、滚动位置),体验等同于弹窗关闭。
contentVisible 是核心状态门控,控制弹窗内容何时出现:
| 阶段 | contentVisible | 可见内容 |
|---|---|---|
| FLIP 变形中(~400ms) | false | MorphSkeleton(标题/日期/正文占位条) |
| FLIP 完成 | true | 标题/摘要/标签 stagger 淡入 |
| API 加载中 | true | BodySkeleton(正文占位条) |
| API 加载完成 | true | MDX 渲染的正文 |
| 关闭 | false | 空白容器缩回 |
关键代码:
// 阶段重置 — 每次 open 或 slug 变化时触发
useEffect(() => {
if (isOpen) {
setContentVisible(false);
if (prefersReducedMotion) setContentVisible(true);
} else {
setContentVisible(false);
}
}, [isOpen, selectedSlug]);
// FLIP 完成 → 翻转门控
onLayoutAnimationComplete={() => {
if (prefersReducedMotion) return;
setContentVisible(true);
}}
为什么不需要竞态保护? AnimatePresence 的 key={selectedSlug} 确保每次打开都创建全新的组件实例。旧实例的回调不可能在新实例上触发,因此不需要 token 比较机制。
bodyState 在 onExitComplete 中清理:关闭时不清除 bodyState,而是等到退出动画完全结束后才重置。原因是 Framer Motion 的 layout projection 在退出动画期间仍需要读取 DOM 节点,提前清除会导致节点消失引发布局闪烁。
const LAYOUT_SPRING = {
type: "spring",
damping: 30, // 高阻尼 — 减少振荡,约 1 次过冲
stiffness: 220, // 高刚度 — 快速响应
mass: 0.8, // 低质量 — 动画更敏捷
};
// 等效时长约 400ms,过阻尼、无明显弹跳
const CONTENT_ENTER = { type: "spring", duration: 0.45, bounce: 0 };
const STAGGER_STEP = 0.07; // 70ms 等距级联
CONTENT_ENTER(等待 API 加载完成后触发)
每个元素都使用 { opacity: 0, y: 8, filter: "blur(4px)" } → { opacity: 1, y: 0, filter: "blur(0px)" } 的渐入效果。prefers-reduced-motion 处理window.matchMedia("(prefers-reduced-motion: reduce)").matchescontentVisible 在 useEffect 中立即设为 truetransition: none,因为 FM 的 layout spring 忽略 CSS 声明两阶段骨架屏提供持续的加载反馈,避免空白容器:
当 contentVisible === false 时显示,模拟标题/日期/正文区域:
h-8 w-3/5(圆角灰条)h-4 w-24.skeleton-pulse CSS 动画(1.2s 呼吸闪烁)当 contentVisible === true 但 bodyState.status === "loading" 时显示:
body-prefetch.ts 提供 LRU 缓存 + 防抖预取机制:
Map 结构,最大 50 条,FIFO 淘汰fetchBodyData(slug):返回 Promise<BodyResult>,自动去重并发请求(同一 slug 的请求复用同一个 Promise)prefetchItem(slug):150ms 防抖触发,由 FeedCard 的 onMouseEnter / onFocus 调用/api/item-body/{slug},返回序列化后的 MDX 源type BodyResult =
| { status: "ok"; source: MDXRemoteSerializeResult }
| { status: "protected" }
| { status: "notFound" }
| { status: "error" };
body-scroll-lock.ts 使用"固定 body"方案:
lockBodyScroll():记录当前 scrollY,设置 position: fixed; top: -scrollYunlockBodyScroll():恢复原始 position,scrollTo(0, savedScrollY)window.innerWidth - documentElement.clientWidth 计算原生滚动条宽度,锁定时添加等量 padding-right 防止布局抖动role="dialog" + aria-modal="true" + aria-label={title} 标记弹窗容器createFocusTrap() 限制 Tab/Shift+Tab 在弹窗内循环,300ms 延迟启动(等待 layout 动画稳定)previousFocusRef 保存当前焦点元素,关闭时恢复keydown 监听器调用 handleClose()aria-hidden + opacity: 0 + pointerEvents: "none",避免 FLIP 期间出现"双影"aria-label="查看 {title}" 提供有意义的辅助文本弹窗与浏览器历史状态同步:
window.history.pushState({ cardModal: slug, cardData: data }, "", "/items/{slug}")popstate 监听器检测到当前状态无 cardModal 字段 → 调用 close({ skipHistoryBack: true })popstate 检测到 e.state.cardModal + e.state.cardData → 重新打开弹窗/items/{slug} — 可分享、SEO 可爬取(作为独立页面的 fallback)layoutId:每张卡片仅一个 card-container-{slug}(容器级)。早期版本尝试嵌套标题 layoutId,导致双 FLIP 冲突("局部消失"问题)。移除后完全解决。AnimatePresence key={selectedSlug}:每次打开创建全新组件实例,消除陈旧回调竞争,无需 token 比较机制。contentVisible 由 onLayoutAnimationComplete 回调触发,不依赖固定延迟值,天然适配不同设备性能。onExitComplete 清理:bodyState 在退出动画完全结束后才重置,避免 projection 读取已卸载节点导致的布局闪烁。open(),CardModal 订阅响应式状态,消除不必要的重渲染。<a> 标签:使用 motion.a 而非 motion.div,移动端可走原生导航享受 bfcache。| 文件路径 | 说明 |
|---|---|
| src/components/CardModalContext.tsx | 双 Context Provider + open/close/popstate |
| src/components/FeedCard.tsx | 三种卡片变体 + layoutId + 移动端检测 |
| src/components/CardModal.tsx | 阶段机 + 弹簧配置 + 骨架屏 + 焦点陷阱 |
| src/lib/body-prefetch.ts | LRU 缓存 + 150ms 防抖预取 |
| src/lib/body-scroll-lock.ts | 引用计数 fixed-body 滚动锁 |
| src/lib/focus-trap.ts | Tab/Shift+Tab 焦点循环 |
| src/styles/feed.css | 卡片 + 弹窗样式(玻璃态、骨架屏动画) |