组件
滑动替换 Slide Swap
悬停或激活时将当前内容向上/向下平移滑出,同时无缝滑入相同形态副本的微动效组件。
基础用法
鼠标悬停在按钮上,文字向上平移滚出并无缝滑入第二层文字:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/slide-swap安装基础依赖与动效库
pnpm add motion lucide-react class-variance-authority clsx tailwind-merge复制组件源码到
components/ui/slide-swap.tsx"use client"
import * as React from "react"
import {
motion,
useReducedMotion,
type HTMLMotionProps,
type Transition,
} from "motion/react"
import { cn } from "@/lib/utils"
export interface SlideSwapProps extends Omit<
HTMLMotionProps<"span">,
"children" | "transition"
> {
/** Content duplicated for the outgoing and incoming layers. */
children: React.ReactNode
/** Direction the visible layer leaves. @default "up" */
direction?: "up" | "down"
/**
* What activates the swap. `"hover"` listens on the component itself;
* `"parent"` listens on the closest link or button (or the parent element),
* so hovering the whole control — padding included — or focusing it with
* the keyboard plays the swap. @default "hover"
*/
trigger?: "hover" | "parent"
/** Force the swapped state from outside the component. */
active?: boolean
/** Transition shared by both content layers. */
transition?: Transition
}
/** Slides one whole content layer out while an identical layer enters. */
function SlideSwap({
children,
direction = "up",
trigger = "hover",
active,
transition = { duration: 0.42, ease: [0.22, 1, 0.36, 1] },
className,
onPointerEnter,
onPointerLeave,
...props
}: SlideSwapProps) {
const ref = React.useRef<HTMLSpanElement>(null)
const [hovered, setHovered] = React.useState(false)
const reduceMotion = useReducedMotion()
const swapped = active ?? hovered
const travel = direction === "up" ? "-100%" : "100%"
const incomingStart = direction === "up" ? "100%" : "-100%"
const outgoingTarget = reduceMotion ? { y: 0 } : { y: swapped ? travel : 0 }
const incomingTarget = reduceMotion
? { y: 0 }
: { y: swapped ? 0 : incomingStart }
React.useEffect(() => {
const element = ref.current
if (trigger !== "parent" || !element) return
const target =
element.parentElement?.closest<HTMLElement>(
"a, button, [role='button'], [data-slide-swap-trigger]"
) ?? element.parentElement
if (!target) return
const enter = () => setHovered(true)
const leave = () => setHovered(false)
const focusIn = () => {
if (target.matches(":focus-visible")) setHovered(true)
}
target.addEventListener("pointerenter", enter)
target.addEventListener("pointerleave", leave)
target.addEventListener("focusin", focusIn)
target.addEventListener("focusout", leave)
return () => {
target.removeEventListener("pointerenter", enter)
target.removeEventListener("pointerleave", leave)
target.removeEventListener("focusin", focusIn)
target.removeEventListener("focusout", leave)
}
}, [trigger])
return (
<motion.span
ref={ref}
data-slot="slide-swap"
data-state={swapped ? "swapped" : "idle"}
className={cn("relative inline-block overflow-hidden", className)}
onPointerEnter={(event) => {
if (trigger === "hover") setHovered(true)
onPointerEnter?.(event)
}}
onPointerLeave={(event) => {
if (trigger === "hover") setHovered(false)
onPointerLeave?.(event)
}}
{...props}
>
<motion.span
data-slot="slide-swap-layer"
className="block"
initial={false}
animate={outgoingTarget}
transition={transition}
>
{children}
</motion.span>
<motion.span
aria-hidden="true"
inert
data-slot="slide-swap-layer"
className="absolute inset-x-0 top-0 block"
initial={false}
animate={incomingTarget}
transition={transition}
>
{children}
</motion.span>
</motion.span>
)
}
export { SlideSwap }
属性 Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | React.ReactNode | — | 在进出场两层之间自动复制并执行滑动替换的内容(如按钮文字、图标组合、状态徽章等)。 |
| direction | "up" | "down" | "up" | 滑动的位移方向。`up` 为向上推挤替换,`down` 为向下推挤替换。 |
| trigger | "hover" | "parent" | "hover" | 触发方式。hover 仅监听组件自身;parent 监听最近的按钮或链接(含内边距区域),并在键盘聚焦(focus-visible)时同样播放。放在按钮内部时推荐使用 parent。 |
| active | boolean | — | 受控模式下强制保持替换激活状态,优先级高于 trigger。 |
| transition | Transition | { duration: 0.42, ease: [0.22, 1, 0.36, 1] } | 两层图层运动共享的 Motion 缓动曲线与持续时间配置。 |
| className | string | — | 应用于外层 overflow-hidden 裁切容器的 CSS 类名。 |
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onPointerEnter | (event: React.PointerEvent) => void | — | 光标移入触发滑动替换时调用。 |
| onPointerLeave | (event: React.PointerEvent) => void | — | 光标移出恢复原始位置时调用。 |
使用场景与设计规范
SlideSwap 适用于核心 CTA 操作按钮、发布公告药丸徽章、导航链接悬停态:
- 精致的高级微动效:相比于生硬的背景变色,垂直滚动推挤文字能给界面带来极具动感的现代交互质感。
- 自动克隆第二层:无需开发者在代码中重复编写两遍相同的文字 JSX,组件内部自动克隆渲染并完成绝对定位。
场景示例
多形态按钮组合
在主按钮、描边按钮与幽灵按钮上使用不同方向的滑动替换。trigger="parent" 让整个按钮(包括内边距)都能触发,键盘聚焦同样有效:
Loading…
顶部通知药丸徽章
悬停或聚焦公告徽章时,文案整体向上替换一次,提示这是可点击的入口:
Loading…
无障碍与交互 Accessibility
- 克隆图层惰性(inert):自动克隆生成的第二层文本节点默认添加
aria-hidden="true"和inert,屏幕阅读器仅会朗读一次真实内容。 - 键盘可达:
trigger="parent"时同时监听父级的focusin/focusout,仅在:focus-visible状态下播放,鼠标点击不会误触发。 - 动效减弱适配:当用户开启
prefers-reduced-motion: reduce时,滑动位移距离强制重置为 0,不再发生位移晃动。