wui
组件

聚光灯网格背景 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
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

属性类型默认值说明
childrenReact.ReactNode—渲染在网格背景之上的业务内容(卡片、标题、指标等)。
pattern"grid" | "dots""grid"背景几何图案类型。`grid` 为正交网格线,`dots` 为规则圆点阵列。
sizenumber28网格单格或点阵的单元尺寸(像素)。
radiusnumber240鼠标指针周围径向聚光灯照明的照射半径(像素)。
patternColorstring"currentColor"网格与点阵所使用的 CSS 颜色值(支持 CSS 变量如 var(--primary))。
baseOpacitynumber0.1未被聚光灯照射时常驻可见的底纹基础不透明度(0 到 1)。
fadeEdgesbooleanfalse常驻底纹是否向边缘径向淡出,避免网格被容器边界生硬截断。
smoothbooleantrue聚光灯是否以缓动方式追随指针。系统开启“减少动态效果”时自动关闭。
patternClassNamestring—应用于背景底纹图层和聚光图层的额外 CSS 类名。
classNamestring—应用于最外层容器的 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",不产生任何无障碍干扰。