WebberUI

Morphing Dialog

點擊卡片後,卡片以共享元素動畫無縫放大變形為置中對話框,關閉時縮回原卡片位置。

載入預覽⋯

安裝

npx shadcn@latest add https://webberui.com/r/morphing-dialog.json

或在 components.json 設定 registries 後,改用 @webberui/morphing-dialog 安裝。

安裝依賴後,從 registry JSON(/r/morphing-dialog.jsonfiles[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 成對話框,關閉時再縮回原卡片位置。

傳入 openonOpenChange 可切換為受控模式:

const [open, setOpen] = React.useState(false);

<MorphingDialog open={open} onOpenChange={setOpen}>
  {/* ... */}
</MorphingDialog>;

Props

MorphingDialog

Prop型別預設值說明
childrenReact.ReactNode內容,需包含 MorphingDialogTriggerMorphingDialogContent
openboolean受控模式的開啟狀態,不傳則由元件內部管理
onOpenChange(open: boolean) => void開啟狀態變更時的回呼(受控與非受控皆會觸發)

MorphingDialogTrigger

Prop型別預設值說明
childrenReact.ReactNode卡片內容
classNamestring追加到卡片容器的 className

MorphingDialogContent

Prop型別預設值說明
childrenReact.ReactNode對話框內容
classNamestring追加到對話框容器的 className

MorphingDialogClose

Prop型別預設值說明
childrenReact.ReactNodelucide-react X 圖示自訂按鈕內容
classNamestring追加到關閉按鈕的 className

細節

  • layoutId FLIP 原理MorphingDialogTriggerMorphingDialogContent 共用一個由 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 方案

On this page