wui
组件

滑动替换 Slide Swap

悬停或激活时将当前内容向上/向下平移滑出,同时无缝滑入相同形态副本的微动效组件。

第三方依赖 · motion

基础用法

鼠标悬停在按钮上,文字向上平移滚出并无缝滑入第二层文字:

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
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

属性类型默认值说明
childrenReact.ReactNode—在进出场两层之间自动复制并执行滑动替换的内容(如按钮文字、图标组合、状态徽章等)。
direction"up" | "down""up"滑动的位移方向。`up` 为向上推挤替换,`down` 为向下推挤替换。
trigger"hover" | "parent""hover"触发方式。hover 仅监听组件自身;parent 监听最近的按钮或链接(含内边距区域),并在键盘聚焦(focus-visible)时同样播放。放在按钮内部时推荐使用 parent。
activeboolean—受控模式下强制保持替换激活状态,优先级高于 trigger。
transitionTransition{ duration: 0.42, ease: [0.22, 1, 0.36, 1] }两层图层运动共享的 Motion 缓动曲线与持续时间配置。
classNamestring—应用于外层 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,不再发生位移晃动。