PB11▸🪟 Overlay · 覆蓋層▸對話框
Morphing Dialog
變形對話框
難度 ★★★★★標籤 layout-id · 變形 · Framer3 min read
模式簡述
Morphing Dialog:列表卡片點擊後「變形」展開成 modal。同一個物件保留視覺連續性。Apple Music、Music.app 大量使用。
Claude Code Prompt
參考:PB11 morphing-dialog 組件:MorphingDialog.tsx Morphing Dialog:列表卡片點擊後「變形」展開成 modal。同一個物件保留視覺連續性。Apple Music、Music.app 大量使用。 限制: - layoutId 不唯一 - 內容 layout 跟著動 - 沒做 backdrop 漸入
完整程式碼
"use client";
import { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X } from "lucide-react";
type State = "card" | "expanded";
export function MorphingDialog({ state = "card" }: { state?: State }) {
const [open, setOpen] = useState(state === "expanded");
useEffect(() => setOpen(state === "expanded"), [state]);
return (
<div className="relative grid h-[320px] w-full max-w-md place-items-center">
<AnimatePresence>
{!open && (
<motion.button
layoutId="card-1"
onClick={() => setOpen(true)}
className="flex w-56 items-center gap-3 rounded-xl border border-border bg-background/50 p-3 text-left hover:border-lab-accent"
>
<motion.div
layoutId="card-1-img"
className="h-12 w-12 rounded-lg bg-gradient-to-br from-lab-accent to-pink-500"
/>
<div>
<motion.p layoutId="card-1-title" className="text-sm font-medium">
Frontend Lab
</motion.p>
<motion.p
layoutId="card-1-desc"
className="text-[11px] text-muted-foreground"
>
點擊展開
</motion.p>
</div>
</motion.button>
)}
{open && (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setOpen(false)}
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
/>
<motion.div
layoutId="card-1"
className="absolute left-1/2 top-1/2 w-[88%] -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-border bg-background p-5 shadow-2xl"
>
<motion.div
layoutId="card-1-img"
className="h-32 w-full rounded-xl bg-gradient-to-br from-lab-accent to-pink-500"
/>
<motion.p layoutId="card-1-title" className="mt-3 text-base font-medium">
Frontend Lab
</motion.p>
<motion.p
layoutId="card-1-desc"
className="text-xs text-muted-foreground"
style={{ fontFamily: "var(--font-zh)" }}
>
點擊展開
</motion.p>
<motion.p
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0, transition: { delay: 0.15 } }}
className="mt-3 text-xs text-muted-foreground"
style={{ fontFamily: "var(--font-zh)" }}
>
Framer Motion 的 `layoutId` 讓相同 id 的元素之間自動補間動畫。
</motion.p>
<button
onClick={() => setOpen(false)}
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
}何時用
- ✅ 列表項點開展示詳情(playlist、product card)
- ✅ 從小卡到大頁的詳情過渡
- ✅ 想營造高質感的 brand site
何時別用
- ❌ 列表項目本來就在卡片以外的位置(變形視覺斷裂)
- ❌ 卡片之間還會切換(變形完還要再展開另一個就尷尬)
- ❌ 工具型介面(過度華麗反而干擾任務)
我踩過的坑
-
layoutId 不唯一:兩個 motion 同名會搶 layout,閃爍。每個卡片用
id-XXX(XXX 為實際 id)。 -
內容 layout 跟著動:文字也跟著縮放會糊掉。固定文字位置,只動容器。
-
沒做 backdrop 漸入:直接黑掉很跳。背景 opacity 跟著 transition 一起。
相關模式
- PB01
- PB10