Frontend Lab
← 回到所有模式
PB11🪟 Overlay · 覆蓋層對話框

Morphing Dialog

變形對話框

難度 ★★★★標籤 layout-id · 變形 · Framer3 min read

模式簡述

Morphing Dialog:列表卡片點擊後「變形」展開成 modal。同一個物件保留視覺連續性。Apple Music、Music.app 大量使用。

縮圖|卡片預設狀態

Claude Code Prompt

💬 Claude Code Prompt
參考:PB11 morphing-dialog
組件:MorphingDialog.tsx

Morphing Dialog:列表卡片點擊後「變形」展開成 modal。同一個物件保留視覺連續性。Apple Music、Music.app 大量使用。

限制:
- layoutId 不唯一
- 內容 layout 跟著動
- 沒做 backdrop 漸入

完整程式碼

MorphingDialog.tsx
"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

何時別用

  • ❌ 列表項目本來就在卡片以外的位置(變形視覺斷裂)
  • ❌ 卡片之間還會切換(變形完還要再展開另一個就尷尬)
  • ❌ 工具型介面(過度華麗反而干擾任務)

我踩過的坑

  1. layoutId 不唯一:兩個 motion 同名會搶 layout,閃爍。每個卡片用 id-XXX(XXX 為實際 id)。

  2. 內容 layout 跟著動:文字也跟著縮放會糊掉。固定文字位置,只動容器。

  3. 沒做 backdrop 漸入:直接黑掉很跳。背景 opacity 跟著 transition 一起。

相關模式

  • PB01
  • PB10

搜尋

按 ⌘K 隨時開啟