组件
过渡面板 Transition Panel
按索引切换一组面板,带方向感知的滑入、淡出与模糊过渡,容器高度随新面板平滑变化。
基础用法
传入面板数组与 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"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 属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | React.ReactNode[] | — | 面板数组,只渲染 `activeIndex` 对应的一项。 |
| activeIndex | number | — | 当前面板下标。组件会与上一次下标比较,自动推断运动方向。 |
| axis | "x" | "y" | "x" | 面板位移的方向轴。 |
| offset | number | 24 | 进出场的位移距离(像素)。 |
| blur | boolean | true | 进出场时是否叠加轻微模糊。 |
| animateHeight | boolean | true | 是否让容器高度平滑过渡到新面板的高度。 |
| transition | Transition | { duration: 0.36, ease: [0.22, 1, 0.36, 1] } | 面板进出场的过渡参数。 |
| heightTransition | Transition | { type: "spring", stiffness: 420, damping: 40, mass: 0.8 } | 容器高度变化的过渡参数。 |
| panelClassName | string | — | 应用于每个面板包裹元素的类名。 |
使用场景与设计规范
TransitionPanel 只负责“切换哪一块内容”的动效,不包含标签或步骤导航,可自由搭配 切换组 Toggle Group、按钮或 步骤条 Steps。
- 多步表单与引导:方向感知的位移能让用户感知“前进”与“后退”。
- 内容区域切换:设置页、发布说明等高度不一的内容切换,高度过渡能避免下方内容突然跳动。
- 需要完整 Tab 语义时:若内容是标准标签页,请优先使用 标签页 Tabs,它提供
tablist/tabpanel语义与键盘导航。
场景示例
多步引导
配合上一步 / 下一步按钮使用,后退时面板从左侧进入。面板容器会裁剪溢出内容,示例通过 panelClassName="p-1" 与外层负边距为输入框的聚焦光环预留空间:
Loading…
无障碍与交互 Accessibility
- 旧面板立即移出交互:退出中的面板会脱离文档流并在动画结束后卸载,不会与新面板争夺焦点。
- 语义由外部提供:组件本身不添加 ARIA 角色,请根据场景为触发器与面板补充
aria-controls、aria-labelledby等属性。 - 减少动态效果:开启
prefers-reduced-motion时,面板切换与高度变化均即时完成。