组件
聚光卡片 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"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 属性:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| children | React.ReactNode | — | 卡片内部渲染的内容元素(文字、图标、封面图片等)。 |
| radius | number | 220 | 聚光灯的照射光晕半径(像素 px)。 |
| color | string | "color-mix(in oklab, var(--foreground) 16%, transparent)" | 聚光灯中心点的 CSS 颜色值,支持透明度颜色或主题变量。 |
| borderColor | string | — | 跟随指针的 1px 边缘高光颜色。不传则不渲染边缘高光。 |
| smooth | boolean | true | 光晕是否以缓动方式追随指针。关闭后光晕直接吸附到指针位置;系统开启“减少动态效果”时自动关闭。 |
| spotlightClassName | string | — | 应用于内部聚光光斑图层的额外 CSS 类名。 |
| className | string | — | 应用于卡片外层容器的额外 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 重渲染,缓动循环在光晕到位后自动停止。
- 坐标写入卡片根节点的 CSS 自定义属性(
- 色彩与饱和度建议:
- 聚光属于辅助视觉层,光晕透明度应保持在
10%~20%之间,避免过于刺眼影响卡片中文字内容的可读性。
- 聚光属于辅助视觉层,光晕透明度应保持在
场景示例
指标卡配色
为不同指标分别传入主题图表色,光晕与边缘高光同色呼应:
Loading…
无障碍与交互 Accessibility
- 非阻断式视觉层:内部的光晕图层标注为
aria-hidden="true"并配置pointer-events-none,绝不拦截卡片内部按钮、超链接的点击与键盘交互。 - 移动端触控友好:触摸(Touch)设备上不会出现生硬的光斑闪烁,保持清晰优雅的静态卡片呈现。
- 高对比度文字保障:卡片内部的标题与正文颜色不受光晕叠加的负面影响,始终满足 WCAG AA 级对比度要求。