组件
滑动高亮 Animated Background
在悬停或选中的子元素之间平滑滑动一块共享的高亮背景,适用于导航、列表与选择器。
基础用法
用 AnimatedBackground 包裹一组带有 data-id 的子元素。hover 模式下高亮跟随指针与键盘焦点移动,离开后回到 value 对应的当前项:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/animated-background安装基础依赖与 Motion 动效库
pnpm add motion clsx tailwind-merge复制组件源码到
components/ui/animated-background.tsx"use client"
import * as React from "react"
import {
AnimatePresence,
motion,
useReducedMotion,
type Transition,
} from "motion/react"
import { cn } from "@/lib/utils"
/** Props every direct child of `AnimatedBackground` may carry. */
export interface AnimatedBackgroundItemProps {
/** Unique id of the item, compared with `value`. */
"data-id": string
className?: string
children?: React.ReactNode
onClick?: React.MouseEventHandler
onPointerEnter?: React.PointerEventHandler
onPointerLeave?: React.PointerEventHandler
onFocus?: React.FocusEventHandler
onBlur?: React.FocusEventHandler
}
export interface AnimatedBackgroundProps {
/** Items to highlight. Each direct child needs a unique `data-id`. */
children:
| React.ReactElement<AnimatedBackgroundItemProps>
| React.ReactElement<AnimatedBackgroundItemProps>[]
/**
* `click` moves the highlight to the clicked item. `hover` follows the
* pointer and keyboard focus, then returns to `value` when it leaves.
* @default "click"
*/
mode?: "click" | "hover"
/** Controlled id of the active item. `null` hides the highlight. */
value?: string | null
/** Initial active id when uncontrolled. @default null */
defaultValue?: string | null
/** Called when a click selects another item. */
onValueChange?: (value: string) => void
/** Classes of the moving highlight, e.g. background and radius. */
highlightClassName?: string
/** Transition used when the highlight moves between items. */
transition?: Transition
}
const defaultTransition: Transition = {
type: "spring",
stiffness: 520,
damping: 38,
mass: 0.7,
}
/**
* Slides one shared highlight behind the active or hovered child. Renders no
* wrapper element, so it fits inside any existing flex, grid or list layout.
*/
function AnimatedBackground({
children,
mode = "click",
value,
defaultValue = null,
onValueChange,
highlightClassName,
transition = defaultTransition,
}: AnimatedBackgroundProps) {
const layoutId = React.useId()
const reduceMotion = useReducedMotion()
const [internalValue, setInternalValue] = React.useState(defaultValue)
const [hoveredId, setHoveredId] = React.useState<string | null>(null)
const activeId = value !== undefined ? value : internalValue
const highlightedId = mode === "hover" ? (hoveredId ?? activeId) : activeId
const select = (id: string) => {
if (value === undefined) setInternalValue(id)
onValueChange?.(id)
}
return React.Children.map(children, (child) => {
const props = child.props
const id = props["data-id"]
const highlighted = highlightedId === id
const interaction: Partial<AnimatedBackgroundItemProps> =
mode === "hover"
? {
onClick: (event) => {
props.onClick?.(event)
select(id)
},
onPointerEnter: (event) => {
props.onPointerEnter?.(event)
setHoveredId(id)
},
onPointerLeave: (event) => {
props.onPointerLeave?.(event)
setHoveredId((current) => (current === id ? null : current))
},
onFocus: (event) => {
props.onFocus?.(event)
setHoveredId(id)
},
onBlur: (event) => {
props.onBlur?.(event)
setHoveredId((current) => (current === id ? null : current))
},
}
: {
onClick: (event) => {
props.onClick?.(event)
select(id)
},
}
return React.cloneElement(
child,
{
...interaction,
className: cn("relative isolate", props.className),
"data-highlighted": highlighted ? "" : undefined,
} as Partial<AnimatedBackgroundItemProps>,
<>
<AnimatePresence initial={false}>
{highlighted ? (
<motion.span
aria-hidden="true"
data-slot="animated-background-highlight"
layoutId={layoutId}
className={cn(
"pointer-events-none absolute inset-0 -z-10 bg-muted",
highlightClassName
)}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={reduceMotion ? { duration: 0 } : transition}
/>
) : null}
</AnimatePresence>
{props.children}
</>
)
})
}
export { AnimatedBackground }
属性 Props
AnimatedBackground 不渲染任何包裹元素,而是把高亮注入到每个直接子元素内部,因此可以直接放进已有的 flex、grid 或列表布局中:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | ReactElement | ReactElement[] | — | 需要高亮的元素,每个直接子元素都必须带有唯一的 `data-id`。组件会为其追加 `relative isolate` 类名。 |
| mode | "click" | "hover" | "click" | click:点击后高亮移动到该项;hover:高亮跟随悬停与键盘焦点,离开后回到当前选中项。 |
| value | string | null | — | 受控的当前项 id,传入 null 时隐藏高亮。 |
| defaultValue | string | null | null | 非受控模式下的初始选中项。 |
| highlightClassName | string | — | 高亮块的类名,用于设置背景色、圆角或边框。默认为 `bg-muted`。 |
| transition | Transition | { type: "spring", stiffness: 520, damping: 38, mass: 0.7 } | 高亮在元素之间移动时的过渡参数。 |
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onValueChange | (value: string) => void | — | 点击某一项时触发,参数为该项的 `data-id`。子元素原有的 onClick、onPointerEnter 等事件会被保留并先行调用。 |
使用场景与设计规范
AnimatedBackground 是一个通用的“共享高亮”原语:它不关心子元素是链接、按钮还是列表项,只负责把同一块背景在它们之间移动。
- 优先使用语义化组件:标准的标签页和分段选择请直接使用 标签页 Tabs 与 切换组 Toggle Group,它们已内置滑动指示器与完整的键盘交互。
- 适用场景:顶部导航的悬停反馈、设置菜单等纵向列表、卡片网格中的选择状态等需要自定义结构的场景。
- 状态要有文字层面的区分:高亮只是视觉提示,当前项仍应通过
aria-current、aria-pressed或文字颜色表达。组件会为高亮中的子元素添加data-highlighted属性,可配合data-highlighted:text-foreground使用。
场景示例
纵向设置列表
hover 模式配合纵向布局,高亮在不同高度的条目之间伸缩移动:
Loading…
网格选择
click 模式下高亮在二维网格中移动,适合地域、套餐等选择:
Loading…
无障碍与交互 Accessibility
- 键盘可达:
hover模式同时监听focus与blur,使用 Tab 键移动焦点时高亮会随之移动。 - 高亮不参与读屏:高亮元素设置了
aria-hidden与pointer-events: none,不会影响点击与读屏顺序。 - 减少动态效果:开启
prefers-reduced-motion时,高亮直接出现在目标位置,不再滑动。