组件
聚光灯网格背景 Spotlight Grid
随鼠标指针移动动态照亮周围网格或点阵阵列的高交互性背景组件。
基础用法
移动鼠标,光标周围的网格被聚光灯点亮,光斑带着轻微惯性追随指针。fadeEdges 让常驻网格向四周自然消隐:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/spotlight-grid安装基础依赖与动效库
pnpm add lucide-react class-variance-authority clsx tailwind-merge复制组件源码到
components/ui/spotlight-grid.tsx"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
export interface SpotlightGridProps extends React.ComponentProps<"div"> {
/** Content rendered above the pattern. */
children?: React.ReactNode
/** Background pattern. @default "grid" */
pattern?: "grid" | "dots"
/** Pattern cell size in pixels. @default 28 */
size?: number
/** Radius of the revealed pointer spotlight in pixels. @default 240 */
radius?: number
/** CSS color used by the pattern. @default "currentColor" */
patternColor?: string
/** Opacity of the always-visible pattern. @default 0.1 */
baseOpacity?: number
/** Fade the always-visible pattern out toward the edges. @default false */
fadeEdges?: boolean
/** Let the spotlight trail the pointer with eased motion instead of snapping to it. @default true */
smooth?: boolean
/** Classes applied to both pattern layers. */
patternClassName?: string
}
function patternImage(pattern: NonNullable<SpotlightGridProps["pattern"]>) {
return pattern === "dots"
? "radial-gradient(circle, currentColor 1px, transparent 1.2px)"
: "linear-gradient(currentColor 1px, transparent 1px), linear-gradient(90deg, currentColor 1px, transparent 1px)"
}
function prefersReducedMotion() {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
}
/** Reveals a grid or dot pattern around the current pointer position. */
function SpotlightGrid({
children,
pattern = "grid",
size = 28,
radius = 240,
patternColor = "currentColor",
baseOpacity = 0.1,
fadeEdges = false,
smooth = true,
className,
style,
patternClassName,
onPointerEnter,
onPointerMove,
onPointerLeave,
...props
}: SpotlightGridProps) {
const rootRef = React.useRef<HTMLDivElement>(null)
const target = React.useRef({ x: 0, y: 0 })
const current = React.useRef({ x: 0, y: 0 })
const frame = React.useRef(0)
const lastTime = React.useRef(0)
const backgroundImage = patternImage(pattern)
const backgroundSize = `${size}px ${size}px`
const edgeMask = "radial-gradient(ellipse at center, black 30%, transparent 80%)"
React.useEffect(() => () => cancelAnimationFrame(frame.current), [])
function paint() {
const root = rootRef.current
if (!root) return
root.style.setProperty("--grid-x", `${current.current.x}px`)
root.style.setProperty("--grid-y", `${current.current.y}px`)
}
function tick(time: number) {
const dt = Math.min((time - lastTime.current) / 1000, 0.064)
lastTime.current = time
const ease = 1 - Math.exp(-dt * 12)
current.current.x += (target.current.x - current.current.x) * ease
current.current.y += (target.current.y - current.current.y) * ease
paint()
const remaining =
Math.abs(target.current.x - current.current.x) +
Math.abs(target.current.y - current.current.y)
frame.current = remaining > 0.5 ? requestAnimationFrame(tick) : 0
}
function track(event: React.PointerEvent<HTMLDivElement>, jump: boolean) {
const rect = event.currentTarget.getBoundingClientRect()
target.current = {
x: event.clientX - rect.left,
y: event.clientY - rect.top,
}
if (jump || !smooth || prefersReducedMotion()) {
current.current = { ...target.current }
paint()
return
}
if (!frame.current) {
lastTime.current = performance.now()
frame.current = requestAnimationFrame(tick)
}
}
return (
<div
ref={rootRef}
data-slot="spotlight-grid"
data-pattern={pattern}
className={cn("group/grid relative isolate overflow-hidden", className)}
style={
{
"--grid-x": "50%",
"--grid-y": "50%",
...style,
} as React.CSSProperties
}
onPointerEnter={(event) => {
if (event.pointerType !== "touch") {
track(event, true)
rootRef.current?.setAttribute("data-spotlight", "on")
}
onPointerEnter?.(event)
}}
onPointerMove={(event) => {
if (event.pointerType !== "touch") track(event, false)
onPointerMove?.(event)
}}
onPointerLeave={(event) => {
rootRef.current?.removeAttribute("data-spotlight")
onPointerLeave?.(event)
}}
{...props}
>
<div
aria-hidden="true"
data-slot="spotlight-grid-pattern"
className={cn(
"pointer-events-none absolute inset-0 -z-10",
patternClassName
)}
style={{
color: patternColor,
opacity: baseOpacity,
backgroundImage,
backgroundSize,
maskImage: fadeEdges ? edgeMask : undefined,
}}
/>
<div
aria-hidden="true"
data-slot="spotlight-grid-reveal"
className={cn(
"pointer-events-none absolute inset-0 -z-10 opacity-0 transition-opacity duration-300 group-data-[spotlight=on]/grid:opacity-100",
patternClassName
)}
style={{
color: patternColor,
backgroundImage,
backgroundSize,
maskImage: `radial-gradient(circle ${radius}px at var(--grid-x) var(--grid-y), black, transparent)`,
}}
/>
{children}
</div>
)
}
export { SpotlightGrid }
属性 Props
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | React.ReactNode | — | 渲染在网格背景之上的业务内容(卡片、标题、指标等)。 |
| pattern | "grid" | "dots" | "grid" | 背景几何图案类型。`grid` 为正交网格线,`dots` 为规则圆点阵列。 |
| size | number | 28 | 网格单格或点阵的单元尺寸(像素)。 |
| radius | number | 240 | 鼠标指针周围径向聚光灯照明的照射半径(像素)。 |
| patternColor | string | "currentColor" | 网格与点阵所使用的 CSS 颜色值(支持 CSS 变量如 var(--primary))。 |
| baseOpacity | number | 0.1 | 未被聚光灯照射时常驻可见的底纹基础不透明度(0 到 1)。 |
| fadeEdges | boolean | false | 常驻底纹是否向边缘径向淡出,避免网格被容器边界生硬截断。 |
| smooth | boolean | true | 聚光灯是否以缓动方式追随指针。系统开启“减少动态效果”时自动关闭。 |
| patternClassName | string | — | 应用于背景底纹图层和聚光图层的额外 CSS 类名。 |
| className | string | — | 应用于最外层容器的 CSS 类名。 |
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onPointerEnter | (event: React.PointerEvent) => void | — | 光标移入容器并激活聚光灯时触发。 |
| onPointerMove | (event: React.PointerEvent) => void | — | 光标在容器内移动、实时更新聚光灯 CSS 变量坐标时触发。 |
| onPointerLeave | (event: React.PointerEvent) => void | — | 光标离开容器、聚光灯淡出时触发。 |
使用场景与设计规范
SpotlightGrid 适用于科技感控制台卡片、开发者工具看板、产品特性介绍区:
- CSS 变量动态驱动:坐标通过根节点上的
--grid-x与--grid-y注入至mask-image,不产生 React 重渲染;缓动循环在光斑到位后自动停止。 - 触控屏自动静默:检测到
touch触控类型时不会显示不自然的固定光斑,保持移动端清爽。
场景示例
点阵模式(Dots)
切换为圆点阵列,并使用主题图表色,适合上传区域等需要轻微引导的空状态:
Loading…
无障碍与交互 Accessibility
- 层级穿透:聚光图层全部为
pointer-events-none,底层与内部的表单项、按钮可以正常操作。 - 无障碍树忽略:网格背景图层默认标记
aria-hidden="true",不产生任何无障碍干扰。