wui
组件

形变对话框 Morphing Dialog

通过共享布局动画(Shared Layout)将触发器表面平滑无缝扩展为居中模态对话框的连续过渡组件。

第三方依赖 · radix-ui第三方依赖 · lucide-react第三方依赖 · motion

基础用法

点击触发器,触发元素的外壳表面将通过真实的物理弹簧形变扩展为居中的全功能对话框:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/morphing-dialog
安装基础依赖与动效库
pnpm add radix-ui motion lucide-react class-variance-authority clsx tailwind-merge
复制组件源码到 components/ui/morphing-dialog.tsx
components/ui/morphing-dialog.tsx
"use client"

import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { XIcon } from "lucide-react"
import {
  AnimatePresence,
  LayoutGroup,
  motion,
  useMotionValue,
  useReducedMotion,
  type Transition,
  type Variants,
} from "motion/react"

import { cn } from "@/lib/utils"

const defaultTransition = {
  type: "spring",
  bounce: 0.12,
  visualDuration: 0.42,
} as const

type MorphingDialogContextValue = {
  open: boolean
  layoutId: string
  transition: Transition
}

const MorphingDialogContext =
  React.createContext<MorphingDialogContextValue | null>(null)

function useMorphingDialog() {
  const context = React.useContext(MorphingDialogContext)
  if (!context) {
    throw new Error(
      "MorphingDialog parts must be used inside <MorphingDialog>."
    )
  }
  return context
}

const MotionContent = motion.create(DialogPrimitive.Content)

/**
 * The shared-layout surface that morphs between the trigger and the dialog.
 * It copies the corner radius of the element it sits on (as a motion value,
 * before the first paint) so Motion can animate and scale-correct the radius
 * instead of stretching it mid-morph.
 */
function MorphingSurface({
  layoutId,
  transition,
  source,
  className,
}: {
  layoutId: string
  transition: Transition
  source: "parent" | "next-sibling"
  className?: string
}) {
  // Start close to the default radii so the server-rendered surface already
  // looks right before the measurement runs.
  const borderRadius = useMotionValue(source === "parent" ? 10 : 8)
  const measure = React.useCallback(
    (node: HTMLSpanElement | null) => {
      const element =
        source === "parent" ? node?.parentElement : node?.nextElementSibling
      if (!element) return
      const rect = element.getBoundingClientRect()
      const radius =
        Number.parseFloat(getComputedStyle(element).borderTopLeftRadius) || 0
      borderRadius.set(Math.min(radius, rect.width / 2, rect.height / 2))
    },
    [borderRadius, source]
  )

  return (
    <motion.span
      ref={measure}
      aria-hidden
      layoutId={layoutId}
      className={cn(
        "bg-background pointer-events-none absolute inset-0 block border",
        className
      )}
      style={{ borderRadius }}
      transition={transition}
    />
  )
}

export interface MorphingDialogProps extends React.ComponentProps<
  typeof DialogPrimitive.Root
> {
  /** Spring or tween used by the shared-layout morph. */
  transition?: Transition
}

function MorphingDialog({
  open: openProp,
  defaultOpen,
  onOpenChange,
  transition = defaultTransition,
  children,
  ...props
}: MorphingDialogProps) {
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
    defaultOpen ?? false
  )
  const open = openProp ?? uncontrolledOpen
  const layoutId = React.useId()

  const handleOpenChange = React.useCallback(
    (nextOpen: boolean) => {
      if (openProp === undefined) setUncontrolledOpen(nextOpen)
      onOpenChange?.(nextOpen)
    },
    [onOpenChange, openProp]
  )

  return (
    <MorphingDialogContext.Provider value={{ open, layoutId, transition }}>
      <LayoutGroup id={layoutId}>
        <DialogPrimitive.Root
          open={open}
          onOpenChange={handleOpenChange}
          {...props}
        >
          {children}
        </DialogPrimitive.Root>
      </LayoutGroup>
    </MorphingDialogContext.Provider>
  )
}

function MorphingDialogTrigger({
  className,
  children,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
  const { open, layoutId, transition } = useMorphingDialog()
  const reduceMotion = useReducedMotion()

  return (
    <span
      data-slot="morphing-dialog-trigger-wrapper"
      className="relative inline-flex"
    >
      {!open ? (
        <MorphingSurface
          layoutId={`${layoutId}-surface`}
          source="next-sibling"
          className="shadow-xs"
          transition={reduceMotion ? { duration: 0 } : transition}
        />
      ) : null}
      <DialogPrimitive.Trigger
        data-slot="morphing-dialog-trigger"
        className={cn(
          "focus-visible:ring-ring/50 relative z-10 inline-flex min-h-9 items-center justify-center rounded-md px-4 text-sm font-medium outline-none transition-opacity focus-visible:ring-[3px] [&[data-variant]]:border-transparent [&[data-variant]]:bg-transparent [&[data-variant]]:shadow-none",
          open && "pointer-events-none opacity-0",
          className
        )}
        {...props}
      >
        {children}
      </DialogPrimitive.Trigger>
    </span>
  )
}

export interface MorphingDialogContentProps extends React.ComponentProps<
  typeof DialogPrimitive.Content
> {
  /** Classes applied to the viewport backdrop. */
  overlayClassName?: string
  /** Motion variants for the content inside the morphing surface. */
  variants?: Variants
  /**
   * Content rendered above the body without the fade-in, typically a
   * `MorphingDialogImage`, so shared media stays fully visible while it morphs.
   */
  media?: React.ReactNode
}

function MorphingDialogContent({
  className,
  overlayClassName,
  variants,
  media,
  children,
  ...props
}: MorphingDialogContentProps) {
  const { open, layoutId, transition } = useMorphingDialog()
  const reduceMotion = useReducedMotion()
  const contentVariants: Variants = variants ?? {
    initial: { opacity: 0, y: 10, scale: 0.98, filter: "blur(6px)" },
    animate: { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" },
    exit: {
      opacity: 0,
      y: 6,
      filter: "blur(4px)",
      transition: { duration: 0.12, delay: 0 },
    },
  }

  return (
    <AnimatePresence initial={false}>
      {open ? (
        <DialogPrimitive.Portal forceMount>
          <DialogPrimitive.Overlay asChild forceMount>
            <motion.div
              data-slot="morphing-dialog-overlay"
              className={cn("fixed inset-0 z-50 bg-overlay/90", overlayClassName)}
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{
                duration: reduceMotion ? 0 : 0.18,
                ease: "easeOut",
              }}
            />
          </DialogPrimitive.Overlay>
          <div className="fixed inset-0 z-50 flex items-center justify-center overflow-y-auto p-4">
            <MotionContent
              forceMount
              data-slot="morphing-dialog-content"
              className={cn(
                "relative w-full max-w-lg overflow-hidden rounded-lg outline-none",
                className
              )}
              initial={false}
              {...(props as React.ComponentProps<typeof MotionContent>)}
            >
              <MorphingSurface
                layoutId={`${layoutId}-surface`}
                source="parent"
                className="shadow-lg"
                transition={reduceMotion ? { duration: 0 } : transition}
              />
              {media ? (
                <div
                  data-slot="morphing-dialog-media"
                  className="relative z-10 overflow-hidden rounded-t-[inherit]"
                >
                  {media}
                </div>
              ) : null}
              <motion.div
                data-slot="morphing-dialog-body"
                className="relative z-10 p-6"
                variants={reduceMotion ? undefined : contentVariants}
                initial={reduceMotion ? false : "initial"}
                animate="animate"
                exit="exit"
                transition={{
                  duration: reduceMotion ? 0 : 0.28,
                  delay: reduceMotion ? 0 : 0.1,
                  ease: [0.22, 1, 0.36, 1],
                }}
              >
                {children}
              </motion.div>
            </MotionContent>
          </div>
        </DialogPrimitive.Portal>
      ) : null}
    </AnimatePresence>
  )
}

function MorphingDialogTitle({
  className,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
  return (
    <DialogPrimitive.Title
      data-slot="morphing-dialog-title"
      className={cn("text-lg font-semibold leading-none", className)}
      {...props}
    />
  )
}

function MorphingDialogSubtitle({
  className,
  ...props
}: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="morphing-dialog-subtitle"
      className={cn("text-foreground/80 mt-1 text-sm font-medium", className)}
      {...props}
    />
  )
}

function MorphingDialogDescription({
  className,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
  return (
    <DialogPrimitive.Description
      data-slot="morphing-dialog-description"
      className={cn(
        "text-muted-foreground mt-3 text-sm leading-relaxed",
        className
      )}
      {...props}
    />
  )
}

function MorphingDialogImage({
  className,
  ...props
}: React.ComponentProps<typeof motion.img>) {
  const { layoutId, transition } = useMorphingDialog()
  const reduceMotion = useReducedMotion()

  return (
    <motion.img
      data-slot="morphing-dialog-image"
      layoutId={`${layoutId}-image`}
      className={cn("block w-full object-cover", className)}
      transition={reduceMotion ? { duration: 0 } : transition}
      {...props}
    />
  )
}

function MorphingDialogClose({
  className,
  children,
  asChild,
  ...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
  if (asChild) {
    return (
      <DialogPrimitive.Close
        asChild
        data-slot="morphing-dialog-close"
        className={className}
        {...props}
      >
        {children}
      </DialogPrimitive.Close>
    )
  }

  return (
    <DialogPrimitive.Close
      data-slot="morphing-dialog-close"
      className={cn(
        "text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/50 absolute right-4 top-4 z-20 inline-flex size-8 items-center justify-center rounded-md outline-none transition-colors focus-visible:ring-[3px]",
        className
      )}
      {...props}
    >
      {children ?? <XIcon className="size-4" />}
      {!children ? <span className="sr-only">关闭</span> : null}
    </DialogPrimitive.Close>
  )
}

export {
  MorphingDialog,
  MorphingDialogClose,
  MorphingDialogContent,
  MorphingDialogDescription,
  MorphingDialogImage,
  MorphingDialogSubtitle,
  MorphingDialogTitle,
  MorphingDialogTrigger,
}

属性 Props

MorphingDialog

MorphingDialog 作为最外层的上下文容器,管理形变共享布局与弹窗的受控/非受控开启状态:

属性类型默认值说明
openboolean—受控模式下对话框的当前打开状态。
defaultOpenbooleanfalse非受控模式下对话框的初始打开状态。
onOpenChange(open: boolean) => void—对话框打开或关闭状态变化时的回调函数。
transitionTransition{ type: "spring", bounce: 0.12, visualDuration: 0.42 }跨布局形变表面与共享元素所使用的 Motion 动画配置。默认弹簧带有极轻的回弹,表面圆角会随形变一同插值并做缩放校正,不会被拉伸变形。
childrenReact.ReactNode—组件子元素,应包含 Trigger 与 Content 部件。

MorphingDialogContent

MorphingDialogContent 承载展开后的弹窗主体内容与遮罩层:

属性类型默认值说明
overlayClassNamestring—应用于背景遮罩层(Backdrop Overlay)的自定义 CSS 类名。
variantsVariants{ initial: { opacity: 0, y: 10, scale: 0.98, filter: "blur(6px)" }, animate: { opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }, exit: { opacity: 0, y: 6, filter: "blur(4px)" } }弹窗内部主体内容进场与离场的 Motion 变体动画。主体在表面形变开始后稍晚淡入,关闭时则先快速淡出,让表面收回更干净。
mediaReact.ReactNode—渲染在主体上方、不参与淡入的媒体区域,通常放置 MorphingDialogImage,让共享图片在形变全程保持可见。
classNamestring—应用于对话框浮层外框的额外 CSS 类名。
childrenReact.ReactNode—对话框内部包含的标题、描述、表单与操作按钮等子元素。

MorphingDialogTrigger

属性类型默认值说明
asChildbooleanfalse是否将触发行为与属性合并到唯一子元素上渲染。
classNamestring—应用于触发器按钮的样式类名。形变表面会读取触发器的圆角,因此设置 rounded-* 后展开与收回都能保持一致的圆角过渡。
childrenReact.ReactNode—触发器内部的文本或图标内容。

MorphingDialogImage

属性类型默认值说明
srcstring—共享图片的资源路径,在 Trigger 和 Content 中复用相同 layoutId 即可保持连续缩放。
altstring—图片的无障碍替代说明文本。
classNamestring—应用于图片的额外 CSS 类名。

事件 Events

属性类型默认值说明
onOpenChange(open: boolean) => void—当用户点击触发器、点击关闭按钮、点击背景遮罩或按下 Escape 键时触发,返回最新的打开布尔值。
onPointerDownOutside(event: CustomEvent) => void—当用户点击对话框外部遮罩区域时触发,可通过 preventDefault 阻止关闭。
onEscapeKeyDown(event: KeyboardEvent) => void—当对话框处于打开状态并按下 Esc 键时触发,可通过 preventDefault 阻止关闭。

使用场景与设计规范

MorphingDialog 适用于由卡片/卡槽展开为深度详情或沉浸式预览的交互流程:

  • 连续性心智模型:相比普通弹窗突兀的淡入或弹跳,形变动画建立了明确的视觉空间因果关系,让用户清晰感知“当前对话框是从哪张卡片/哪个按钮演变而来的”。
  • 媒体与画廊展开:搭配 MorphingDialogImage 可以让缩略图直接放大为全高清画幅,视觉无闪烁。
  • 信息分级与渐进披露:在卡片态只保留高层关键指标,点击后顺畅展开为完整配置项或账单流水,避免界面信息过载。

场景示例

媒体画廊展开

通过 media 插槽放置共享图片,在缩略图卡片与详情弹窗之间实现全程可见的图片缩放形变:

Loading…

业务卡片展开详情

适用于费用卡、资产账户或监控卡片从概览形态平滑过渡为详情面板:

Loading…

无障碍与交互 Accessibility

  • 焦点管理与捕捉:基于 Radix Dialog,打开时自动将键盘焦点转移至弹窗内首个可交互控件,关闭后焦点平稳归还至触发器按钮。
  • 键盘快捷键:
    • Esc:立即关闭当前对话框并归还焦点。
    • Tab / Shift + Tab:在弹窗内部可聚焦元素间循环跳转,严格防止焦点逸出至底层背景。
  • 动态效果减弱(prefers-reduced-motion):当用户在操作系统中开启“减弱动态效果”时,共享形变动画将自动降级为瞬间切换,避免可能引起晕动症的高振幅形变。