组件
悬浮底栏 Dock
仿 macOS 风格的弹性放大底栏导航,随着鼠标经过产生丝滑的近邻放大动效。
基础示例 · Basic example
Loading…
pnpm dlx @wui-design/cli@latest add @wui/dock"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import {
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
type MotionValue,
} from "motion/react"
import { cn } from "@/lib/utils"
const dockVariants = cva(
"group/dock inline-flex gap-1.5 rounded-xl border bg-background p-1.5 shadow-sm",
{
variants: {
direction: {
horizontal: "flex-row items-end",
vertical: "flex-col items-start",
},
},
defaultVariants: {
direction: "horizontal",
},
}
)
interface DockContextValue {
pointer: MotionValue<number>
direction: "horizontal" | "vertical"
magnification: number
distance: number
}
const DockContext = React.createContext<DockContextValue | null>(null)
export interface DockProps
extends React.ComponentProps<"div">,
VariantProps<typeof dockVariants> {
/** 鼠标悬停时的最大放大尺寸(像素)。 @default 56 */
magnification?: number
/** 触发放大影响的鼠标距离阈值(像素)。 @default 120 */
distance?: number
/** 排列方向。 @default "horizontal" */
direction?: "horizontal" | "vertical"
}
/**
* macOS 风格的弹性放大底栏。图标沿垂直于排列方向的一侧放大溢出,底栏自身
* 尺寸保持不变,相邻图标被平滑推开。
*/
function Dock({
className,
direction = "horizontal",
magnification = 56,
distance = 120,
children,
onPointerMove,
onPointerLeave,
...props
}: DockProps) {
const pointer = useMotionValue(Infinity)
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
onPointerMove?.(event)
if (event.pointerType === "touch") return
pointer.set(direction === "horizontal" ? event.clientX : event.clientY)
}
const handlePointerLeave = (event: React.PointerEvent<HTMLDivElement>) => {
onPointerLeave?.(event)
pointer.set(Infinity)
}
const context = React.useMemo(
() => ({ pointer, direction, magnification, distance }),
[pointer, direction, magnification, distance]
)
return (
<DockContext.Provider value={context}>
<div
role="toolbar"
aria-orientation={direction}
data-slot="dock"
data-direction={direction}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
className={cn(dockVariants({ direction }), className)}
{...props}
>
{children}
</div>
</DockContext.Provider>
)
}
export interface DockItemProps extends Omit<
React.ComponentProps<typeof motion.button>,
"children"
> {
/** 基础默认尺寸(像素)。 @default 40 */
size?: number
/** 提示标签文案,同时作为无障碍名称。 */
label?: string
/** 标记为当前激活项,在图标下方显示指示点。 */
active?: boolean
/** 子元素内容,内部 SVG 图标会随尺寸等比放大。 */
children?: React.ReactNode
}
const springConfig = { mass: 0.1, stiffness: 170, damping: 14 }
function DockItem({
className,
size = 40,
label,
active = false,
children,
style,
...props
}: DockItemProps) {
const slotRef = React.useRef<HTMLDivElement>(null)
const fallbackPointer = useMotionValue(Infinity)
const context = React.useContext(DockContext)
const reduceMotion = useReducedMotion()
const pointer = context?.pointer ?? fallbackPointer
const direction = context?.direction ?? "horizontal"
const magnification = context?.magnification ?? 56
const distance = context?.distance ?? 120
// Measure the in-flow slot; the button is absolutely positioned inside it
// and overflows the dock as it grows.
const offset = useTransform(pointer, (value: number) => {
const bounds = slotRef.current?.getBoundingClientRect()
if (!bounds || !Number.isFinite(value)) return Infinity
const center =
direction === "horizontal"
? bounds.left + bounds.width / 2
: bounds.top + bounds.height / 2
return value - center
})
const targetSize = useTransform(
offset,
[-distance, 0, distance],
[size, magnification, size]
)
const animatedSize = useSpring(targetSize, springConfig)
const itemSize = reduceMotion ? size : animatedSize
return (
<motion.div
ref={slotRef}
data-slot="dock-item-slot"
className="relative shrink-0"
style={
direction === "horizontal"
? { width: itemSize, height: size }
: { width: size, height: itemSize }
}
>
<motion.button
type="button"
data-slot="dock-item"
data-active={active || undefined}
aria-label={label}
style={{ ...style, width: itemSize, height: itemSize }}
className={cn(
"group/dock-item absolute flex cursor-pointer items-center justify-center rounded-lg bg-muted/70 text-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[active]:bg-muted [&_svg]:size-[45%] [&_svg]:shrink-0",
direction === "horizontal"
? "bottom-0 left-1/2 -translate-x-1/2"
: "left-0 top-1/2 -translate-y-1/2",
className
)}
{...props}
>
{children}
{active ? (
<span
aria-hidden="true"
className={cn(
"bg-foreground/70 pointer-events-none absolute size-1 rounded-full",
direction === "horizontal"
? "-bottom-1.5 left-1/2 -translate-x-1/2"
: "-left-1.5 top-1/2 -translate-y-1/2"
)}
/>
) : null}
{label ? (
<span
aria-hidden="true"
className={cn(
"bg-foreground text-background pointer-events-none absolute whitespace-nowrap rounded-md px-2 py-1 text-xs font-medium opacity-0 transition-[opacity,translate] duration-150 ease-out group-hover/dock-item:opacity-100 group-focus-visible/dock-item:opacity-100",
direction === "horizontal"
? "bottom-full left-1/2 mb-2 -translate-x-1/2 translate-y-1 group-hover/dock-item:translate-y-0 group-focus-visible/dock-item:translate-y-0"
: "left-full top-1/2 ml-2 -translate-y-1/2 -translate-x-1 group-hover/dock-item:translate-x-0 group-focus-visible/dock-item:translate-x-0"
)}
>
{label}
</span>
) : null}
</motion.button>
</motion.div>
)
}
function DockSeparator({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
role="separator"
data-slot="dock-separator"
className={cn(
"bg-border shrink-0 self-center",
"group-data-[direction=horizontal]/dock:mx-1 group-data-[direction=horizontal]/dock:h-6 group-data-[direction=horizontal]/dock:w-px",
"group-data-[direction=vertical]/dock:my-1 group-data-[direction=vertical]/dock:h-px group-data-[direction=vertical]/dock:w-6",
className
)}
{...props}
/>
)
}
export { Dock, DockItem, DockSeparator, dockVariants }
组件作用 · What it's for
Dock 适合作为桌面端应用全局导航栏、画布控制台工具条或现代营销主页的常驻快捷入口。
适用场景
- 桌面级工作台快捷导航:高频工具的快速触达与悬浮切换。
- 弹簧距离插值:基于 Motion Spring 动态计算邻近项的放大比例,手感细腻跟手。
- 方向与尺寸自定义:支持
horizontal(水平)与vertical(垂直)两种布局。
何时不建议使用
- 在紧凑的移动端触屏视口中,建议使用标准
Tabs或底部导航栏以避免 hover 依赖。
组件属性 · Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| magnification | number | 56 | 鼠标悬停时的最大放大尺寸(像素)。 |
| distance | number | 120 | 触发放大影响的鼠标距离阈值(像素)。 |
| direction | "horizontal" | "vertical" | horizontal | 排列方向。 |
事件 · Events
DockItem继承原生按钮属性,支持标准的onClick事件。
拓展使用 · Extended usage
可结合 DockSeparator 划分功能模块,通过 active 标记当前所在的入口(图标下方显示指示点)。图标放大时底栏自身尺寸保持不变,图标沿垂直于排列方向的一侧溢出,相邻图标被平滑推开;内部 SVG 图标会随按钮尺寸等比放大。
垂直工具栏
direction="vertical" 适合画布类产品的左侧工具栏,提示标签显示在图标右侧:
Loading…
DockItem
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| size | number | 40 | 未放大时的边长(像素)。 |
| label | string | — | 悬停或键盘聚焦时显示的提示文字,同时作为按钮的无障碍名称。 |
| active | boolean | false | 标记为当前激活项,显示指示点。 |
无障碍
Dock渲染为role="toolbar"并声明aria-orientation。- 提示标签在键盘
focus-visible时同样显示;触屏指针不会触发放大。 - 开启“减少动态效果”时,图标保持固定尺寸。