wui
组件

形变气泡卡片 Morphing Popover

将紧凑型按钮原地扩展为轻量级表单、快捷输入框或上下文操作浮层的形变交互组件。

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

基础用法

点击触发按钮,按钮外层形状直接展开为原地承载输入框与操作按钮的气泡卡片:

Loading…

安装与引入

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

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

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

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

const defaultTransition = {
  type: "spring",
  bounce: 0.14,
  visualDuration: 0.36,
} as const

/**
 * The shared-layout surface that morphs between trigger and panel. It copies
 * the corner radius of the element it decorates (as a motion value, before
 * the first paint) so the radius animates and stays scale-corrected.
 */
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(
        "pointer-events-none absolute inset-0 block border",
        className
      )}
      style={{ borderRadius }}
      transition={transition}
    />
  )
}

type MorphingPopoverContextValue = {
  open: boolean
  setOpen: (open: boolean) => void
  layoutId: string
  transition: Transition
  variants?: Variants
  triggerRef: React.RefObject<HTMLElement | null>
  contentRef: React.RefObject<HTMLDivElement | null>
}

const MorphingPopoverContext =
  React.createContext<MorphingPopoverContextValue | null>(null)

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

export interface MorphingPopoverProps extends React.ComponentProps<"div"> {
  /** Whether the popover is open in controlled mode. */
  open?: boolean
  /** Initial state in uncontrolled mode. @default false */
  defaultOpen?: boolean
  /** Called whenever the open state changes. */
  onOpenChange?: (open: boolean) => void
  /** Spring or tween used by the shared-layout morph. */
  transition?: Transition
  /** Motion variants for content entering and leaving the expanded surface. */
  variants?: Variants
}

function MorphingPopover({
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  transition = defaultTransition,
  variants,
  className,
  children,
  ...props
}: MorphingPopoverProps) {
  const [uncontrolledOpen, setUncontrolledOpen] = React.useState(defaultOpen)
  const open = openProp ?? uncontrolledOpen
  const layoutId = React.useId()
  const triggerRef = React.useRef<HTMLElement | null>(null)
  const contentRef = React.useRef<HTMLDivElement | null>(null)
  const wasOpen = React.useRef(open)

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

  React.useEffect(() => {
    if (!open) return

    const handlePointerDown = (event: PointerEvent) => {
      const target = event.target as Node
      if (
        !contentRef.current?.contains(target) &&
        !triggerRef.current?.contains(target)
      ) {
        setOpen(false)
      }
    }
    const handleKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape") setOpen(false)
    }

    document.addEventListener("pointerdown", handlePointerDown)
    document.addEventListener("keydown", handleKeyDown)
    return () => {
      document.removeEventListener("pointerdown", handlePointerDown)
      document.removeEventListener("keydown", handleKeyDown)
    }
  }, [open, setOpen])

  React.useEffect(() => {
    if (open) {
      contentRef.current
        ?.querySelector<HTMLElement>(
          "[autofocus], textarea, input, select, button, [href], [tabindex]:not([tabindex='-1'])"
        )
        ?.focus()
    } else if (wasOpen.current) {
      triggerRef.current?.focus()
    }
    wasOpen.current = open
  }, [open])

  return (
    <MorphingPopoverContext.Provider
      value={{
        open,
        setOpen,
        layoutId,
        transition,
        variants,
        triggerRef,
        contentRef,
      }}
    >
      <LayoutGroup id={layoutId}>
        <div
          data-slot="morphing-popover"
          className={cn("relative inline-flex", className)}
          {...props}
        >
          {children}
        </div>
      </LayoutGroup>
    </MorphingPopoverContext.Provider>
  )
}

export interface MorphingPopoverTriggerProps extends React.ComponentProps<"button"> {
  /** Merge trigger behavior onto the single child element. @default false */
  asChild?: boolean
}

function MorphingPopoverTrigger({
  className,
  children,
  asChild = false,
  onClick,
  ...props
}: MorphingPopoverTriggerProps) {
  const { open, setOpen, layoutId, transition, triggerRef } =
    useMorphingPopover()
  const reduceMotion = useReducedMotion()
  const Comp = asChild ? Slot.Root : "button"

  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    onClick?.(event)
    if (!event.defaultPrevented) setOpen(true)
  }

  return (
    <span
      className="relative inline-flex"
      data-slot="morphing-popover-trigger-wrapper"
    >
      {!open ? (
        <MorphingSurface
          layoutId={`${layoutId}-surface`}
          source="next-sibling"
          className="bg-background shadow-xs"
          transition={reduceMotion ? { duration: 0 } : transition}
        />
      ) : null}
      <Comp
        ref={triggerRef as React.Ref<HTMLButtonElement>}
        type={asChild ? undefined : "button"}
        data-slot="morphing-popover-trigger"
        aria-expanded={open}
        aria-haspopup="dialog"
        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]",
          open && "pointer-events-none opacity-0",
          className
        )}
        onClick={handleClick}
        {...props}
      >
        {children}
      </Comp>
    </span>
  )
}

export interface MorphingPopoverContentProps extends React.ComponentProps<
  typeof motion.div
> {}

function MorphingPopoverContent({
  className,
  children,
  ...props
}: MorphingPopoverContentProps) {
  const { open, layoutId, transition, variants, contentRef } =
    useMorphingPopover()
  const reduceMotion = useReducedMotion()
  const contentVariants: Variants = variants ?? {
    initial: { opacity: 0, y: 4, filter: "blur(4px)" },
    animate: {
      opacity: 1,
      y: 0,
      filter: "blur(0px)",
      transition: { duration: 0.24, delay: 0.08, ease: [0.22, 1, 0.36, 1] },
    },
    exit: {
      opacity: 0,
      filter: "blur(2px)",
      transition: { duration: 0.1 },
    },
  }

  return (
    <AnimatePresence initial={false}>
      {open ? (
        <div
          data-slot="morphing-popover-positioner"
          className="absolute left-1/2 top-1/2 z-50 w-max -translate-x-1/2 -translate-y-1/2"
        >
          <motion.div
            ref={contentRef}
            data-slot="morphing-popover-content"
            role="dialog"
            className={cn(
              "text-popover-foreground relative w-72 rounded-lg p-4 outline-none",
              className
            )}
            {...props}
          >
            <MorphingSurface
              layoutId={`${layoutId}-surface`}
              source="parent"
              className="bg-popover shadow-md"
              transition={reduceMotion ? { duration: 0 } : transition}
            />
            <motion.div
              data-slot="morphing-popover-body"
              className="relative"
              variants={reduceMotion ? undefined : contentVariants}
              initial={reduceMotion ? false : "initial"}
              animate="animate"
              exit="exit"
              transition={reduceMotion ? { duration: 0 } : transition}
            >
              {children}
            </motion.div>
          </motion.div>
        </div>
      ) : null}
    </AnimatePresence>
  )
}

export interface MorphingPopoverCloseProps extends React.ComponentProps<"button"> {
  /** Merge close behavior onto the single child element. @default false */
  asChild?: boolean
}

function MorphingPopoverClose({
  asChild = false,
  onClick,
  children,
  ...props
}: MorphingPopoverCloseProps) {
  const { setOpen } = useMorphingPopover()
  const Comp = asChild ? Slot.Root : "button"

  const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
    onClick?.(event)
    if (!event.defaultPrevented) setOpen(false)
  }

  return (
    <Comp
      type={asChild ? undefined : "button"}
      data-slot="morphing-popover-close"
      onClick={handleClick}
      {...props}
    >
      {children}
    </Comp>
  )
}

export {
  MorphingPopover,
  MorphingPopoverClose,
  MorphingPopoverContent,
  MorphingPopoverTrigger,
}

属性 Props

MorphingPopover

属性类型默认值说明
openboolean—受控模式下气泡的打开状态。
defaultOpenbooleanfalse非受控模式下气泡的初始打开状态。
onOpenChange(open: boolean) => void—气泡展开或收起状态变化时的回调函数。
transitionTransition{ type: "spring", bounce: 0.14, visualDuration: 0.36 }原地形变过渡使用的 Motion 弹簧动画配置。
variantsVariants—气泡内部子内容在进场与离场阶段的变体。默认在表面形变开始后稍晚以模糊对焦方式淡入,关闭时快速淡出。
classNamestring—应用于外层定位容器的额外 CSS 类名。

MorphingPopoverTrigger

属性类型默认值说明
asChildbooleanfalse是否将点击触发行为合并到子元素上。
classNamestring—应用于触发器按钮的样式类名。

MorphingPopoverContent

属性类型默认值说明
classNamestring—应用于展开后气泡面板的 CSS 类名(尺寸、内边距、圆角等)。背景与阴影由独立的形变表面绘制,文字不会在形变过程中被拉伸。
childrenReact.ReactNode—展开后呈现的表单、操作列表或文字内容。

事件 Events

属性类型默认值说明
onOpenChange(open: boolean) => void—当气泡展开、点击外部空白区域或按下 Escape 键时触发,返回最新的打开状态。
onClick(event: React.MouseEvent) => void—点击触发按钮或关闭按钮时触发的常规鼠标事件。

使用场景与设计规范

MorphingPopover 适用于低摩擦的行内快速操作(In-situ Action):

  • 快速反馈与便签:如点击“快速反馈”直接原地展开文本域与发送按钮,无需打断用户视线跳转全屏弹窗。
  • 微型过滤与快捷搜索:在紧凑工具栏中,将小图标按钮展开为带有输入框的筛选框。
  • 比标准 Popover 更具实体感:标准 Popover 通常是从上方或下方弹出的新图层,而 MorphingPopover 则是原有按钮物理延展的物理直觉。

场景示例

快捷反馈卡片

将轻量反馈入口按钮原地变形为带提交验证的表单卡片:

Loading…

无障碍与交互 Accessibility

  • 自动聚焦:当气泡展开时,组件会自动寻找内部首个 autofocus、textarea、input 或 button 并将焦点转移至该元素。
  • 快捷键:
    • Esc:快速收起当前气泡卡片,并将焦点安全归还到原触发按钮。
  • 指针外侧点击检测:点击页面其他区域(Outside Click)会自动触发平滑收起。
  • 动效降级:在 prefers-reduced-motion: reduce 环境下,跳过物理形变过程直接呈现。