组件
形变气泡卡片 Morphing Popover
将紧凑型按钮原地扩展为轻量级表单、快捷输入框或上下文操作浮层的形变交互组件。
基础用法
点击触发按钮,按钮外层形状直接展开为原地承载输入框与操作按钮的气泡卡片:
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"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
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| open | boolean | — | 受控模式下气泡的打开状态。 |
| defaultOpen | boolean | false | 非受控模式下气泡的初始打开状态。 |
| onOpenChange | (open: boolean) => void | — | 气泡展开或收起状态变化时的回调函数。 |
| transition | Transition | { type: "spring", bounce: 0.14, visualDuration: 0.36 } | 原地形变过渡使用的 Motion 弹簧动画配置。 |
| variants | Variants | — | 气泡内部子内容在进场与离场阶段的变体。默认在表面形变开始后稍晚以模糊对焦方式淡入,关闭时快速淡出。 |
| className | string | — | 应用于外层定位容器的额外 CSS 类名。 |
MorphingPopoverTrigger
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| asChild | boolean | false | 是否将点击触发行为合并到子元素上。 |
| className | string | — | 应用于触发器按钮的样式类名。 |
MorphingPopoverContent
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| className | string | — | 应用于展开后气泡面板的 CSS 类名(尺寸、内边距、圆角等)。背景与阴影由独立的形变表面绘制,文字不会在形变过程中被拉伸。 |
| children | React.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环境下,跳过物理形变过程直接呈现。