# 聚光卡片（Border Glow + Inner Spotlight Card）

- 类型 / 材质：`card` / `spotlight`（聚光）
- 状态：done ｜ 最后提交：2026-11-01
- 灵感来源：Vercel Templates 的边框流光 + Linear 官网的内光斑（实测调研）
- 依据标准：`/design#material` ｜ 组件页：`/showcase/component/card/spotlight` ｜ 条目：`/r/card--spotlight.json`
- 必缺状态：（无必缺状态）
- 整页场景：尚未接入

## 规格

- 外层渐变边框跟随鼠标位置，白底盖住中心只露 1px 光边
- 内层 7% accent 微光斑压在内容之下
- 光层可单独开合

## 参数（CSS 变量）

`--color-accent` `--color-surface` `--color-text` `--color-text-muted` `--dur-slow` `--fw-semibold` `--glass-border` `--mx` `--my` `--spot-on`

## Props 契约

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `title` | `string` | 可选 | — |
| `subtitle` | `string` | 可选 | — |
| `children` | `ReactNode` | 可选 | — |
| `onClick` | `() => void` | 可选 | — |
| `glow` | `'border' | 'inner' | 'both'` | 可选 | border = 边框流光，inner = 内光斑，both = 双层全开 |
| `className` | `string` | 可选 | — |

## 提示词（可直接粘给智能体）

```
复现「聚光卡片」— Border Glow + Inner Spotlight Card（卡片 / Spotlight）
灵感来源：Vercel Templates 的边框流光 + Linear 官网的内光斑（实测调研）
规格：
- 外层渐变边框跟随鼠标位置，白底盖住中心只露 1px 光边
- 内层 7% accent 微光斑压在内容之下
- 光层可单独开合
参数（CSS 变量）：--color-accent --color-surface --color-text --color-text-muted --dur-slow --fw-semibold --glass-border --mx --my --spot-on
必缺状态：（无必缺状态）
整页场景：（尚未接进整页场景）
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/card/spotlight · 标准 material /design#material · 调研笔记 /showcase/component/card/spotlight#notes
```

## 调研笔记

import { useRef, type ReactNode, type MouseEvent } from 'react';

interface Props {
  title?: string;
  subtitle?: string;
  children?: ReactNode;
  onClick?: () => void;
  /** border = 边框流光，inner = 内光斑，both = 双层全开 */
  glow?: 'border' | 'inner' | 'both';
  className?: string;
}

export function SpotlightCard({
  title,
  subtitle,
  children,
  onClick,
  glow = 'both',
  className = '',
}: Props) {
  const ref = useRef<HTMLDivElement>(null);

  const setSpot = (on: boolean, e?: MouseEvent) => {
    const el = ref.current;
    if (!el) return;
    el.style.setProperty('--spot-on', on ? '1' : '0');
    if (on && e) {
      const r = el.getBoundingClientRect();
      el.style.setProperty('--mx', `${e.clientX - r.left}px`);
      el.style.setProperty('--my', `${e.clientY - r.top}px`);
    }
  };

  const hasBorder = glow === 'border' || glow === 'both';
  const hasInner = glow === 'inner' || glow === 'both';

  return (
    <div
      ref={ref}
      onMouseMove={(e) => setSpot(true, e)}
      onMouseLeave={() => setSpot(false)}
      onClick={onClick}
      role={onClick ? 'button' : undefined}
      tabIndex={onClick ? 0 : undefined}
      onKeyDown={onClick ? (e) => {
        if (e.key === 'Enter' || e.key === ' ') {
          e.preventDefault();
          onClick();
        }
      } : undefined}
      style={{ '--spot-on': '0', '--mx': '50%', '--my': '50%' } as React.CSSProperties}
      className={`
        group relative rounded-2xl p-px overflow-hidden
        bg-[var(--glass-border)]
        ${onClick ? 'cursor-pointer' : ''}
        ${className}
      `}
    >
      {/* 边框流光层：跟随鼠标的 accent 渐变，被内层盖住中心，只露 1px 光边 */}
      {hasBorder && (
        <div
          aria-hidden="true"
          className="absolute inset-0 rounded-2xl pointer-events-none transition-opacity duration-[var(--dur-slow)]"
          style={{
            opacity: 'var(--spot-on)',
            background:
              'radial-gradient(220px circle at var(--mx) var(--my), var(--color-accent), transparent 70%)',
          }}
        />
      )}

      {/* 内容层：白底盖住中心 */}
      <div className="relative rounded-[calc(1rem-1px)] bg-[var(--color-surface)] overflow-hidden">
        {/* 内光斑层：低透明度 accent，位于内容之下 */}
        {hasInner && (
          <div
            aria-hidden="true"
            className="absolute inset-0 pointer-events-none transition-opacity duration-[var(--dur-slow)]"
            style={{
              opacity: 'var(--spot-on)',
              background:
                'radial-gradient(260px circle at var(--mx) var(--my), color-mix(in srgb, var(--color-accent) 7%, transparent), transparent 70%)',
            }}
          />
        )}

        <div className="relative p-6">
          {(title || subtitle) && (
            <div className="mb-4">
              {title && (
                <h3 className="text-base" style={{ fontWeight: 'var(--fw-semibold)' }}>
                  {title}
                </h3>
              )}
              {subtitle && (
                <p className="text-sm text-[var(--color-text-muted)] mt-1">{subtitle}</p>
              )}
            </div>
          )}
          {children}
        </div>
      </div>
    </div>
  );
}
