カード
SpotlightCard
CSS
Linear / Vercel 風のスポットライトカード — ポインターを追う放射状の光が 1px の枠線と面を照らします。SpotlightGrid で包むと、近づいた隣のカードの枠線まで光ります。純粋な CSS + rAF。
高速なビルド
変更したファイルだけを再ビルド。数千ページでも数秒で反映されます。
エッジ配信
世界中のエッジから静的アセットと API を配信し、遅延を最小に。
型安全
スキーマから型を生成。フロントとバックエンドの食い違いを未然に防ぎます。
自動スケール
アクセスの急増にも設定なしで追従。使った分だけの課金です。
AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約26 KB)。
# SpotlightCard をこのプロジェクトに追加してください
このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。
## コンポーネントについて
- 名前: SpotlightCard
- 説明: Linear / Vercel 風のスポットライトカード — ポインターを追う放射状の光が 1px の枠線と面を照らします。SpotlightGrid で包むと、近づいた隣のカードの枠線まで光ります。純粋な CSS + rAF。
- 構成ファイル: `components/spotlight-card/index.tsx`, `components/spotlight-card/SpotlightCard.module.css`
## 前提と注意
- React 19 / TypeScript / Next.js(App Router)のプロジェクトを想定しています。Next.js 以外の React プロジェクト(Vite など)でもそのまま動きます。
- スタイルは同梱の CSS Modules(ある場合)で自己完結しており、Tailwind CSS の有無や設定には依存しません。Tailwind の設定変更やグローバル CSS の追加は、この依頼文に明記された分だけにしてください。
- ブラウザ API やインタラクションを使うクライアントコンポーネントです。ファイル先頭の "use client" を維持してください。
- コンポーネント自体の背景は透過です。配置先ページの背景がそのまま透けて見える前提で作られているので、白背景や単色の `div` などで囲わず、コンポーネントだけをそのまま配置してください。
- ソースコードの内容は変更しないでください。例外は、`@/` パスエイリアスが無いプロジェクトで import パスを相対パスに直す場合だけです。
## 手順
### 1. 依存パッケージを追加する
追加の npm パッケージは不要です(React のみ)。
### 2. ファイルを作成する
`@/components` が指すディレクトリ(多くは `src/components/` か `components/`。shadcn の `components.json` があれば `aliases.components` の指す場所)に、以下の 2 ファイルを**一字一句そのまま**作成してください。
#### `components/spotlight-card/index.tsx`
```tsx
"use client";
import {
createContext,
useContext,
useEffect,
useRef,
type ComponentType,
type CSSProperties,
type ElementType,
type HTMLAttributes,
type ReactNode,
type RefObject,
} from "react";
import styles from "./SpotlightCard.module.css";
/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;
type PassThrough = Omit<HTMLAttributes<HTMLElement>, "children" | "className"> & {
/** Forwarded as-is so `as="a"` can be a real link. */
href?: string;
};
export type SpotlightCardProps = PassThrough & {
/** Light of the spotlight on the border and (softer) on the surface. */
spotlightColor?: string;
/** Resting 1px border color, visible where the spotlight isn't. */
borderColor?: string;
/** Diameter of the surface glow in px. The border glow is ~60% of it. */
spotlightSize?: number;
/** 0–1. Peak opacity under the pointer; the idle glow is ~45% of it. */
intensity?: number;
/** Corner radius in px. */
radius?: number;
children?: ReactNode;
className?: string;
/** Element to render, e.g. "article", "li" or "a". Default "div". */
as?: ElementType;
};
export type SpotlightGridProps = Omit<HTMLAttributes<HTMLElement>, "className"> & {
children?: ReactNode;
className?: string;
/** Element to render, e.g. "ul" with `as="li"` cards. Default "div". */
as?: ElementType;
};
const DEFAULT_SPOTLIGHT_COLOR = "#c4b5fd";
const DEFAULT_BORDER_COLOR = "#26262b";
const DEFAULT_SPOTLIGHT_SIZE = 420;
const DEFAULT_INTENSITY = 1;
const DEFAULT_RADIUS = 18;
/**
* Idle spot of a grid, as a fraction of its size: the centre seam, so the
* inner corners of every card catch some light at rest (Linear-style).
*/
const GRID_IDLE_X = 0.5;
const GRID_IDLE_Y = 0.5;
/**
* `as` accepts any element, so TypeScript can't know its props; render it
* through this loose alias (the public props are still typed above).
*/
type LooseElement = ComponentType<Record<string, unknown>>;
/** True for cards inside a SpotlightGrid: the grid tracks the pointer. */
const GroupContext = createContext(false);
/**
* Pointer-follow plumbing shared by the card and the grid. Pointer events
* only record the latest position; the CSS variables are written at most
* once per frame, and nothing goes through React state.
*/
function usePointerSpot(
ref: RefObject<HTMLElement | null>,
enabled: boolean,
write: (el: HTMLElement, x: number, y: number) => void,
) {
// Kept in a ref so a new `write` closure doesn't re-bind the listeners.
const writeRef = useRef(write);
useEffect(() => {
writeRef.current = write;
});
useEffect(() => {
const el = ref.current;
if (!el || !enabled) return;
let frame = 0;
let x = 0;
let y = 0;
const flush = () => {
frame = 0;
writeRef.current(el, x, y);
};
const onMove = (event: PointerEvent) => {
// Touch has no hover: a tap would just jump the light. Keep idle.
if (event.pointerType === "touch") return;
x = event.clientX;
y = event.clientY;
el.dataset.active = "";
if (!frame) frame = requestAnimationFrame(flush);
};
const onLeave = () => {
// The light stays where it was and dims back to the idle level.
delete el.dataset.active;
};
el.addEventListener("pointermove", onMove, { passive: true });
el.addEventListener("pointerleave", onLeave, { passive: true });
return () => {
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerleave", onLeave);
cancelAnimationFrame(frame);
delete el.dataset.active;
};
}, [ref, enabled]);
}
/**
* Linear / Vercel-style feature card: a dark surface with a 1px border, and
* a radial spotlight that follows the pointer — sharp on the border, soft
* and wide on the surface. At rest the light is parked top-left at reduced
* strength, so the card never renders flat (first paint, touch, no JS).
*/
export function SpotlightCard({
spotlightColor,
borderColor,
spotlightSize,
intensity,
radius,
children,
className,
as = "div",
style,
...rest
}: SpotlightCardProps) {
const Tag = as as unknown as LooseElement;
const ref = useRef<HTMLElement>(null);
const grouped = useContext(GroupContext);
// Percentages of the card's own rect: independent of any CSS transform
// scaling the card (e.g. a scaled-down preview), and in the same unit
// as the 30% / 20% idle position in the stylesheet.
usePointerSpot(ref, !grouped, (el, x, y) => {
const rect = el.getBoundingClientRect();
if (!rect.width || !rect.height) return;
el.style.setProperty("--sc-x", `${((x - rect.left) / rect.width) * 100}%`);
el.style.setProperty("--sc-y", `${((y - rect.top) / rect.height) * 100}%`);
});
// The CSS module carries the defaults; custom properties are only set
// for non-default values so the default render matches the stylesheet.
const vars: CSSVars = { ...style };
if (spotlightColor && spotlightColor.toLowerCase() !== DEFAULT_SPOTLIGHT_COLOR) {
vars["--sc-color"] = spotlightColor;
}
if (borderColor && borderColor.toLowerCase() !== DEFAULT_BORDER_COLOR) {
vars["--sc-border"] = borderColor;
}
if (spotlightSize !== undefined && spotlightSize !== DEFAULT_SPOTLIGHT_SIZE) {
vars["--sc-size"] = `${spotlightSize}px`;
}
if (intensity !== undefined && intensity !== DEFAULT_INTENSITY) {
vars["--sc-intensity"] = Math.min(1, Math.max(0, intensity));
}
if (radius !== undefined && radius !== DEFAULT_RADIUS) {
vars["--sc-radius"] = `${radius}px`;
}
return (
<Tag
{...rest}
ref={ref}
className={className ? `${styles.card} ${className}` : styles.card}
data-spotlight-card=""
style={vars}
>
<div className={styles.surface}>
<div className={styles.content}>{children}</div>
</div>
</Tag>
);
}
/**
* Optional wrapper for a group of SpotlightCards (Linear's features grid):
* tracks the pointer across the whole grid, so the borders of neighbouring
* cards light up as the pointer approaches them, not only the hovered one.
* Lays its children out as a responsive grid; `className` can override it.
*/
export function SpotlightGrid({
children,
className,
as = "div",
...rest
}: SpotlightGridProps) {
const Tag = as as unknown as LooseElement;
const ref = useRef<HTMLElement>(null);
// Each card's offset inside the grid, in the grid's untransformed px, is
// measured on resize only. Pointer moves then write just two variables
// on the grid and every card derives its own spot in CSS.
useEffect(() => {
const grid = ref.current;
if (!grid) return;
let moved = false;
const measure = () => {
const box = grid.getBoundingClientRect();
// Rects are in screen px; divide out any transform scale.
const scale = grid.offsetWidth ? box.width / grid.offsetWidth : 1;
if (!scale) return;
grid.querySelectorAll<HTMLElement>("[data-spotlight-card]").forEach((card) => {
const rect = card.getBoundingClientRect();
card.style.setProperty("--sc-ox", `${(rect.left - box.left) / scale}px`);
card.style.setProperty("--sc-oy", `${(rect.top - box.top) / scale}px`);
});
if (!moved) {
grid.style.setProperty("--sc-gx", `${grid.offsetWidth * GRID_IDLE_X}px`);
grid.style.setProperty("--sc-gy", `${grid.offsetHeight * GRID_IDLE_Y}px`);
}
grid.dataset.ready = "";
};
const markMoved = (event: PointerEvent) => {
if (event.pointerType !== "touch") moved = true;
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(grid);
// A card can change size (e.g. its text wraps) without the grid
// resizing, which would shift its neighbours' offsets.
grid
.querySelectorAll<HTMLElement>("[data-spotlight-card]")
.forEach((card) => observer.observe(card));
grid.addEventListener("pointermove", markMoved, { passive: true });
return () => {
observer.disconnect();
grid.removeEventListener("pointermove", markMoved);
};
}, []);
usePointerSpot(ref, true, (el, x, y) => {
const box = el.getBoundingClientRect();
const scale = el.offsetWidth ? box.width / el.offsetWidth : 1;
if (!scale) return;
el.style.setProperty("--sc-gx", `${(x - box.left) / scale}px`);
el.style.setProperty("--sc-gy", `${(y - box.top) / scale}px`);
});
return (
<Tag
{...rest}
ref={ref}
className={className ? `${styles.grid} ${className}` : styles.grid}
>
<GroupContext.Provider value={true}>{children}</GroupContext.Provider>
</Tag>
);
}
export default SpotlightCard;
```
#### `components/spotlight-card/SpotlightCard.module.css`
```css
/* ==========================================================================
Spotlight card — Linear / Vercel-style feature card
A dark surface inside a 1px border; a radial light follows the pointer.
Built without masks: the card's own background IS the border (1px of
padding), a glow layer sits on top of it, and an opaque surface covers
everything but the outer 1px. So the glow shows only as a lit border,
and each layer can fade its opacity independently (idle ↔ active).
Layers: border color → border glow (::before) → surface → surface glow
(.surface::before) → grain (.surface::after) → content.
========================================================================== */
.card {
/* Spec values. The component only sets these inline for non-default
props, so the default render is exactly this stylesheet. */
--sc-color: #c4b5fd; /* spotlight */
--sc-border: #26262b; /* resting border */
--sc-size: 420px; /* surface glow diameter; the border glow is 60% */
--sc-intensity: 1; /* peak opacity under the pointer */
--sc-radius: 18px;
--sc-padding: 24px;
--sc-surface: #0c0c0f;
/* Idle spot: parked top-left at reduced strength, so the card is lit
on first paint, on touch devices and before any JS runs. */
--sc-idle: 0.45;
--sc-x: 30%;
--sc-y: 20%;
/* Inside an active SpotlightGrid the grid raises the level for all. */
--sc-level: var(--sc-group-level, var(--sc-idle));
--sc-surface-level: var(--sc-level);
position: relative;
display: block;
box-sizing: border-box;
padding: 1px; /* the border */
border-radius: var(--sc-radius);
background: var(--sc-border);
isolation: isolate;
color: #ededef;
text-decoration: none;
font-family:
-apple-system, BlinkMacSystemFont, "Hiragino Sans",
"Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto,
sans-serif;
-webkit-tap-highlight-color: transparent;
}
/* Pointer over the card, or keyboard focus inside it: full strength. */
.card[data-active],
.card:focus-within {
--sc-level: 1;
}
/* --- Border glow: a tight, bright spot. Only its outer 1px is visible. --- */
.card::before {
content: "";
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
background: radial-gradient(
circle calc(var(--sc-size) * 0.3) at var(--sc-x) var(--sc-y),
var(--sc-color),
transparent 100%
);
opacity: calc(var(--sc-level) * var(--sc-intensity));
transition: opacity 0.4s ease;
pointer-events: none;
}
/* --- Surface: must stay opaque, or the border glow bleeds through. --- */
.surface {
position: relative;
z-index: 1;
box-sizing: border-box;
height: 100%;
overflow: hidden;
border-radius: calc(var(--sc-radius) - 1px);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), transparent 45%),
var(--sc-surface);
/* Hairline top highlight: reads as a lit edge even at rest. */
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
/* Surface glow: the same light, wide and faint. */
.surface::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(
circle calc(var(--sc-size) * 0.5) at var(--sc-x) var(--sc-y),
color-mix(in srgb, var(--sc-color) 16%, transparent),
transparent 100%
);
opacity: calc(var(--sc-surface-level) * var(--sc-intensity));
transition: opacity 0.4s ease;
pointer-events: none;
}
/* Film grain: keeps the large dark gradient from banding. */
.surface::after {
content: "";
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
opacity: 0.05;
mix-blend-mode: overlay;
pointer-events: none;
}
.content {
position: relative;
z-index: 1;
height: 100%;
box-sizing: border-box;
padding: var(--sc-padding);
}
/* `as="a"` / focusable cards. */
.card:focus-visible {
outline: 2px solid color-mix(in srgb, var(--sc-color) 70%, white);
outline-offset: 3px;
}
/* --- SpotlightGrid ------------------------------------------------------
The grid writes the pointer (--sc-gx / --sc-gy, grid px) and each
card's offset (--sc-ox / --sc-oy); a card's spot is the difference, so
one light spans the whole grid and lights neighbouring borders. Until
the first measurement the cards keep their own idle spot. */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
gap: 12px;
margin: 0;
padding: 0;
list-style: none;
}
.grid[data-ready] .card {
--sc-x: calc(var(--sc-gx) - var(--sc-ox, 0px));
--sc-y: calc(var(--sc-gy) - var(--sc-oy, 0px));
}
.grid[data-active] {
--sc-group-level: 1;
}
/* Neighbours get lit borders but only a hint of surface glow; the card
under the pointer gets the full wash. */
.grid[data-active] .card:not(:hover, :focus-within) {
--sc-surface-level: 0.35;
}
@media (prefers-reduced-motion: reduce) {
.card::before,
.surface::before {
transition: none;
}
}
```
代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/spotlight-card.json
```
### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { SpotlightCard } from "@/components/spotlight-card";
// TODO: children は仮の中身です。JSX の子要素としてカードの中身(アイコン・見出し・説明など)に差し替えてください
// TODO: カードを並べる場合は import { SpotlightGrid } from "@/components/spotlight-card"; で囲むと、ポインターが近づいた隣のカードの枠線も光ります(プレビューはこの状態)
<SpotlightCard
spotlightColor="#c4b5fd"
borderColor="#26262b"
spotlightSize={420}
intensity={1}
radius={18}
children={
<>
<h3>高速なビルド</h3>
<p>変更したファイルだけを再ビルド。数千ページでも数秒で反映されます。</p>
</>
}
/>
```
### 4. 組み込み手順
1. 中身を子要素として渡して置く: `<SpotlightCard><h3>…</h3><p>…</p></SpotlightCard>`。コンポーネント自体が `"use client"` なので、Server Component からそのまま使える。
2. 複数枚を並べる場合は `<SpotlightGrid>` で囲む。ポインター位置をグリッド全体で 1 つだけ追跡し、近づいた隣のカードの枠線も光る(Linear の機能紹介グリッドの挙動)。既定のレイアウトは `repeat(auto-fit, minmax(min(100%, 240px), 1fr))`・`gap: 12px`。
3. リストにするなら `<SpotlightGrid as="ul">` + `<SpotlightCard as="li">`、カード全体をリンクにするなら `<SpotlightCard as="a" href="…">`(`:focus-visible` でリングが出る)。
4. 内側の余白や面の色はクラスから CSS 変数で上書きする: `--sc-padding`(既定 24px)、`--sc-surface`(既定 #0c0c0f)。色・大きさ・強さ・角丸は props で渡す。
5. 中身の文字色・フォントは `color: #ededef` とシステムフォントを継承する。見出しなどのスタイルは子要素側で付ける。
6. `npm run build` が通ることを確認し、マウスで「枠線と面の光がポインターに追従する」「離すと左上の待機位置の明るさに戻る」、スマホで「タップしても光が飛ばず、待機状態のまま」を確認する。
#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| 枠線だけでなくカード全体がぼんやり光る | 面(`.surface`)の背景を半透明にした | 面は**不透明のまま**にする。枠線の光は面の下にあり、外周 1px だけが見える仕組み |
| ポインターを動かすとカクつく / 再レンダーが走る | 座標を React の state で持った | `pointermove` では座標を記録するだけにし、`requestAnimationFrame` で CSS 変数(`--sc-x` / `--sc-y`)へ直接書く |
| 縮小表示(`transform: scale`)の中で光がずれる | `clientX - rect.left` の px をそのまま書いた | 単体カードは**矩形に対する %** で書く。グリッドは `rect.width / offsetWidth` で拡大率を割り戻す |
| SpotlightGrid に `grid-cols-2` などを付けても列数が変わらない | コンポーネントの CSS Modules はレイヤー外なので、`@layer utilities` の Tailwind より強い | 列定義は `style={{ gridTemplateColumns: … }}` で渡す |
| 枠線の光を mask で作り直したら Chrome / Safari で枠が出ない | `mask` と `-webkit-mask` を併記すると Next.js(Lightning CSS)がプレフィックスを整理してしまう | そもそも mask を使わない現行の構造(1px の padding + 不透明な面)を維持する |
| ホバーしていない初期表示が真っ暗 | 待機時の `--sc-x: 30%` / `--sc-y: 20%` と `--sc-idle` を消した | ルートクラスの既定値を残す。JS が動く前・タッチ端末でもこの状態で灯る |
### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる
## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- Next.js(App Router)、React 19、TypeScript、CSS Modules。依存パッケージなし
- ファイル: `components/spotlight-card/index.tsx`(`"use client"`)+ `SpotlightCard.module.css`
- エクスポート: `SpotlightCard`(default も同じ)、`SpotlightGrid`
- `SpotlightCard` props: `spotlightColor`(既定 #c4b5fd)、`borderColor`(#26262b)、`spotlightSize`(420 px)、`intensity`(0–1、既定 1)、`radius`(18 px)、`children`、`className`、`as`(既定 "div")、その他の HTML 属性(`href` 含む)はルート要素へそのまま渡す
- 既定値は CSS 変数(接頭辞 `--sc-`)でルートクラスに持ち、既定と異なる props だけ inline の style で上書きする
### 見た目
層構成は「枠線色 → 枠線の光 → 面 → 面の光 → グレイン → 中身」。mask は使わない。
- **ルート(.card)**: `padding: 1px`(これが枠線)、`background: var(--sc-border)`、`border-radius: var(--sc-radius)`、`isolation: isolate`、文字色 #ededef、システムフォント(-apple-system, "Hiragino Sans", "Noto Sans JP", "Segoe UI", Roboto…)
- **枠線の光(.card::before)**: `inset: 0`、`radial-gradient(circle calc(var(--sc-size) * .3) at var(--sc-x) var(--sc-y), var(--sc-color), transparent 100%)`。不透明な面に覆われ、外周 1px だけが光って見える
- **面(.surface)**: 不透明。`linear-gradient(180deg, 白 3.5%, transparent 45%)` + `#0c0c0f`、`border-radius: calc(var(--sc-radius) - 1px)`、`overflow: hidden`、`inset 0 1px 0 白 4%` の上辺ハイライト
- **面の光(.surface::before)**: `radial-gradient(circle calc(var(--sc-size) * .5) at var(--sc-x) var(--sc-y), color-mix(in srgb, var(--sc-color) 16%, transparent), transparent 100%)`
- **グレイン(.surface::after)**: SVG `feTurbulence`(fractalNoise, baseFrequency .9)のデータ URI、`opacity: .05`、`mix-blend-mode: overlay`。暗いグラデーションのバンディング防止
- **中身(.content)**: `padding: var(--sc-padding)`(24px)
- **明るさ**: 2 つの光の `opacity` は `calc(var(--sc-level) * var(--sc-intensity))`。`--sc-level` は待機時 `--sc-idle`(.45)、ポインター下と `:focus-within` で 1。`opacity .4s ease` でフェード
- **待機状態**: `--sc-x: 30%; --sc-y: 20%`(左上)。初回描画・JS 実行前・タッチ端末でも、この位置で控えめに灯る
- `:focus-visible`: `2px solid color-mix(in srgb, var(--sc-color) 70%, white)`、offset 3px
### SpotlightGrid
- `display: grid`、`repeat(auto-fit, minmax(min(100%, 240px), 1fr))`、`gap: 12px`、ul 用に margin / padding / list-style をリセット
- 子カードは Context でグリッド内と判定し、自前のポインター追跡をしない
- グリッドがポインター位置 `--sc-gx` / `--sc-gy`(グリッド内 px)を書き、各カードのオフセット `--sc-ox` / `--sc-oy` を ResizeObserver(グリッドと各カード)で計測して書く。準備完了で `data-ready` を付与し、`.grid[data-ready] .card { --sc-x: calc(var(--sc-gx) - var(--sc-ox)) }`(y も同様)。カードの外にある光も放射グラデーションなので、近い辺の枠線だけが光る
- グリッドの待機位置は中央(50% / 50%)。全カードの内側の角が控えめに光る。計測前は各カード自身の 30% / 20%
- ポインターがグリッド上にある間は全カードの枠線を level 1 に。面の光はホバー中のカードだけ 1、他は .35
### モーション
- `pointermove`(passive、`addEventListener` で登録し利用側のハンドラーを潰さない)では座標を記録して `data-active` を付けるだけ。CSS 変数の書き込みは `requestAnimationFrame` で 1 フレーム 1 回
- 単体カード: `(clientX - rect.left) / rect.width * 100%`。% なので親の `transform: scale` に影響されない
- グリッド: `(clientX - rect.left) / (rect.width / offsetWidth)` px
- `pointerleave`: `data-active` を外す。光はその場に残り、待機の明るさへフェード
- `pointerType === "touch"` は無視(タップで光が飛ばない)
- 常時アニメーションは無いので IntersectionObserver は不要
- `prefers-reduced-motion: reduce`: フェードの transition を無効化(追従自体は残す)
### アクセシビリティ
- 装飾の光とグレインはすべて疑似要素で、読み上げ対象にならない
- `as` で意味のある要素(article / li / a)を選べる。キーボードで中のリンクにフォーカスすると `:focus-within` でカードが点灯する
### 受け入れ条件
- ホバーなしの初回描画で、左上が控えめに光った状態で表示される
- マウス移動で枠線と面の光が追従し、React の再レンダーが発生しない
- SpotlightGrid 内では、カード間の隙間にポインターがあっても両隣の枠線が光る
- `transform: scale(.4)` の中でも光がポインター位置とずれない
- タッチ端末では待機状態のまま、レイアウトやスクロールに影響しないインストール
npx shadcn@latest add https://anima-js.vercel.app/r/spotlight-card.json生成コード
import { SpotlightCard } from "@/components/spotlight-card";
// TODO: children は仮の中身です。JSX の子要素としてカードの中身(アイコン・見出し・説明など)に差し替えてください
// TODO: カードを並べる場合は import { SpotlightGrid } from "@/components/spotlight-card"; で囲むと、ポインターが近づいた隣のカードの枠線も光ります(プレビューはこの状態)
<SpotlightCard
spotlightColor="#c4b5fd"
borderColor="#26262b"
spotlightSize={420}
intensity={1}
radius={18}
children={
<>
<h3>高速なビルド</h3>
<p>変更したファイルだけを再ビルド。数千ページでも数秒で反映されます。</p>
</>
}
/>