光斑卡片
Spotlight Card光跟着指针走,卡片边缘一起亮起
完整版:适合从零新建组件,规格、契约、验收全都在。
实时预览
指针交互一束光跟着你的指针走
把它移到卡片上:光斑跟着走,边缘同时亮起,卡片朝你微微倾斜。
参数
边调边看,提示词同步更新。
角色
你是一名资深前端工程师,工作在现有的项目中,技术栈是 React 19 + TypeScript + Tailwind CSS v4 + Motion v14(motion 包)。只实现下面这一个微交互:不要加额外功能,不要顺手重构周围代码。
任务
实现光斑卡片:一束 220px 的柔和径向光跟随指针在卡片表面移动,并同步点亮 1px 边框;卡片朝指针方向倾斜最多 6 度,悬停时缩放到 1.02。
文件契约
- 只新建一个文件:components/effects/SpotlightCard.tsx。
- 依赖:项目已安装 motion@14,从 motion/react 引入。不要新增任何其他包。
- 不要修改 package.json、全局 CSS、Tailwind 配置或任何其他已有文件。
实现要点
- 文件顶部加 "use client"。
- 用 useMotionValue 保存指针位置(百分比字符串)和光斑透明度;用 useSpring 驱动 rotateX / rotateY / scale;用 useMotionTemplate 生成 radial-gradient 背景。
- 边框高光用 1px padding + mask-composite: exclude(WebKit 下 WebkitMaskComposite: "xor")把渐变裁到 1px 边框上。
- 导出 SpotlightCard,props 为 spotlightSize、intensity、tilt、scale、borderHighlight、title、description、footer、className。
规格 —— 精确数值,不许四舍五入或“优化”
- 卡片:宽 100%、最大 400px,高 260px,圆角 20px,背景 #0C0E11,1px 实线边框 rgba(255, 255, 255, 0.08),内边距 28px。
- 透视:在被变换的元素上设置 900px(transformPerspective)。
- 倾斜:rotateY = ((指针X / 宽度) − 0.5) × 2 × 6deg;rotateX = (0.5 − 指针Y / 高度) × 2 × 6deg。
- 倾斜与缩放共用弹簧:刚度 200、阻尼 24、质量 1。
- 悬停缩放:指针在卡片内时 1.02,离开回到 1。
- 光斑:radial-gradient(220px circle at <指针位置>, rgba(201, 244, 90, 0.22), transparent 65%),指针位置用卡片内的百分比写入 CSS 变量 --mx / --my。
- 边框高光:1px 边框用同一束径向渐变绘制,颜色 rgba(201, 244, 90, 0.65)、圆心跟随指针;用 1px padding + mask-composite: exclude(WebKit 用 WebkitMaskComposite: "xor")把渐变裁到边框上。
- 内容:眉标 10px 等宽、字距 0.18em、颜色 #6A6D74;标题 24px / 600 / 1.1,颜色 #EDEEF0;描述 13.5px / 1.7,颜色 #9A9CA1,最多 30ch。
- 只允许改变 transform、opacity,以及不触发重排的背景绘制。
行为
- 指针事件绑在卡片自身上;指针位置不要走 React state(用 motion value 或 CSS 变量)。
- pointerenter:光斑淡入、缩放到设定值;pointerleave:倾斜与缩放弹回原位、光斑淡出。
- 触屏:光斑跟随触点,但不倾斜、不缩放。
- 键盘:卡片内的 CTA 必须是真实按钮/链接,focus-visible 显示 2px #C9F45A、外扩 3px 的描边;卡片聚焦时不倾斜。
- prefers-reduced-motion: reduce:不倾斜、不缩放;保留居中的静态光斑(不跟随指针、不做淡入动画)与边框高光。
- 指针快速移动时不能堆积动画或抖动;中途离开卡片必须回到静止状态。
- 卸载时清理监听与动画;不要每帧触发状态更新。
禁止
- 光斑不要用 canvas、WebGL 或图片实现。
- 不要用动画改变 width / height / top / left / margin / padding / box-shadow。
- 弹簧驱动 transform 时不要再叠加 CSS transition。
- 不要留 TODO、不要写 // 其余代码、不要用 any、不要 console.log、不要留注释掉的实验代码。
- 不要把组件包进页面、布局、演示壳或 Provider。
- 不要改动全局 CSS、Tailwind 配置或上面文件之外的任何文件。
- 除 Motion 外不要引入任何动画库,更不要用 GSAP。
验收 —— 回答前逐项自查
1. 光斑跟随指针,边框高光同步点亮。
2. 倾斜方向与指针位置一致,离开后约 0.5s 内平滑归位。
3. 悬停缩放符合规格,离开后缩放回到 1、旋转回到 0。
4. 卡片内的 CTA 可点击,键盘聚焦有可见描边。
5. 只有 transform / opacity / 绘制变化,DevTools 中零布局位移。
6. 触屏不倾斜;prefers-reduced-motion 下关闭倾斜与缩放,但保留光斑与边框高光。
7. 渲染数值与规格完全一致:光斑尺寸、强度、倾斜、缩放。
输出
- 用一个代码块输出完整文件内容,不要 diff、不要省略号,代码块之前不要解释。
- 如果缺少必要依赖,先停下来问我,不要擅自安装。
- 代码块之后最后输出一行:self-check: 1 ✓ · 2 ✓ · 3 ✓ · 4 ✓ · 5 ✓ · 6 ✓ · 7 ✓,未通过的项用 ✗ 并附一句原因。
"use client";
import {
motion,
useMotionTemplate,
useMotionValue,
useReducedMotion,
useSpring,
} from "motion/react";
import { useEffect, useRef, type ReactNode } from "react";
import { SPOTLIGHT as CARD } from "@/content/effects/spotlight-card/constants";
export type SpotlightCardProps = {
eyebrow?: string;
title: string;
description: string;
footer?: ReactNode;
/** diameter of the spotlight, in px */
spotlightSize?: number;
/** alpha of the spotlight color, 0–1 */
intensity?: number;
/** max tilt toward the pointer, in degrees */
tilt?: number;
/** hover scale, e.g. 1.02 */
scale?: number;
/** paint the 1px border with the spotlight gradient too */
borderHighlight?: boolean;
className?: string;
};
const clampPct = (value: number) => Math.min(Math.max(value, 0), 100);
export function SpotlightCard({
eyebrow,
title,
description,
footer,
spotlightSize = CARD.defaults.size,
intensity = CARD.defaults.intensity,
tilt = CARD.defaults.tilt,
scale = CARD.defaults.scale,
borderHighlight = CARD.defaults.borderHighlight,
className,
}: SpotlightCardProps) {
const cardRef = useRef<HTMLDivElement>(null);
const hoveredRef = useRef(false);
const reduced = useReducedMotion();
const mx = useMotionValue<string>("50%");
const my = useMotionValue<string>("50%");
const glowOpacity = useSpring(0, CARD.glowSpring);
const rotateX = useSpring(0, CARD.spring);
const rotateY = useSpring(0, CARD.spring);
const cardScale = useSpring(1, CARD.spring);
const glowColor = `rgba(${CARD.spotlight.r}, ${CARD.spotlight.g}, ${CARD.spotlight.b}, ${intensity})`;
const glowBackground = useMotionTemplate`radial-gradient(${spotlightSize}px circle at ${mx} ${my}, ${glowColor}, transparent 65%)`;
const borderBackground = useMotionTemplate`radial-gradient(${spotlightSize}px circle at ${mx} ${my}, ${CARD.borderGlow}, transparent 70%)`;
useEffect(() => {
if (reduced) {
// Reduced motion keeps a static, centered glow and no tilt / scale.
glowOpacity.set(1);
rotateX.set(0);
rotateY.set(0);
cardScale.set(1);
return;
}
if (!hoveredRef.current) glowOpacity.set(0);
}, [cardScale, glowOpacity, reduced, rotateX, rotateY]);
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (reduced) return;
const rect = cardRef.current?.getBoundingClientRect();
if (!rect) return;
const px = clampPct(((event.clientX - rect.left) / rect.width) * 100);
const py = clampPct(((event.clientY - rect.top) / rect.height) * 100);
mx.set(`${px}%`);
my.set(`${py}%`);
if (event.pointerType !== "touch") {
rotateY.set(((px - 50) / 50) * tilt);
rotateX.set(((50 - py) / 50) * tilt);
}
};
const handlePointerEnter = (event: React.PointerEvent<HTMLDivElement>) => {
hoveredRef.current = true;
glowOpacity.set(1);
if (reduced) return;
if (event.pointerType !== "touch") cardScale.set(scale);
};
const handlePointerLeave = () => {
hoveredRef.current = false;
rotateX.set(0);
rotateY.set(0);
cardScale.set(1);
glowOpacity.set(reduced ? 1 : 0);
};
return (
<motion.div
ref={cardRef}
data-demo="spotlight-card"
onPointerEnter={handlePointerEnter}
onPointerMove={handlePointerMove}
onPointerLeave={handlePointerLeave}
style={{
rotateX,
rotateY,
scale: cardScale,
transformPerspective: CARD.card.perspective,
background: CARD.card.bg,
borderColor: CARD.card.border,
borderRadius: CARD.card.radius,
height: CARD.card.height,
maxWidth: CARD.card.maxWidth,
padding: CARD.card.padding,
}}
className={[
"relative flex w-full flex-col justify-between overflow-hidden border",
"will-change-transform select-none",
className ?? "",
].join(" ")}
>
<motion.div
aria-hidden
data-spotlight-glow
className="pointer-events-none absolute inset-0"
style={{ background: glowBackground, opacity: glowOpacity }}
/>
{borderHighlight ? (
<motion.div
aria-hidden
data-spotlight-border
className="pointer-events-none absolute inset-0"
style={{
padding: 1,
borderRadius: CARD.card.radius,
background: borderBackground,
WebkitMask:
"linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
WebkitMaskComposite: "xor",
maskComposite: "exclude",
}}
/>
) : null}
<div className="relative z-10 flex items-center justify-between">
<span
className="font-mono uppercase"
style={{
fontSize: CARD.content.eyebrowSize,
letterSpacing: CARD.content.eyebrowTracking,
color: CARD.content.eyebrowColor,
}}
>
{eyebrow}
</span>
<span className="flex gap-1.5" aria-hidden>
<span className="h-1 w-1 rounded-full bg-white/20" />
<span className="h-1 w-1 rounded-full bg-white/20" />
<span className="h-1 w-1 rounded-full bg-white/20" />
</span>
</div>
<div className="relative z-10">
<h3
style={{
fontSize: CARD.content.titleSize,
fontWeight: CARD.content.titleWeight,
lineHeight: CARD.content.titleLineHeight,
letterSpacing: "-0.02em",
color: CARD.card.fg,
}}
>
{title}
</h3>
<p
className="mt-2.5"
style={{
fontSize: CARD.content.descriptionSize,
lineHeight: CARD.content.descriptionLineHeight,
maxWidth: `${CARD.content.descriptionMaxCh}ch`,
color: CARD.card.muted,
}}
>
{description}
</p>
</div>
<div className="relative z-10 flex items-center justify-between">
{footer}
</div>
</motion.div>
);
}
构建时从真实文件读取 —— 这是你真的在看的那份代码,不是另写的示例。
技术规格
指针在卡片表面移动时,一束柔和的径向光跟随指针,1px 边框同步点亮;卡片朝指针方向轻微倾斜,离开后弹回原位。位置只用 CSS 变量与 transform 驱动,不触发重排。
- 光斑尺寸
- 220 px
- 光斑强度
- 0.22
- 倾斜角度
- 6 °
- 悬停缩放
- 1.02 ×
- 边框高光
- 开启
- 卡片
- 400 × 260 px
- 圆角
- 20px
- 透视
- 900px
- 光斑颜色
- rgba(201, 244, 90, 0.22)
- 光斑直径
- 220px
- 边框高光颜色
- rgba(201, 244, 90, 0.65)
- 弹簧
- 200 / 24
- 内容内边距
- 28px
验收清单
提示词要求 AI 在输出末尾逐条自检。
- 光斑跟随指针,边框高光同步点亮。
- 倾斜方向与指针位置一致,离开后约 0.5s 内平滑归位。
- 悬停缩放符合规格,离开后缩放回到 1、旋转回到 0。
- 卡片内的 CTA 可点击,键盘聚焦有可见描边。
- 只有 transform / opacity / 绘制变化,DevTools 中零布局位移。
- 触屏不倾斜;prefers-reduced-motion 下关闭倾斜与缩放,但保留光斑与边框高光。
- 渲染数值与规格完全一致:光斑尺寸、强度、倾斜、缩放。
待回归验证提示词与演示已同源生成;尚未在真实 Cursor / Claude Code 项目中做回归。
提示词如何还原这个效果
不是把效果描写一遍,而是把效果写成一份可执行的工程需求。
- 数字规格
- 每个像素、每个时间参数都来自同一个参数源——滑块改什么,提示词就写什么。
- 文件契约
- 只允许新建指定文件、指定依赖白名单,避免它顺手改你的全局样式或装一堆包。
- 验收清单
- 提示词末尾强制 AI 自检布局位移、键盘可用、reduced-motion 等 7 项。