カード
TiltCard
MotionCSS 3D
Apple TV のポスターやホロカードのような 3D パララックスカード — ポインターに向かってスプリングで傾き、反対側へ流れるグレア、虹色のホログラム箔、translateZ で浮き出るレイヤー。待機中はゆっくり揺れます。
animaPLATINUM
5300 0324 1986 2026
MEMBERAKANE SATOVALID THRU10/30
カーソルを乗せて傾ける
AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約25 KB)。
# TiltCard をこのプロジェクトに追加してください
このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。
## コンポーネントについて
- 名前: TiltCard
- 説明: Apple TV のポスターやホロカードのような 3D パララックスカード — ポインターに向かってスプリングで傾き、反対側へ流れるグレア、虹色のホログラム箔、translateZ で浮き出るレイヤー。待機中はゆっくり揺れます。
- 実行時に必要な npm パッケージ: framer-motion
- 構成ファイル: `components/tilt-card/index.tsx`, `components/tilt-card/TiltCard.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. 依存パッケージを追加する
pnpm / yarn / bun を使っているプロジェクトでは、そのパッケージマネージャーのコマンドに置き換えてください。
```bash
npm install framer-motion
```
### 2. ファイルを作成する
`@/components` が指すディレクトリ(多くは `src/components/` か `components/`。shadcn の `components.json` があれば `aliases.components` の指す場所)に、以下の 2 ファイルを**一字一句そのまま**作成してください。
#### `components/tilt-card/index.tsx`
```tsx
"use client";
import {
useEffect,
useRef,
type CSSProperties,
type HTMLAttributes,
type PointerEvent,
type ReactNode,
} from "react";
import {
MotionConfig,
motion,
useMotionTemplate,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
} from "framer-motion";
import styles from "./TiltCard.module.css";
/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;
export type TiltCardProps = {
/** Card content. Wrap parts in <TiltLayer depth={…}> to lift them off the surface. */
children?: ReactNode;
/** Maximum rotation on each axis, in degrees. */
maxTilt?: number;
/** Distance of the viewer from the card in px. Smaller = stronger depth. */
perspective?: number;
/** Scale while hovered / focused (1 = no lift). */
scaleOnHover?: number;
/** Specular highlight that slides across the surface. */
glare?: boolean;
/** Peak opacity of the glare while hovered (0–1). */
glareOpacity?: number;
/** Rainbow foil (color-dodge), visible only around the highlight. */
holo?: boolean;
/** Spring stiffness of the tilt. */
springStiffness?: number;
/** Spring damping of the tilt. */
springDamping?: number;
/** Corner radius of the card in px. */
radius?: number;
/**
* Slow auto-sway while the pointer is away (and always on touch), so the
* card shows its depth without hover. Off: the card rests flat.
*/
idleAnimation?: boolean;
className?: string;
};
export type TiltLayerProps = HTMLAttributes<HTMLDivElement> & {
/** How far the layer floats above the card surface, in px. */
depth?: number;
};
const DEFAULT_PERSPECTIVE = 1000;
const DEFAULT_RADIUS = 20;
/** Where a reduced-motion card rests: a fixed slight tilt still reads as 3D. */
const STATIC_POINT = { x: 0.64, y: 0.36 };
const CENTER = { x: 0.5, y: 0.5 };
/**
* Idle path in pointer space (0–1). Two incommensurate sines draw a slow
* Lissajous loop, so the sway never visibly repeats; t = 0 already sits
* off-center so the first paint is tilted, not flat.
*/
function swayPoint(seconds: number) {
return {
x: 0.5 + 0.3 * Math.sin(seconds * 0.55 + 1.1),
y: 0.5 + 0.22 * Math.sin(seconds * 0.37 + 2.4),
};
}
/**
* 3D parallax tilt card — rotates toward the pointer on springs, with a
* glare that slides the opposite way, optional holographic foil, and
* <TiltLayer> children that float at their own depth. Without a hovering
* pointer it sways slowly; with reduced motion it rests at a fixed tilt.
*/
export function TiltCard({
children,
maxTilt = 14,
perspective,
scaleOnHover = 1.04,
glare = true,
glareOpacity = 0.35,
holo = false,
springStiffness = 160,
springDamping = 20,
radius,
idleAnimation = true,
className,
}: TiltCardProps) {
const rootRef = useRef<HTMLDivElement>(null);
const reduced = useReducedMotion() ?? false;
const swaying = idleAnimation && !reduced;
// Raw pointer position (0–1 across the card); springs smooth it. Starts
// on the sway path so SSR and first paint already show the tilt.
const start = idleAnimation ? swayPoint(0) : CENTER;
const px = useMotionValue(start.x);
const py = useMotionValue(start.y);
const spring = { stiffness: springStiffness, damping: springDamping, mass: 0.7 };
const sx = useSpring(px, spring);
const sy = useSpring(py, spring);
// 0 → 1 while hovered or focused: drives the lift and the glare boost.
const active = useMotionValue(0);
const activeSpring = useSpring(active, { stiffness: 260, damping: 26 });
// Props mirrored into motion values: a multi-input useTransform then
// re-derives on change without stale closures over old props.
const maxTiltMV = useMotionValue(maxTilt);
const liftMV = useMotionValue(scaleOnHover);
const glareMV = useMotionValue(glareOpacity);
useEffect(() => maxTiltMV.set(maxTilt), [maxTilt, maxTiltMV]);
useEffect(() => liftMV.set(scaleOnHover), [scaleOnHover, liftMV]);
useEffect(() => glareMV.set(glareOpacity), [glareOpacity, glareMV]);
// The card faces the pointer: the side under it recedes.
const rotateX = useTransform([sy, maxTiltMV], ([y, m]: number[]) => (0.5 - y) * 2 * m);
const rotateY = useTransform([sx, maxTiltMV], ([x, m]: number[]) => (x - 0.5) * 2 * m);
const scale = useTransform([activeSpring, liftMV], ([a, s]: number[]) => 1 + (s - 1) * a);
// Highlight sits opposite the pointer, like a fixed light on a turning card.
const gx = useTransform(sx, (v) => (1 - v) * 100);
const gy = useTransform(sy, (v) => (1 - v) * 100);
const glareBg = useMotionTemplate`radial-gradient(farthest-corner circle at ${gx}% ${gy}%, rgba(255,255,255,0.9) 0%, rgba(255,255,255,0.32) 22%, rgba(255,255,255,0) 58%)`;
// Idle glare is a softer version of the hover one, so the gallery card
// still shows the sheen.
const glareAlpha = useTransform([activeSpring, glareMV], ([a, o]: number[]) => o * (0.55 + 0.45 * a));
// Foil bands drift with the pointer (parallax against the art) and are
// masked to the highlight, so the rainbow only flashes where light hits.
const hx = useTransform(sx, (v) => v * 100);
const hy = useTransform(sy, (v) => v * 100);
const holoPos = useMotionTemplate`${hx}% ${hy}%`;
const holoMask = useMotionTemplate`radial-gradient(circle at ${gx}% ${gy}%, #000 0%, rgba(0,0,0,0.45) 38%, transparent 72%)`;
const holoAlpha = useTransform(activeSpring, (a) => 0.5 + 0.3 * a);
// Shadow falls away from the raised side and deepens with the lift.
const shadowX = useTransform(sx, (v) => (0.5 - v) * 36);
const shadowY = useTransform([sy, activeSpring], ([y, a]: number[]) => 22 + (0.5 - y) * 24 + a * 14);
const shadow = useMotionTemplate`${shadowX}px ${shadowY}px 60px -18px rgba(0,0,0,0.65), 0 2px 6px rgba(0,0,0,0.3)`;
const hoverRef = useRef(false);
const pointerRef = useRef({ x: 0.5, y: 0.5 });
const frameRef = useRef(0);
// Idle sway / resting pose. One rAF loop, paused while offscreen; the
// hovering pointer simply takes precedence inside the loop.
useEffect(() => {
const root = rootRef.current;
if (!swaying || !root) {
if (!hoverRef.current) {
const rest = reduced ? STATIC_POINT : CENTER;
px.set(rest.x);
py.set(rest.y);
}
return;
}
let raf = 0;
let visible = true;
const origin = performance.now();
const tick = (now: number) => {
raf = 0;
if (!visible) return;
if (!hoverRef.current) {
const p = swayPoint((now - origin) / 1000);
px.set(p.x);
py.set(p.y);
}
raf = requestAnimationFrame(tick);
};
const io = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
if (visible && !raf) raf = requestAnimationFrame(tick);
});
io.observe(root);
raf = requestAnimationFrame(tick);
return () => {
io.disconnect();
cancelAnimationFrame(raf);
};
}, [swaying, reduced, px, py]);
useEffect(() => () => cancelAnimationFrame(frameRef.current), []);
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
// Touch has no hover: leave the card on its idle sway rather than
// having it jump under a scrolling finger. Reduced motion: no tracking.
if (event.pointerType === "touch" || reduced) return;
const root = rootRef.current;
if (!root) return;
// Read the untransformed root, not the tilted card, so the mapping
// doesn't feed back into itself.
const rect = root.getBoundingClientRect();
pointerRef.current = {
x: Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)),
y: Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height)),
};
if (!hoverRef.current) {
hoverRef.current = true;
active.set(1);
}
// Coalesce to one write per frame regardless of the event rate.
if (frameRef.current) return;
frameRef.current = requestAnimationFrame(() => {
frameRef.current = 0;
if (!hoverRef.current) return;
px.set(pointerRef.current.x);
py.set(pointerRef.current.y);
});
};
const onPointerLeave = () => {
if (!hoverRef.current) return;
hoverRef.current = false;
active.set(0);
// With sway on, the loop picks the card up on its next frame; the
// springs blend the hand-off. Otherwise glide back to rest.
if (!swaying) {
const rest = reduced ? STATIC_POINT : CENTER;
px.set(rest.x);
py.set(rest.y);
}
};
// 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 = {};
if (perspective !== undefined && perspective !== DEFAULT_PERSPECTIVE) {
vars["--tc-perspective"] = `${perspective}px`;
}
if (radius !== undefined && radius !== DEFAULT_RADIUS) {
vars["--tc-radius"] = `${radius}px`;
}
return (
<MotionConfig reducedMotion="user">
<div
ref={rootRef}
className={className ? `${styles.root} ${className}` : styles.root}
data-tilt-card
style={vars}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
// Keyboard users get the lift (not the tilt) when focus lands inside.
onFocus={() => active.set(1)}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
active.set(hoverRef.current ? 1 : 0);
}
}}
>
<motion.div
className={styles.card}
style={{ rotateX, rotateY, scale, boxShadow: shadow }}
>
<div className={styles.content}>{children}</div>
{holo ? (
<motion.div
className={styles.holo}
aria-hidden
style={{
opacity: holoAlpha,
backgroundPosition: holoPos,
maskImage: holoMask,
WebkitMaskImage: holoMask,
}}
/>
) : null}
{glare ? (
<motion.div
className={styles.glare}
aria-hidden
style={{ background: glareBg, opacity: glareAlpha }}
/>
) : null}
</motion.div>
</div>
</MotionConfig>
);
}
/**
* A part of the card that floats `depth` px above the surface. Must be a
* child of TiltCard (or of another TiltLayer) — any plain wrapper element
* in between flattens the 3D and the layer stops popping out.
*/
export function TiltLayer({ depth = 30, className, style, ...rest }: TiltLayerProps) {
const layerStyle: CSSVars = { ...style, "--tc-depth": `${depth}px` };
return (
<div
{...rest}
className={className ? `${styles.layer} ${className}` : styles.layer}
data-tilt-layer
style={layerStyle}
/>
);
}
export default TiltCard;
```
#### `components/tilt-card/TiltCard.module.css`
```css
/* ==========================================================================
3D parallax tilt card (Apple TV poster / holographic trading card)
root (perspective) → card (rotated by framer-motion, preserve-3d)
→ content (preserve-3d) → TiltLayer × n (translateZ)
→ holo foil (color-dodge, masked to the highlight)
→ glare (radial highlight)
The card is NOT overflow: hidden — clipping flattens preserve-3d and the
layers would stop popping out. Each flat layer rounds its own corners.
========================================================================== */
.root {
/* Defaults. The component only sets these inline for non-default
values, so the default render is exactly this stylesheet. */
--tc-perspective: 1000px;
--tc-radius: 20px;
position: relative;
display: block;
perspective: var(--tc-perspective);
/* Leaves vertical page scroll to the browser on touch; pointer tracking
is mouse / pen only anyway. */
touch-action: pan-y;
}
.card {
position: relative;
width: 100%;
border-radius: var(--tc-radius);
transform-style: preserve-3d;
will-change: transform;
}
.content {
position: relative;
border-radius: inherit;
transform-style: preserve-3d;
}
.layer {
transform: translateZ(var(--tc-depth, 30px));
transform-style: preserve-3d;
}
/* Both overlays sit a hair above the surface (coplanar layers z-fight in
Safari) and below any TiltLayer, which reads like raised print. */
.glare,
.holo {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
}
.holo {
transform: translateZ(1px);
background-image:
repeating-linear-gradient(
115deg,
#ff77c8 0%,
#ffd36b 7%,
#75ffc6 14%,
#6fc6ff 21%,
#b28aff 28%,
#ff77c8 35%
),
repeating-linear-gradient(
25deg,
rgba(255, 255, 255, 0.18) 0 1px,
transparent 1px 4px
);
background-size:
320% 320%,
100% 100%;
mix-blend-mode: color-dodge;
filter: saturate(1.15);
}
.glare {
transform: translateZ(2px);
}
@media (prefers-reduced-motion: reduce) {
.card {
will-change: auto;
}
}
```
代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/tilt-card.json
```
### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { TiltCard } from "@/components/tilt-card";
// TODO: カードの中身を children として渡してください。高さは中身で決まります(例: aspect-ratio を持つ背景アートを最初の子に置く)
// TODO: 浮かせたい要素は同じパッケージの TiltLayer で包み、depth(px)を指定してください: import { TiltLayer } from "@/components/tilt-card"; <TiltLayer depth={40}>…</TiltLayer>
// TODO: 幅は className で指定してください(例: className="w-[380px] max-w-full")
<TiltCard
glare={true}
glareOpacity={0.35}
holo={false}
radius={20}
maxTilt={14}
perspective={1000}
scaleOnHover={1.04}
idleAnimation={true}
springStiffness={160}
springDamping={20}
/>
```
### 4. 組み込み手順
1. `"use client"` のコンポーネント(またはサーバーコンポーネントから直接)で `<TiltCard className="w-[380px] max-w-full">…</TiltCard>` を置く。幅は `className` で決め、高さは中身で決まる。
2. 最初の子に**カードの土台(背景アート)**を置く。`aspect-ratio` と `border-radius: inherit`(Tailwind なら `rounded-[inherit]`)を付けると角の丸みが `radius` に揃う。土台の中は `overflow: hidden` で自由に描いてよい。
3. 浮かせたい要素(ロゴ・数字・名前など)は `import { TiltLayer } from "@/components/tilt-card"` で包み、`depth`(px)を指定する。`position: absolute` で土台の上に重ねる。目安は 20〜60px、遠近感(`perspective`)が小さいほど強く飛び出す。
4. カード全体をリンクにしたい場合は、中にある `<a>` / `<button>` を通常どおり置く。フォーカスが入るとカードが少し浮き上がる(傾きはポインターのみ)。
5. 一覧に複数並べる場合も 1 枚ずつ独立して動く。待機中のゆらぎは画面外で自動停止する。
6. `npm run build` が通ることを確認し、PC(マウス)で「ポインターに向かって傾く」「グレアが反対側へ流れる」「離すと滑らかに戻る」、スマホで「ゆっくり揺れ続け、スクロールを妨げない」を確認する。
#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| TiltLayer が飛び出さず平面に見える | TiltCard と TiltLayer の間に普通の `<div>` を挟むと `transform-style: preserve-3d` が途切れる | TiltLayer は **TiltCard(または別の TiltLayer)の直接の子**に置く。まとめたい場合は TiltLayer 同士を入れ子にする |
| 角を丸めるために `.card` に `overflow: hidden` を足すとレイヤーが平らになる | `overflow` / `opacity < 1` / `filter` / `isolation` / `mix-blend-mode` は 3D を平面化する(グルーピングプロパティ) | カード本体やラッパーには付けない。**土台(平面の葉要素)側**で `overflow: hidden` と `rounded-[inherit]` を使う |
| 傾きが毎フレーム React を再レンダーして重い | ポインター位置を `useState` に入れている | 位置は `useMotionValue` → `useSpring` → `useTransform` で流し、イベントは rAF で 1 フレーム 1 回にまとめる(実装済み。state 化しない) |
| ポインターから遠ざかるように震える | 傾いた `.card` 自身の `getBoundingClientRect()` で座標を正規化している(回転で矩形が変わり、フィードバックする) | 傾かない外側の `.root` の矩形で計算する |
| スマホでカードが指に吸い付いてスクロールできない | タッチの `pointermove` で傾けている | タッチは無視してゆらぎを続ける(実装済み)。`touch-action: pan-y` を外さない |
| グレアの props を変えても反映されない | `useTransform` の関数が古い props をクロージャで保持 | props は motion value に写し、複数入力の `useTransform` で合成する |
### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる
## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- React 19 + framer-motion 12。スタイルは CSS Modules(`TiltCard.module.css`)で完結し、Tailwind 不要
- エクスポート: `TiltCard`(default も)、`TiltLayer`、型 `TiltCardProps` / `TiltLayerProps`
- Props: `maxTilt`=14(deg)、`perspective`=1000(px)、`scaleOnHover`=1.04、`glare`=true、`glareOpacity`=0.35、`holo`=false、`springStiffness`=160、`springDamping`=20、`radius`=20(px)、`idleAnimation`=true、`children`、`className`
- `TiltLayer`: `depth`=30(px)+任意の div 属性。`transform: translateZ(var(--tc-depth))` と `preserve-3d`
### 見た目
- 構造: `.root`(`perspective: var(--tc-perspective)`、`touch-action: pan-y`)→ `.card`(framer-motion で回転・拡大、`transform-style: preserve-3d`、`border-radius: var(--tc-radius)`、overflow は付けない)→ `.content`(preserve-3d、children)+ ホロ箔 + グレア
- CSS 変数 `--tc-perspective: 1000px` / `--tc-radius: 20px` を `.root` に既定値として持ち、既定と異なる値だけインラインで上書き
- **グレア**: `translateZ(2px)` の全面レイヤー。`radial-gradient(farthest-corner circle at {gx}% {gy}%, 白 .9 0%, 白 .32 22%, 透明 58%)`。位置はポインターの反対(`gx = (1 - x) * 100`)。不透明度 = `glareOpacity × (0.55 + 0.45 × hover)`
- **ホロ箔**: `translateZ(1px)`。`repeating-linear-gradient(115deg, #ff77c8, #ffd36b 7%, #75ffc6 14%, #6fc6ff 21%, #b28aff 28%, #ff77c8 35%)` を `background-size: 320%` で敷き、`background-position` をポインター位置(`x*100% y*100%`)で動かす。細い斜線(25deg、白 .18、4px 周期)を重ね、`mix-blend-mode: color-dodge`、`saturate(1.15)`。`mask-image: radial-gradient(circle at {gx}% {gy}%, #000, rgba(0,0,0,.45) 38%, transparent 72%)` でハイライト周辺だけに見せる。不透明度 0.5 → ホバーで 0.8
- **影**: `{(0.5 - x) * 36}px {22 + (0.5 - y) * 24 + hover * 14}px 60px -18px rgba(0,0,0,.65), 0 2px 6px rgba(0,0,0,.3)` — 持ち上がった側の反対へ落ちる
- デモ: 380px 幅・縦横比 1.586 の会員カード。暗いメッシュグラデーション+ギョーシェ風の同心円+フィルムグレインの土台、depth 26 のロゴ行、44 の IC チップ、58 のカード番号、34 の名義・有効期限
### モーション
- ポインター位置 `x, y`(0〜1、`.root` の矩形で正規化)を `useMotionValue` に入れ、`useSpring({ stiffness, damping, mass: 0.7 })` で平滑化
- `rotateX = (0.5 - y) * 2 * maxTilt`、`rotateY = (x - 0.5) * 2 * maxTilt`(カードがポインターの方を向き、ポインター側が奥へ下がる)
- hover / focus で `active` 0→1(spring 260 / 26)。`scale = 1 + (scaleOnHover - 1) * active`
- `pointermove` は rAF で 1 フレーム 1 回に間引く。`pointerType === "touch"` は無視
- 離れたら: `idleAnimation` ならゆらぎに戻る(スプリングで補間)、オフなら中央(水平)へ戻る
- **待機中のゆらぎ**: `x = 0.5 + 0.3 sin(0.55t + 1.1)`、`y = 0.5 + 0.22 sin(0.37t + 2.4)` のリサージュ。t=0 で既に傾いているので初回描画から立体に見える。rAF ループは IntersectionObserver で画面外なら停止
- `prefers-reduced-motion: reduce`: ゆらぎもポインター追従もせず、固定の軽い傾き(x .64 / y .36)で静止。`<MotionConfig reducedMotion="user">` で包む
### アクセシビリティ
- グレア・ホロ箔は `aria-hidden`、`pointer-events: none`。カードの中身は実テキストのまま読める
- カード自体はフォーカスを持たない。中の `<a>` / `<button>` にフォーカスが入るとカードが浮き上がる(focus-within 相当)
- 視差(ポインター追従の傾き)は前庭障害のトリガーになり得るため、reduced motion では無効化
### 受け入れ条件
- マウスでポインターの方へ滑らかに傾き、グレアが反対側へ流れ、TiltLayer が奥行きの差で視差を見せる
- ポインターが離れると滑らかに戻り、待機中はゆっくり揺れる(タッチ端末も同様、スクロールを妨げない)
- `holo` をオンにするとハイライト周辺だけ虹色の箔が光る
- ポインター移動中に React の再レンダーが発生しない
- reduced motion では静止した軽い傾きで表示されるインストール
npx shadcn@latest add https://anima-js.vercel.app/r/tilt-card.json生成コード
import { TiltCard } from "@/components/tilt-card";
// TODO: カードの中身を children として渡してください。高さは中身で決まります(例: aspect-ratio を持つ背景アートを最初の子に置く)
// TODO: 浮かせたい要素は同じパッケージの TiltLayer で包み、depth(px)を指定してください: import { TiltLayer } from "@/components/tilt-card"; <TiltLayer depth={40}>…</TiltLayer>
// TODO: 幅は className で指定してください(例: className="w-[380px] max-w-full")
<TiltCard
glare={true}
glareOpacity={0.35}
holo={false}
radius={20}
maxTilt={14}
perspective={1000}
scaleOnHover={1.04}
idleAnimation={true}
springStiffness={160}
springDamping={20}
/>