Morphing Dialog
點擊卡片後,卡片以共享元素動畫無縫放大變形為置中對話框,關閉時縮回原卡片位置。
載入預覽⋯
安裝
npx shadcn@latest add https://webberui.com/r/morphing-dialog.json或在 components.json 設定 registries 後,改用 @webberui/morphing-dialog 安裝。
安裝依賴後,從 registry JSON(/r/morphing-dialog.json 的 files[0].content)複製 morphing-dialog.tsx 原始碼到你的 components/ui/ 目錄:
npm install motion lucide-react clsx tailwind-merge使用
import {
MorphingDialog,
MorphingDialogTrigger,
MorphingDialogContent,
MorphingDialogClose,
} from "@/components/ui/morphing-dialog";
<MorphingDialog>
<MorphingDialogTrigger className="w-52 rounded-xl border border-neutral-200 bg-white p-4 dark:border-neutral-800 dark:bg-neutral-900">
<h3 className="text-sm font-semibold">卡片標題</h3>
<p className="text-xs text-neutral-500">點擊展開更多內容</p>
</MorphingDialogTrigger>
<MorphingDialogContent className="max-w-sm p-6">
<MorphingDialogClose />
<h3 className="text-lg font-semibold">對話框標題</h3>
<p className="pt-2 text-sm text-neutral-600 dark:text-neutral-300">
卡片放大變形之後可以承載更完整的內容。
</p>
</MorphingDialogContent>
</MorphingDialog>;MorphingDialogTrigger 渲染為可點擊的卡片容器,MorphingDialogContent 是開啟後置中顯示的對話框。兩者共用同一個 layoutId,點擊時卡片會無縫放大 morph 成對話框,關閉時再縮回原卡片位置。
傳入 open 與 onOpenChange 可切換為受控模式:
const [open, setOpen] = React.useState(false);
<MorphingDialog open={open} onOpenChange={setOpen}>
{/* ... */}
</MorphingDialog>;Props
MorphingDialog
| Prop | 型別 | 預設值 | 說明 |
|---|---|---|---|
children | React.ReactNode | — | 內容,需包含 MorphingDialogTrigger 與 MorphingDialogContent |
open | boolean | — | 受控模式的開啟狀態,不傳則由元件內部管理 |
onOpenChange | (open: boolean) => void | — | 開啟狀態變更時的回呼(受控與非受控皆會觸發) |
MorphingDialogTrigger
| Prop | 型別 | 預設值 | 說明 |
|---|---|---|---|
children | React.ReactNode | — | 卡片內容 |
className | string | — | 追加到卡片容器的 className |
MorphingDialogContent
| Prop | 型別 | 預設值 | 說明 |
|---|---|---|---|
children | React.ReactNode | — | 對話框內容 |
className | string | — | 追加到對話框容器的 className |
MorphingDialogClose
| Prop | 型別 | 預設值 | 說明 |
|---|---|---|---|
children | React.ReactNode | lucide-react X 圖示 | 自訂按鈕內容 |
className | string | — | 追加到關閉按鈕的 className |
細節
- layoutId FLIP 原理:
MorphingDialogTrigger與MorphingDialogContent共用一個由useId產生的layoutId。開啟時 content 掛載,Motion 快照卡片的邊界框,讓對話框從卡片位置以 transform 過渡到置中的最終位置(FLIP);關閉時 content 在AnimatePresence內退場,把位置交還給仍掛載的卡片,反向 morph 縮回原位。 - 內容交叉淡接:共享的是外框,卡片與對話框的內部內容可以完全不同——Motion 對共用
layoutId的兩個元素會自動 crossfade,morph 期間兩側內容交叉淡接。 - 比例變形:FLIP 以 scale 內插兩個尺寸不同的盒子,morph 期間內容與圓角會暫時變形,動畫結束後恢復精確排版。這是共享元素動畫的固有特性;讓卡片與對話框的長寬比、圓角相近可以減輕變形感。
- Portal 到 body:對話框與遮罩經
createPortal渲染到document.body,避免祖先的overflow裁切或transform建立 containing block 影響fixed定位與置中。 - 退場同步:遮罩淡出的 duration 與 layout morph 的 duration 相同,關閉時對話框縮回與遮罩淡出同步結束,交接不斷拍。
- 受控模式:傳入
open即切換為受控,元件不再改動內部狀態;onOpenChange在受控與非受控下都會觸發,可用來監聽開關事件。
可及性
- content 帶
role="dialog"與aria-modal="true";開啟時焦點移入對話框(tabIndex={-1}),關閉後焦點歸還給原本的卡片 - 卡片為
role="button"、tabIndex={0},支援 Enter / Space 鍵開啟,並帶aria-haspopup="dialog"與aria-expanded狀態 - 按 Esc 或點擊遮罩皆可關閉;開啟期間
body設為overflow: hidden鎖住背景捲動,關閉時恢復原值 - 使用者系統開啟「減少動態效果」時,layout 位移歸零、morph 退化為快速淡入淡出,不產生任何位移感
- 元件走輕量路線,未內建 focus trap;若對話框內有多個互動元素且需要完整的鍵盤焦點循環,建議自行搭配 focus trap 方案