wui
组件

过渡面板 Transition Panel

按索引切换一组面板,带方向感知的滑入、淡出与模糊过渡,容器高度随新面板平滑变化。

第三方依赖 · motion

基础用法

传入面板数组与 activeIndex。向后切换时新面板从右侧进入、旧面板向左退出,向前切换时方向相反;面板高度不同时,容器高度会以弹簧动画过渡:

Loading…

安装与引入

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

pnpm dlx @wui-design/cli@latest add @wui/transition-panel
安装基础依赖与 Motion 动效库
pnpm add motion clsx tailwind-merge
复制组件源码到 components/ui/transition-panel.tsx
components/ui/transition-panel.tsx
"use client"

import * as React from "react"
import {
  AnimatePresence,
  motion,
  useIsPresent,
  useReducedMotion,
  type Transition,
  type Variants,
} from "motion/react"

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

export interface TransitionPanelProps
  extends Omit<React.ComponentProps<"div">, "children"> {
  /** Panels, one per index. Only the active one is rendered. */
  children: React.ReactNode[]
  /** Index of the visible panel. Moving forward slides in from the end side. */
  activeIndex: number
  /** Axis panels travel along. @default "x" */
  axis?: "x" | "y"
  /** Travel distance of entering and exiting panels, in pixels. @default 24 */
  offset?: number
  /** Blur panels while they enter and exit. @default true */
  blur?: boolean
  /** Ease the container height to fit the next panel. @default true */
  animateHeight?: boolean
  /** Transition of the entering and exiting panels. */
  transition?: Transition
  /** Transition of the container height. */
  heightTransition?: Transition
  /** Class applied to the element wrapping each panel. */
  panelClassName?: string
}

const defaultTransition: Transition = {
  duration: 0.36,
  ease: [0.22, 1, 0.36, 1],
}

const defaultHeightTransition: Transition = {
  type: "spring",
  stiffness: 420,
  damping: 40,
  mass: 0.8,
}

interface PanelProps extends Omit<React.ComponentProps<typeof motion.div>, "onResize"> {
  onResize: (height: number) => void
}

function Panel({ ref, onResize, ...props }: PanelProps) {
  const localRef = React.useRef<HTMLDivElement>(null)
  const isPresent = useIsPresent()

  const setRefs = React.useCallback(
    (node: HTMLDivElement | null) => {
      localRef.current = node
      if (typeof ref === "function") ref(node)
      else if (ref) ref.current = node
    },
    [ref]
  )

  React.useEffect(() => {
    const node = localRef.current
    if (!isPresent || !node) return
    const observer = new ResizeObserver(([entry]) => {
      onResize(entry.borderBoxSize[0].blockSize)
    })
    observer.observe(node)
    return () => observer.disconnect()
  }, [isPresent, onResize])

  return <motion.div ref={setRefs} {...props} />
}

/**
 * Shows one panel at a time. Panels slide in the direction of travel, fade
 * and optionally blur, while the container eases to the next panel's height.
 */
function TransitionPanel({
  children,
  activeIndex,
  axis = "x",
  offset = 24,
  blur = true,
  animateHeight = true,
  transition = defaultTransition,
  heightTransition = defaultHeightTransition,
  panelClassName,
  className,
  ...props
}: TransitionPanelProps) {
  const reduceMotion = useReducedMotion()
  const [height, setHeight] = React.useState<number | null>(null)
  const [travel, setTravel] = React.useState({ index: activeIndex, direction: 1 })

  if (travel.index !== activeIndex) {
    setTravel({
      index: activeIndex,
      direction: activeIndex > travel.index ? 1 : -1,
    })
  }

  const variants = React.useMemo<Variants>(() => {
    const moved = (direction: number) => ({
      [axis]: direction * offset,
      opacity: 0,
      ...(blur ? { filter: "blur(4px)" } : null),
    })
    return {
      enter: (direction: number) => moved(direction),
      center: {
        [axis]: 0,
        opacity: 1,
        ...(blur ? { filter: "blur(0px)" } : null),
      },
      exit: (direction: number) => moved(-direction),
    }
  }, [axis, blur, offset])

  const handleResize = React.useCallback((next: number) => setHeight(next), [])

  return (
    <div
      data-slot="transition-panel"
      className={cn("relative", className)}
      {...props}
    >
      <motion.div
        data-slot="transition-panel-viewport"
        className="relative overflow-hidden"
        initial={false}
        animate={animateHeight && height !== null ? { height } : undefined}
        transition={reduceMotion ? { duration: 0 } : heightTransition}
      >
        <AnimatePresence
          initial={false}
          mode="popLayout"
          custom={travel.direction}
        >
          <Panel
            key={activeIndex}
            data-slot="transition-panel-item"
            className={cn("w-full", panelClassName)}
            custom={travel.direction}
            variants={variants}
            initial="enter"
            animate="center"
            exit="exit"
            transition={reduceMotion ? { duration: 0 } : transition}
            onResize={handleResize}
          >
            {children[activeIndex]}
          </Panel>
        </AnimatePresence>
      </motion.div>
    </div>
  )
}

export { TransitionPanel }

属性 Props

TransitionPanel 支持以下配置属性,并继承原生 <div> 的其余 HTML 属性:

属性类型默认值说明
childrenReact.ReactNode[]—面板数组,只渲染 `activeIndex` 对应的一项。
activeIndexnumber—当前面板下标。组件会与上一次下标比较,自动推断运动方向。
axis"x" | "y""x"面板位移的方向轴。
offsetnumber24进出场的位移距离(像素)。
blurbooleantrue进出场时是否叠加轻微模糊。
animateHeightbooleantrue是否让容器高度平滑过渡到新面板的高度。
transitionTransition{ duration: 0.36, ease: [0.22, 1, 0.36, 1] }面板进出场的过渡参数。
heightTransitionTransition{ type: "spring", stiffness: 420, damping: 40, mass: 0.8 }容器高度变化的过渡参数。
panelClassNamestring—应用于每个面板包裹元素的类名。

使用场景与设计规范

TransitionPanel 只负责“切换哪一块内容”的动效,不包含标签或步骤导航,可自由搭配 切换组 Toggle Group、按钮或 步骤条 Steps。

  • 多步表单与引导:方向感知的位移能让用户感知“前进”与“后退”。
  • 内容区域切换:设置页、发布说明等高度不一的内容切换,高度过渡能避免下方内容突然跳动。
  • 需要完整 Tab 语义时:若内容是标准标签页,请优先使用 标签页 Tabs,它提供 tablist / tabpanel 语义与键盘导航。

场景示例

多步引导

配合上一步 / 下一步按钮使用,后退时面板从左侧进入。面板容器会裁剪溢出内容,示例通过 panelClassName="p-1" 与外层负边距为输入框的聚焦光环预留空间:

Loading…

无障碍与交互 Accessibility

  • 旧面板立即移出交互:退出中的面板会脱离文档流并在动画结束后卸载,不会与新面板争夺焦点。
  • 语义由外部提供:组件本身不添加 ARIA 角色,请根据场景为触发器与面板补充 aria-controls、aria-labelledby 等属性。
  • 减少动态效果:开启 prefers-reduced-motion 时,面板切换与高度变化均即时完成。