wui
组件

聚光卡片 Spotlight Card

根据光标指针移动在卡片表面渲染跟随式局部径向光照,为静态卡片注入灵动的空间立体感。

基础用法

光标在卡片上移动时,柔和的光晕带着轻微惯性跟随指针,borderColor 同时点亮指针附近的边缘:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/spotlight-card
安装基础依赖与工具函数
pnpm add clsx tailwind-merge
复制组件源码到 components/ui/spotlight-card.tsx
components/ui/spotlight-card.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export interface SpotlightCardProps extends React.ComponentProps<"div"> {
  /** Content rendered above the pointer spotlight. */
  children: React.ReactNode
  /** Spotlight radius in pixels. @default 220 */
  radius?: number
  /** CSS color used at the center of the spotlight. @default "color-mix(in oklab, var(--foreground) 16%, transparent)" */
  color?: string
  /** CSS color of a 1px edge highlight that follows the pointer. Omit to disable. */
  borderColor?: string
  /** Let the light trail the pointer with eased motion instead of snapping to it. @default true */
  smooth?: boolean
  /** Classes applied to the spotlight layer. */
  spotlightClassName?: string
}

const edgeMask: React.CSSProperties = {
  maskImage: "linear-gradient(#000 0 0), linear-gradient(#000 0 0)",
  maskClip: "content-box, border-box",
  maskOrigin: "content-box, border-box",
  maskComposite: "exclude",
}

function prefersReducedMotion() {
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches
}

/** Illuminates a surface around the current pointer position. */
function SpotlightCard({
  children,
  radius = 220,
  color = "color-mix(in oklab, var(--foreground) 16%, transparent)",
  borderColor,
  smooth = true,
  className,
  style,
  spotlightClassName,
  onPointerEnter,
  onPointerMove,
  onPointerLeave,
  ...props
}: SpotlightCardProps) {
  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)

  React.useEffect(() => () => cancelAnimationFrame(frame.current), [])

  function paint() {
    const root = rootRef.current
    if (!root) return
    root.style.setProperty("--spotlight-x", `${current.current.x}px`)
    root.style.setProperty("--spotlight-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 * 14)
    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-card"
      className={cn("group/spotlight relative isolate overflow-hidden", className)}
      style={
        {
          "--spotlight-x": "50%",
          "--spotlight-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-card-light"
        className={cn(
          "pointer-events-none absolute inset-0 -z-10 opacity-0 transition-opacity duration-300 group-data-[spotlight=on]/spotlight:opacity-100",
          spotlightClassName
        )}
        style={{
          background: `radial-gradient(circle ${radius}px at var(--spotlight-x) var(--spotlight-y), ${color}, transparent 72%)`,
        }}
      />
      {borderColor ? (
        <div
          aria-hidden="true"
          data-slot="spotlight-card-edge"
          className="pointer-events-none absolute inset-0 z-10 rounded-[inherit] p-px opacity-0 transition-opacity duration-300 group-data-[spotlight=on]/spotlight:opacity-100"
          style={{
            background: `radial-gradient(circle ${radius * 0.75}px at var(--spotlight-x) var(--spotlight-y), ${borderColor}, transparent 70%)`,
            ...edgeMask,
          }}
        />
      ) : null}
      {children}
    </div>
  )
}

export { SpotlightCard }

属性 Props

SpotlightCard 支持以下配置属性,并继承原生 <div> 容器的所有 HTML 属性:

属性类型默认值说明
childrenReact.ReactNode—卡片内部渲染的内容元素(文字、图标、封面图片等)。
radiusnumber220聚光灯的照射光晕半径(像素 px)。
colorstring"color-mix(in oklab, var(--foreground) 16%, transparent)"聚光灯中心点的 CSS 颜色值,支持透明度颜色或主题变量。
borderColorstring—跟随指针的 1px 边缘高光颜色。不传则不渲染边缘高光。
smoothbooleantrue光晕是否以缓动方式追随指针。关闭后光晕直接吸附到指针位置;系统开启“减少动态效果”时自动关闭。
spotlightClassNamestring—应用于内部聚光光斑图层的额外 CSS 类名。
classNamestring—应用于卡片外层容器的额外 CSS 类名。

事件 Events

SpotlightCard 继承原生容器的事件回调,并在内部优化了指针位移计算:

属性类型默认值说明
onPointerEnter(event: React.PointerEvent<HTMLDivElement>) => void—光标指针进入卡片区域时触发,内部同步淡入聚光层。
onPointerMove(event: React.PointerEvent<HTMLDivElement>) => void—光标指针在卡片内移动时触发,内部通过 requestAnimationFrame 缓动更新光斑坐标 CSS 变量。
onPointerLeave(event: React.PointerEvent<HTMLDivElement>) => void—光标指针离开卡片区域时触发,内部自动淡出聚光层。

使用场景与设计规范

SpotlightCard 是打造现代高质感 SaaS 首页与核心产品功能网格的利器。

  • 适用场景:
    • 核心功能亮点网格(Features Grid):在产品矩阵展示中,为每个功能卡片添加低饱和度微光,增强探索欲。
    • 作品集与案例展示(Portfolio):鼠标滑动时产生渐进式照明,提升视觉沉浸感。
    • 定价与方案卡片:突出重点套餐或企业方案。
  • 性能与渲染优势:
    • 坐标写入卡片根节点的 CSS 自定义属性(--spotlight-x、--spotlight-y),光晕与边缘高光共享同一组变量;整个过程不触发 React 重渲染,缓动循环在光晕到位后自动停止。
  • 色彩与饱和度建议:
    • 聚光属于辅助视觉层,光晕透明度应保持在 10%~20% 之间,避免过于刺眼影响卡片中文字内容的可读性。

场景示例

指标卡配色

为不同指标分别传入主题图表色,光晕与边缘高光同色呼应:

Loading…

无障碍与交互 Accessibility

  • 非阻断式视觉层:内部的光晕图层标注为 aria-hidden="true" 并配置 pointer-events-none,绝不拦截卡片内部按钮、超链接的点击与键盘交互。
  • 移动端触控友好:触摸(Touch)设备上不会出现生硬的光斑闪烁,保持清晰优雅的静态卡片呈现。
  • 高对比度文字保障:卡片内部的标题与正文颜色不受光晕叠加的负面影响,始终满足 WCAG AA 级对比度要求。