カード
SwipeCardStack
Motion
重なったカードを左右にスワイプして送るカードスタック — お客様の声・オンボーディング・マッチングアプリ風。ドラッグの方向に傾き、LIKE / NOPE スタンプが浮かび、後ろのカードがバネでせり上がります。ボタンと ← / → キーでも同じ動き。framer-motion。
デザインから実装までの往復がほぼゼロに。チーム全員が同じ画面を見て話せるようになりました。
佐藤 美咲プロダクトデザイナー / Nordlight
1 / 5
AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約43 KB)。
# SwipeCardStack をこのプロジェクトに追加してください
このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。
## コンポーネントについて
- 名前: SwipeCardStack
- 説明: 重なったカードを左右にスワイプして送るカードスタック — お客様の声・オンボーディング・マッチングアプリ風。ドラッグの方向に傾き、LIKE / NOPE スタンプが浮かび、後ろのカードがバネでせり上がります。ボタンと ← / → キーでも同じ動き。framer-motion。
- 実行時に必要な npm パッケージ: framer-motion, lucide-react
- 構成ファイル: `components/swipe-card-stack/index.tsx`, `components/swipe-card-stack/SwipeCardStack.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 lucide-react
```
### 2. ファイルを作成する
`@/components` が指すディレクトリ(多くは `src/components/` か `components/`。shadcn の `components.json` があれば `aliases.components` の指す場所)に、以下の 2 ファイルを**一字一句そのまま**作成してください。
#### `components/swipe-card-stack/index.tsx`
```tsx
"use client";
import {
useEffect,
useRef,
useState,
type CSSProperties,
type KeyboardEvent,
type ReactNode,
} from "react";
import {
AnimatePresence,
MotionConfig,
animate,
motion,
useIsPresent,
useMotionValue,
useTransform,
type PanInfo,
} from "framer-motion";
import { Heart, Undo2, X } from "lucide-react";
import styles from "./SwipeCardStack.module.css";
/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;
export type SwipeDirection = "left" | "right";
export type SwipeCardItem = {
/** Stable id (used for the accessible label lookups and your own state). */
id: string;
/** Default card: the person's name. Also the top card's accessible name. */
title: string;
/** Default card: role / company under the name. */
subtitle?: string;
/** Default card: the quote. */
body?: string;
/**
* Art area background. A CSS gradient (`linear-gradient(…)`) or
* `url(…)` is used as-is; anything else is treated as an image URL.
*/
image?: string;
/** Per-card accent: quote mark, avatar and the glow in the art area. */
accent?: string;
};
export type SwipeCardRenderContext = {
/** 0-based position of this item in `items`. */
index: number;
total: number;
/** Only the top card is interactive; the rest are decorative depth. */
isTop: boolean;
};
export type SwipeCardStackProps = {
items: readonly SwipeCardItem[];
/** Swiped cards go back to the bottom of the deck. Off: `endSlot` shows. */
loop?: boolean;
/** Cards visible in the stack including the top one (2–4). */
visibleCount?: number;
/** Drag distance in px past which a release throws the card. */
threshold?: number;
/** Degrees of tilt per 100px of horizontal drag. */
rotateFactor?: number;
/** LIKE / NOPE stamps that fade in with the drag direction. */
showStamps?: boolean;
/** Undo / skip / like buttons under the stack. */
showControls?: boolean;
likeLabel?: string;
nopeLabel?: string;
/** Called once per throw (drag, button or arrow key), not on undo. */
onSwipe?: (item: SwipeCardItem, direction: SwipeDirection) => void;
/** Replaces the default card content; the card frame and stamps stay. */
renderCard?: (item: SwipeCardItem, ctx: SwipeCardRenderContext) => ReactNode;
/** Shown when the deck is exhausted (`loop={false}`). */
endSlot?: ReactNode;
/** Spring of the stack re-settling and the snap-back. */
springStiffness?: number;
springDamping?: number;
className?: string;
"aria-label"?: string;
};
/** Px each card behind sits lower than the one in front. Mirrors --scs-step. */
const STEP_Y = 14;
const STEP_SCALE = 0.055;
/** Alternating tilt of the cards behind, so the deck reads as hand-stacked. */
const BASE_TILT = [0, -2.6, 2.2, -1.6, 1.2];
/** A release this fast (px/s) throws the card even below `threshold`. */
const FLING_VELOCITY = 600;
const HISTORY_LIMIT = 50;
type Move = {
action: "swipe" | "undo";
dir: SwipeDirection;
/** Px the card travels to leave the stack container. */
distance: number;
/** Release velocity — a hard throw leaves faster. */
velocity: number;
};
const mod = (n: number, m: number) => ((n % m) + m) % m;
const sign = (dir: SwipeDirection) => (dir === "right" ? 1 : -1);
function depthPose(depth: number, visible: number) {
return {
x: 0,
y: depth * STEP_Y,
scale: 1 - depth * STEP_SCALE,
rotate: BASE_TILT[depth] ?? 0,
// The staging card one level beyond the visible stack is invisible, so
// the next card fades in at the back instead of popping in.
opacity: depth < visible ? 1 : 0,
};
}
function artBackground(image: string) {
return /gradient\(|^url\(/i.test(image.trim()) ? image : `url("${image}")`;
}
function DefaultCard({
item,
ctx,
}: {
item: SwipeCardItem;
ctx: SwipeCardRenderContext;
}) {
const vars: CSSVars = {};
if (item.accent) vars["--scs-accent"] = item.accent;
const pad = (n: number) => String(n).padStart(2, "0");
return (
<div className={styles.defaultCard} style={vars}>
<div
className={styles.art}
style={item.image ? { backgroundImage: artBackground(item.image) } : undefined}
>
<span className={styles.counter} aria-hidden>
{pad(ctx.index + 1)} / {pad(ctx.total)}
</span>
<span className={styles.quoteMark} aria-hidden>
“
</span>
</div>
<div className={styles.content}>
{item.body && <p className={styles.body}>{item.body}</p>}
<div className={styles.person}>
<span className={styles.avatar} aria-hidden>
{item.title.trim().charAt(0)}
</span>
<span className={styles.personText}>
<span className={styles.name}>{item.title}</span>
{item.subtitle && <span className={styles.role}>{item.subtitle}</span>}
</span>
</div>
</div>
</div>
);
}
type StackCardProps = {
item: SwipeCardItem;
ctx: SwipeCardRenderContext;
depth: number;
visible: number;
zIndex: number;
/** Mount pose; `undefined` = mount straight into the depth pose. */
enterFrom?: ReturnType<typeof depthPose>;
threshold: number;
rotateFactor: number;
showStamps: boolean;
likeLabel: string;
nopeLabel: string;
spring: { type: "spring"; stiffness: number; damping: number };
onThrow: (dir: SwipeDirection, velocity: number) => void;
renderCard?: SwipeCardStackProps["renderCard"];
/** Deck position; lets the stack find the new top card to focus. */
pos: number;
};
/**
* One card. Split out so each card owns its motion values — hooks can't be
* called inside `items.map`. Two layers on purpose:
* - the outer layer holds the stack pose (y / scale / tilt) and the fly-off
* exit, driven by `animate` / AnimatePresence;
* - the inner layer holds the drag offset.
* Tilt and stamps read the SUM of both x values, so a throw continues from
* wherever the finger let go, and a button press tilts and stamps exactly
* like a drag.
*/
function StackCard({
item,
ctx,
depth,
visible,
zIndex,
enterFrom,
threshold,
rotateFactor,
showStamps,
likeLabel,
nopeLabel,
spring,
onThrow,
renderCard,
pos,
}: StackCardProps) {
const isTop = depth === 0;
const flyX = useMotionValue(0);
const dragX = useMotionValue(0);
const totalX = useTransform(() => flyX.get() + dragX.get());
const rotate = useTransform(totalX, (x) => (x / 100) * rotateFactor);
const likeOpacity = useTransform(totalX, [threshold * 0.2, threshold], [0, 1]);
const nopeOpacity = useTransform(totalX, [-threshold, -threshold * 0.2], [1, 0]);
// Undo while this card is still flying re-adds it mid-exit (same key):
// the outer layer animates back via `animate`, but the drag offset on the
// inner layer has to be reset by hand.
const isPresent = useIsPresent();
useEffect(() => {
if (isPresent && dragX.get() !== 0) animate(dragX, 0, spring);
// `spring` is a fresh object each render; only presence matters here.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isPresent, dragX]);
const handleDragEnd = (_: unknown, info: PanInfo) => {
const x = dragX.get();
const v = info.velocity.x;
if (x > threshold || (v > FLING_VELOCITY && x > 0)) onThrow("right", v);
else if (x < -threshold || (v < -FLING_VELOCITY && x < 0)) onThrow("left", v);
else animate(dragX, 0, spring);
};
const pose = depthPose(depth, visible);
return (
<motion.div
className={styles.slot}
style={{ x: flyX }}
initial={enterFrom ?? false}
animate={{ ...pose, zIndex }}
transition={{ ...spring, opacity: { duration: 0.25 }, zIndex: { duration: 0 } }}
// Variant functions are the only place AnimatePresence's live `custom`
// reaches a leaving card — its own props are frozen at removal time.
variants={{
exit: (move: Move) => {
if (depth === 0 && move.action === "swipe") {
const duration = Math.min(
0.5,
Math.max(0.28, 0.5 - Math.abs(move.velocity) / 6000),
);
return {
x: sign(move.dir) * move.distance,
opacity: 0,
// Keep the leaving card above the one rising into its place.
zIndex: 100,
transition: {
x: { duration, ease: [0.32, 0.72, 0, 1] },
// Reduced motion makes the x move instant; the fade still
// runs, so the throw reads as a dissolve instead of a pop.
opacity: { duration: duration * 0.55, delay: duration * 0.45 },
zIndex: { duration: 0 },
},
};
}
return { opacity: 0, transition: { duration: 0.2 } };
},
}}
exit="exit"
aria-hidden={isTop ? undefined : true}
inert={!isTop}
>
<motion.div
data-pos={pos}
className={styles.card}
style={{ x: dragX, rotate }}
drag={isTop ? "x" : false}
// No constraints + no momentum: the card stays where it's released,
// then either flies on from there or springs back (handleDragEnd).
dragMomentum={false}
onDragEnd={isTop ? handleDragEnd : undefined}
whileDrag={{ cursor: "grabbing" }}
role={isTop ? "group" : undefined}
aria-roledescription={isTop ? "カード" : undefined}
aria-label={isTop ? `${item.title}(${ctx.index + 1} / ${ctx.total})` : undefined}
tabIndex={isTop ? 0 : -1}
>
{renderCard ? renderCard(item, ctx) : <DefaultCard item={item} ctx={ctx} />}
{/* Cards behind get darker with depth: reads as distance. */}
<motion.div
className={styles.shade}
aria-hidden
initial={false}
animate={{ opacity: Math.min(depth * 0.22, 0.6) }}
transition={{ duration: 0.3 }}
/>
{showStamps && isTop && (
<>
<motion.span
className={`${styles.stamp} ${styles.stampLike}`}
style={{ opacity: likeOpacity }}
aria-hidden
>
{likeLabel}
</motion.span>
<motion.span
className={`${styles.stamp} ${styles.stampNope}`}
style={{ opacity: nopeOpacity }}
aria-hidden
>
{nopeLabel}
</motion.span>
</>
)}
</motion.div>
</motion.div>
);
}
export function SwipeCardStack({
items,
loop = true,
visibleCount = 3,
threshold = 120,
rotateFactor = 6,
showStamps = true,
showControls = true,
likeLabel = "LIKE",
nopeLabel = "NOPE",
onSwipe,
renderCard,
endSlot,
springStiffness = 320,
springDamping = 30,
className,
"aria-label": ariaLabel = "カードスタック",
}: SwipeCardStackProps) {
const total = items.length;
const visible = Math.min(4, Math.max(1, Math.round(visibleCount)));
// Absolute deck position of the top card. Grows without bound in loop
// mode; the item is `items[pos mod total]`, and positions double as keys
// so a looping item never collides with its own leaving copy.
const [index, setIndex] = useState(0);
const [history, setHistory] = useState<SwipeDirection[]>([]);
const [move, setMove] = useState<Move>({
action: "swipe",
dir: "right",
distance: 0,
velocity: 0,
});
const rootRef = useRef<HTMLElement>(null);
const stackRef = useRef<HTMLDivElement>(null);
const refocusTop = useRef(false);
const exhausted = !loop && index >= total;
const canThrow = total > 0 && !exhausted;
const canUndo = total > 0 && (loop || index > 0);
const spring = {
type: "spring" as const,
stiffness: springStiffness,
damping: springDamping,
};
// Hand focus to the new top card only if the leaving one had it (keyboard
// users keep their place); never on mount. Looked up by deck position, not
// a shared ref: the leaving card stays mounted during its exit and would
// clear a shared ref when it finally unmounts.
useEffect(() => {
if (!refocusTop.current) return;
refocusTop.current = false;
const stack = stackRef.current;
const top = stack?.querySelector<HTMLElement>(`[data-pos="${index}"]`);
(top ?? stack)?.focus({ preventScroll: true });
}, [index]);
const flyDistance = () => {
const stack = stackRef.current;
const root = rootRef.current;
const width = root?.offsetWidth ?? 600;
const cardWidth = stack?.offsetWidth ?? 320;
// Half the container + a full card clears the edge even when tilted.
return width / 2 + cardWidth + 40;
};
const throwTop = (dir: SwipeDirection, velocity = 0) => {
if (!canThrow) return;
// Only the top card is focusable inside the stack (the rest are inert).
refocusTop.current = !!stackRef.current?.contains(document.activeElement);
setMove({ action: "swipe", dir, distance: flyDistance(), velocity });
setHistory((h) => [...h, dir].slice(-HISTORY_LIMIT));
setIndex((i) => i + 1);
onSwipe?.(items[mod(index, total)], dir);
};
const undo = () => {
if (!canUndo) return;
const dir = history.at(-1) ?? "left";
setMove({ action: "undo", dir, distance: flyDistance(), velocity: 0 });
setHistory((h) => h.slice(0, -1));
setIndex((i) => i - 1);
};
const handleKeyDown = (e: KeyboardEvent<HTMLElement>) => {
if (e.key === "ArrowRight") {
e.preventDefault();
throwTop("right");
} else if (e.key === "ArrowLeft") {
e.preventDefault();
throwTop("left");
}
};
// Visible cards plus one invisible staging card, never more than the
// deck holds (so the same item can't appear twice in the stack).
const remaining = loop ? total : Math.max(0, total - index);
const count = Math.min(visible + 1, remaining);
const cards = Array.from({ length: count }, (_, depth) => {
const pos = index + depth;
return { pos, depth, item: items[mod(pos, total)], itemIndex: mod(pos, total) };
}).reverse(); // deepest first → top card last in DOM order
const vars: CSSVars = {};
if (visible !== 3) vars["--scs-depth-room"] = `${(visible - 1) * STEP_Y}px`;
const announcement =
total === 0
? ""
: exhausted
? `すべてのカードを見ました(${total} 枚)`
: `${mod(index, total) + 1} / ${total}`;
return (
<MotionConfig reducedMotion="user">
<section
ref={rootRef}
className={[styles.root, className].filter(Boolean).join(" ")}
style={vars}
aria-roledescription="カードスタック"
aria-label={ariaLabel}
onKeyDown={handleKeyDown}
>
<div ref={stackRef} className={styles.stack} tabIndex={-1}>
<AnimatePresence custom={move} initial={false}>
{cards.map(({ pos, depth, item, itemIndex }) => (
<StackCard
key={pos}
item={item}
ctx={{ index: itemIndex, total, isTop: depth === 0 }}
depth={depth}
visible={visible}
zIndex={10 + count - depth}
enterFrom={
depth === 0 && move.action === "undo"
? {
...depthPose(0, visible),
x: sign(move.dir) * move.distance,
opacity: 0,
}
: { ...depthPose(depth, visible), opacity: 0 }
}
threshold={threshold}
rotateFactor={rotateFactor}
showStamps={showStamps}
likeLabel={likeLabel}
nopeLabel={nopeLabel}
spring={spring}
onThrow={throwTop}
renderCard={renderCard}
pos={pos}
/>
))}
</AnimatePresence>
{exhausted && (
<div className={styles.end}>
{endSlot ?? (
<>
<p className={styles.endTitle}>すべて見ました</p>
<button
type="button"
className={styles.endButton}
onClick={() => {
setHistory([]);
setMove((m) => ({ ...m, action: "swipe" }));
setIndex(0);
}}
>
最初から
</button>
</>
)}
</div>
)}
</div>
{showControls && (
<div className={styles.controls}>
<button
type="button"
className={`${styles.control} ${styles.controlSmall}`}
onClick={undo}
disabled={!canUndo}
aria-label="前のカードに戻る"
>
<Undo2 size={17} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={`${styles.control} ${styles.controlNope}`}
onClick={() => throwTop("left")}
disabled={!canThrow}
aria-label="スキップ"
>
<X size={22} strokeWidth={2.2} aria-hidden />
</button>
<button
type="button"
className={`${styles.control} ${styles.controlLike}`}
onClick={() => throwTop("right")}
disabled={!canThrow}
aria-label="いいね"
>
<Heart size={20} strokeWidth={2.2} aria-hidden />
</button>
</div>
)}
<p className={styles.srOnly} aria-live="polite" aria-atomic="true">
{announcement}
</p>
</section>
</MotionConfig>
);
}
export default SwipeCardStack;
```
#### `components/swipe-card-stack/SwipeCardStack.module.css`
```css
/* ==========================================================================
Swipe card stack — a hand-stacked deck of cards. The top card is thrown
left / right; the cards behind sit lower, smaller, slightly tilted and
darker, so the depth reads even in a still frame.
framer-motion owns every transform (stack pose, drag, fly-off). This
file only sizes and paints.
========================================================================== */
.root {
/* The component only sets these inline for non-default values, so the
default render is exactly this stylesheet. */
--scs-card-width: 300px;
--scs-card-height: 380px;
/* Room under the stack for the cards peeking out below:
(visibleCount - 1) × 14px. Mirrors STEP_Y in the component. */
--scs-depth-room: 28px;
--scs-radius: 24px;
--scs-surface: #141416;
--scs-accent: #a78bfa;
--scs-like: #4ade80;
--scs-nope: #fb7185;
display: flex;
flex-direction: column;
align-items: center;
gap: 22px;
font-family:
"Inter", -apple-system, BlinkMacSystemFont, "Hiragino Sans",
"Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto,
sans-serif;
color: #fff;
}
.stack {
position: relative;
/* Sized from the container (max-width: 100%), never the viewport. */
width: var(--scs-card-width);
max-width: 100%;
height: var(--scs-card-height);
margin-bottom: var(--scs-depth-room);
outline: none;
}
/* Every card fills the stack box; framer moves it from there. Scaling
from the bottom keeps the cards behind peeking out under the top one. */
.slot {
position: absolute;
inset: 0;
transform-origin: 50% 100%;
}
.card {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: var(--scs-radius);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.045), rgba(255, 255, 255, 0) 40%),
var(--scs-surface);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.1),
0 1px 2px rgba(0, 0, 0, 0.4),
0 24px 48px -12px rgba(0, 0, 0, 0.6);
cursor: grab;
user-select: none;
-webkit-user-select: none;
/* Horizontal drag is ours; vertical swipes still scroll the page. */
touch-action: pan-y;
-webkit-tap-highlight-color: transparent;
}
.card:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: 3px;
}
/* Darkens the cards behind (opacity animated per depth). */
.shade {
position: absolute;
inset: 0;
background: #050506;
pointer-events: none;
}
/* --- Default card -------------------------------------------------------- */
.defaultCard {
display: flex;
flex-direction: column;
height: 100%;
}
.art {
position: relative;
flex: 0 0 40%;
background:
radial-gradient(80% 90% at 20% 10%, color-mix(in srgb, var(--scs-accent) 70%, transparent), transparent 70%),
linear-gradient(135deg, #1d1b2e, #0f0f14);
background-size: cover;
background-position: center;
}
/* Film grain + a fade into the card surface, so gradients don't band and
the art melts into the text area instead of ending on a hard line. */
.art::before,
.art::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
}
.art::before {
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.22;
mix-blend-mode: overlay;
}
.art::after {
background: linear-gradient(180deg, transparent 45%, var(--scs-surface));
}
.counter {
position: absolute;
top: 14px;
left: 14px;
z-index: 1;
padding: 4px 9px;
border-radius: 999px;
font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, monospace;
font-size: 11px;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.82);
background: rgba(10, 10, 12, 0.35);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.14);
/* Unprefixed only: the build adds -webkit-, and writing both makes
Lightning CSS drop the unprefixed one (no blur in Chrome). */
backdrop-filter: blur(8px);
}
.quoteMark {
position: absolute;
left: 20px;
bottom: -30px;
z-index: 1;
font-family: Georgia, "Times New Roman", serif;
font-size: 84px;
line-height: 1;
color: var(--scs-accent);
text-shadow: 0 0 28px color-mix(in srgb, var(--scs-accent) 55%, transparent);
}
.content {
display: flex;
flex: 1;
flex-direction: column;
justify-content: space-between;
gap: 16px;
min-height: 0;
padding: 26px 22px 20px;
}
.body {
display: -webkit-box;
margin: 0;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
font-size: 14.5px;
line-height: 1.75;
letter-spacing: 0.01em;
color: rgba(255, 255, 255, 0.86);
text-wrap: pretty;
}
.person {
display: flex;
align-items: center;
gap: 11px;
padding-top: 14px;
border-top: 1px solid rgba(255, 255, 255, 0.07);
}
.avatar {
display: grid;
flex: none;
place-items: center;
width: 34px;
height: 34px;
border-radius: 50%;
font-size: 14px;
font-weight: 600;
color: #fff;
background:
linear-gradient(135deg, color-mix(in srgb, var(--scs-accent) 85%, #fff), color-mix(in srgb, var(--scs-accent) 55%, #000));
box-shadow:
0 0 0 2px var(--scs-surface),
0 0 0 3px color-mix(in srgb, var(--scs-accent) 50%, transparent);
}
.personText {
display: flex;
flex-direction: column;
min-width: 0;
}
.name {
font-size: 14px;
font-weight: 600;
letter-spacing: -0.005em;
}
.role {
overflow: hidden;
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
white-space: nowrap;
text-overflow: ellipsis;
}
/* --- Stamps -------------------------------------------------------------- */
.stamp {
position: absolute;
top: 26px;
z-index: 2;
padding: 5px 12px;
border: 2.5px solid currentColor;
border-radius: 10px;
font-size: 22px;
font-weight: 800;
letter-spacing: 0.12em;
line-height: 1.1;
background: rgba(10, 10, 12, 0.3);
backdrop-filter: blur(6px);
box-shadow: 0 0 24px -4px currentColor;
pointer-events: none;
}
/* LIKE shows on the left while the card moves right, like a rubber stamp
on the side the card is leaving from. */
.stampLike {
left: 20px;
color: var(--scs-like);
transform: rotate(-14deg);
}
.stampNope {
right: 20px;
color: var(--scs-nope);
transform: rotate(14deg);
}
/* --- End slot ------------------------------------------------------------ */
.end {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
border-radius: var(--scs-radius);
border: 1px dashed rgba(255, 255, 255, 0.16);
text-align: center;
}
.endTitle {
margin: 0;
font-size: 15px;
color: rgba(255, 255, 255, 0.7);
}
.endButton {
padding: 9px 18px;
border: 0;
border-radius: 999px;
font: inherit;
font-size: 13px;
font-weight: 600;
color: #0a0a0a;
background: #fff;
cursor: pointer;
}
/* --- Controls ------------------------------------------------------------ */
.controls {
display: flex;
align-items: center;
gap: 14px;
}
.control {
display: grid;
place-items: center;
width: 54px;
height: 54px;
padding: 0;
border: 0;
border-radius: 50%;
color: rgba(255, 255, 255, 0.9);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.09), rgba(255, 255, 255, 0.03));
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.12),
0 8px 20px -6px rgba(0, 0, 0, 0.6);
cursor: pointer;
transition:
transform 120ms ease,
color 160ms ease,
box-shadow 160ms ease,
opacity 160ms ease;
-webkit-tap-highlight-color: transparent;
}
.control:hover:not(:disabled) {
transform: translateY(-1px);
}
.control:active:not(:disabled) {
transform: scale(0.94);
}
.control:focus-visible,
.endButton:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.85);
outline-offset: 3px;
}
.control:disabled {
opacity: 0.35;
cursor: default;
}
.controlSmall {
width: 42px;
height: 42px;
color: rgba(255, 255, 255, 0.65);
}
.controlNope:hover:not(:disabled) {
color: var(--scs-nope);
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--scs-nope) 45%, transparent),
0 8px 24px -6px color-mix(in srgb, var(--scs-nope) 45%, transparent);
}
.controlLike {
color: #0a0a0a;
background: linear-gradient(180deg, #fff, #d9d9de);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.9),
0 10px 28px -8px rgba(255, 255, 255, 0.35);
}
.controlLike:hover:not(:disabled) {
color: #e11d48;
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
@media (prefers-reduced-motion: reduce) {
.control {
transition: none;
}
.control:hover:not(:disabled),
.control:active:not(:disabled) {
transform: none;
}
}
```
代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/swipe-card-stack.json
```
### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { SwipeCardStack } from "@/components/swipe-card-stack";
// TODO: items はプレビューと同じ仮のお客様の声です。実データ(id / title / subtitle / body / image / accent)に差し替えてください。image は CSS グラデーションか画像 URL
// TODO: スワイプ結果を受け取るには onSwipe={(item, direction) => …} を渡します(direction は "left" | "right")
// TODO: カードの中身を自作するなら renderCard={(item, { index, total, isTop }) => …} を渡します。枠・スタンプ・ドラッグはそのまま使われます
<SwipeCardStack
visibleCount={3}
showStamps={true}
showControls={true}
loop={true}
threshold={120}
rotateFactor={6}
springStiffness={320}
springDamping={30}
items={[
{
id: "sato",
title: "佐藤 美咲",
subtitle: "プロダクトデザイナー / Nordlight",
body: "デザインから実装までの往復がほぼゼロに。チーム全員が同じ画面を見て話せるようになりました。",
image: "linear-gradient(135deg, #7c3aed 0%, #db2777 55%, #f59e0b 100%)",
accent: "#c084fc",
},
{
id: "takahashi",
title: "高橋 健太",
subtitle: "CTO / Lumen Labs",
body: "導入初週でビルド時間が半分になりました。もう以前の構成には戻れません。",
image: "linear-gradient(135deg, #0ea5e9 0%, #6366f1 60%, #1e1b4b 100%)",
accent: "#60a5fa",
},
{
id: "kobayashi",
title: "小林 葵",
subtitle: "フロントエンドエンジニア / Kite",
body: "アニメーションの細部まで気が利いていて、そのまま本番に出せる品質。レビューが楽しくなりました。",
image: "linear-gradient(135deg, #10b981 0%, #0d9488 50%, #164e63 100%)",
accent: "#34d399",
},
{
id: "yamamoto",
title: "山本 拓海",
subtitle: "創業者 / Mosaic",
body: "LP の転換率が 1.4 倍に。小さなチームでも大きな会社と同じ体験を届けられます。",
image: "linear-gradient(135deg, #f97316 0%, #e11d48 60%, #4c0519 100%)",
accent: "#fb923c",
},
{
id: "nakamura",
title: "中村 さくら",
subtitle: "マーケティングリード / Hanabi",
body: "お客様の声をこの形で見せてから、問い合わせ前の離脱が目に見えて減りました。",
image: "linear-gradient(135deg, #f472b6 0%, #a855f7 55%, #312e81 100%)",
accent: "#f472b6",
},
]}
aria-label="お客様の声"
/>
```
### 4. 組み込み手順
1. スタックを置くコンポーネントを `"use client"` にして `<SwipeCardStack items={[…]} aria-label="お客様の声" />` を描画する。`onSwipe` / `renderCard` は関数なので、Server Component から props として渡すことはできない(items だけなら Server Component から渡せる)。
2. `items` は `{ id, title, subtitle?, body?, image?, accent? }` の配列。`id` は一意にする。`image` は `linear-gradient(…)` などの CSS グラデーションか画像 URL(URL は `background-image` として表示されるので、`next/image` の最適化は効かない。大きな画像は事前に縮小しておく)。
3. 大きさは CSS 変数で変える: ラッパーに `[--scs-card-width:360px] [--scs-card-height:440px]` のようなクラスを付けるか、`className` で `--scs-card-width` / `--scs-card-height` を上書きする。幅は親を超えない(`max-width: 100%`)。
4. スタックの下には後ろのカードが覗く余白(`(visibleCount - 1) × 14px`)と操作ボタンが入る。高さが決まった枠に置くなら `カード高さ + 余白 + 約 80px` を確保する。
5. カードは横に飛んでいくので、**祖先要素に `overflow: hidden` があると枠の端で切れる**。切りたくない場合は祖先を `overflow: visible` にする(逆に、セクション内で切りたいならそのままでよい)。
6. スワイプ結果を保存するなら `onSwipe={(item, direction) => …}` を使う。`direction` は `"left"`(スキップ)/ `"right"`(いいね)。戻る(undo)では呼ばれない。
7. `loop={false}` のとき、最後までめくると `endSlot` が表示される(未指定なら「すべて見ました」+「最初から」ボタン)。
8. `npm run build` が通ることを確認し、「ドラッグで傾きながら飛ぶ」「しきい値未満で離すとバネで戻る」「ボタンと ← / → キーで同じ動き」「スマホで縦スクロールが妨げられない」を確認する。
#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| ループ時にカードが一瞬消える / 飛んだカードが戻ってくる | key に `item.id` を使っている(飛んでいくカードと山の一番下に戻るカードが同じ key になる) | key は山の中の通し位置(増え続ける数値)を使う。表示枚数も `items.length` を超えない |
| ボタンで飛ばすと逆方向に飛ぶ / 方向が古い | 退場中の要素の props は削除時点で固定される | 方向は `AnimatePresence custom={…}` から渡し、`exit` は variant 関数で受け取る |
| 離した瞬間に中央へ戻ってから飛ぶ | `dragConstraints={{ left: 0, right: 0 }}` で自動的にスナップバックしている | 制約を付けず `dragMomentum={false}`、しきい値未満のときだけ `animate(x, 0)` で戻す。飛ぶ動きは外側レイヤーの x に加算する |
| 飛んでいくカードが次のカードの下に潜る | 退場中の要素は DOM 上の位置が変わらず z-index が並ぶ | 退場 variant に `zIndex: 100` を入れる |
| スマホで縦スクロールできない | カードがすべてのタッチを奪っている | カードの `touch-action: pan-y` を外さない |
| 視差効果オフでもカードが飛ぶ | framer-motion は JS で動くので CSS の reduced-motion が効かない | `<MotionConfig reducedMotion="user">` を外さない。退場に opacity を含めているので、動きを減らす設定ではフェードになる |
| フックのエラー | `useMotionValue` / `useTransform` を `items.map` の中で呼んでいる | カードは子コンポーネント(`StackCard`)に分け、フックはその中で呼ぶ |
### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる
## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- Next.js(App Router)、React 19、TypeScript、CSS Modules、`framer-motion`(v12 以降)、`lucide-react`
- ファイル: `components/swipe-card-stack/SwipeCardStack.tsx`(`"use client"`)+ `SwipeCardStack.module.css` + `index.ts`
- props: `items`(`{ id, title, subtitle?, body?, image?, accent? }[]`)、`loop = true`、`visibleCount = 3`(1〜4 に丸める)、`threshold = 120`(px)、`rotateFactor = 6`(100px あたりの度数)、`showStamps = true`、`showControls = true`、`likeLabel = "LIKE"`、`nopeLabel = "NOPE"`、`onSwipe?(item, direction)`、`renderCard?(item, { index, total, isTop })`、`endSlot?`、`springStiffness = 320`、`springDamping = 30`、`className`、`aria-label`
- 状態: 一番上のカードの通し位置 `index`(ループ時は増え続け、アイテムは `items[index mod n]`)と、undo 用の方向履歴(最大 50 件)
### 見た目
- CSS 変数(ルートが既定値を持ち、既定と違う値だけインライン): `--scs-card-width: 300px`、`--scs-card-height: 380px`、`--scs-depth-room: 28px`、`--scs-radius: 24px`、`--scs-surface: #141416`、`--scs-accent: #a78bfa`、`--scs-like: #4ade80`、`--scs-nope: #fb7185`
- ルート: 縦 flex・中央揃え・gap 22px。スタック: `width: var(--scs-card-width); max-width: 100%; height: var(--scs-card-height)`、下に `--scs-depth-room` の余白(後ろのカードが覗く分)
- 重なり: 表示枚数 + 1 枚(見えない待機カード)を描画し、深さ d のカードは `y = 14d px`、`scale = 1 − 0.055d`、傾き `[0, −2.6, 2.2, −1.6]°`、`transform-origin: 50% 100%`(下端基準なので下に覗く)。待機カードは opacity 0。後ろのカードほど暗い(黒 `#050506` の幕を opacity `min(0.22d, 0.6)`)
- カード: 角丸 24px、`overflow: hidden`、面 `--scs-surface` + 上から白 4.5% のグラデーション、`inset 0 0 0 1px 白 .08` / `inset 0 1px 0 白 .10` / `0 24px 48px −12px 黒 .6`
- 既定のカード: 上 40% がアート(`image` を背景に、無ければアクセント色の放射グラデーション)。アートにはフィルムグレイン(SVG `feTurbulence`、opacity .22、overlay)と下端を面の色へ溶かすグラデーション。左上に `01 / 05` の等幅チップ(ガラス、`backdrop-filter: blur(8px)` unprefixed のみ)、アート下端にアクセント色の大きな引用符(Georgia 84px、発光)
- 本文: 14.5px / 行間 1.75 / 白 .86、5 行で省略。下に区切り線と人物行(頭文字のアバター 34px、アクセント色グラデーション + 二重リング、名前 14px / 600、肩書 12px / 白 .5)
- スタンプ: 上 26px、2.5px の枠・角丸 10px・22px / 800・字間 .12em、半透明の黒 + ぼかし、同色の発光。LIKE は左上で −14°、NOPE は右上で 14°
- 操作ボタン: 戻る 42px(控えめ)、スキップ 54px(ガラス、ホバーで NOPE 色)、いいね 54px(白いグラデーション、ホバーでハートが赤)。押下 `scale(.94)`、無効時 opacity .35
### モーション
- カードは 2 層: 外側 = 山の中の姿勢(y / scale / 傾き / opacity)と退場、内側 = ドラッグの x。傾き `(外側x + 内側x) / 100 × rotateFactor`、LIKE の opacity は合計 x が `threshold × 0.2 → threshold` で 0 → 1(NOPE は左右反転)
- ドラッグ: `drag="x"`、制約なし、`dragMomentum={false}`。離したとき `|x| > threshold`、または同じ向きに `|速度| > 600px/s` なら飛ばす。それ以外はバネ(`springStiffness` / `springDamping`)で 0 に戻す
- 飛ばす: `AnimatePresence custom={move} initial={false}`、退場 variant が外側 x を `±(コンテナ幅/2 + カード幅 + 40px)` まで動かす(`0.28〜0.5s`、速く振るほど短い、ease `[.32,.72,0,1]`)。opacity は後半 55% でフェード、`zIndex: 100`。後ろのカードはバネで一段ずつせり上がり、待機カードがフェードイン
- 戻る: 最後に飛ばした方向の画面外から opacity 0 で入ってきて一番上に戻る。履歴が無いとき(ループ時)は左から
- ボタン・← / → キーはドラッグと同じ関数を呼ぶので、傾き・スタンプも同じように出る
- `<MotionConfig reducedMotion="user">` で包む(transform は即時、フェードは残る)
### アクセシビリティ
- ルートは `<section aria-roledescription="カードスタック" aria-label>`(名前付き section = region)。キー操作はルートで受ける(← スキップ / → いいね)
- 一番上のカードだけ `role="group" aria-roledescription="カード" aria-label="名前(n / 全体)" tabIndex={0}`。後ろのカードは `aria-hidden` + `inert`
- フォーカスのあるカードを飛ばしたら新しい一番上のカードへフォーカスを移す(それ以外ではフォーカスを動かさない。マウント時も動かさない)
- ボタンは `aria-label`(前のカードに戻る / スキップ / いいね)、`:focus-visible` は白 2px アウトライン
- 視覚的に隠した `aria-live="polite"` 領域で「n / 全体」を読み上げ、ループなしで最後まで行ったら「すべてのカードを見ました」
### 受け入れ条件
- 静止状態で後ろのカードが下にずれて覗き、立体的な山に見える
- ドラッグ方向に傾き、スタンプが浮かび、しきい値を超えて離すとその位置から自然に飛んでいく。未満ならバネで戻る
- ボタン・矢印キーでも同じ演出で飛び、戻るで直前のカードが飛んだ方向から戻ってくる
- ループ時は無限にめくれ、ループなしでは終了表示が出る
- スマホで縦スクロールが妨げられないインストール
npx shadcn@latest add https://anima-js.vercel.app/r/swipe-card-stack.json生成コード
import { SwipeCardStack } from "@/components/swipe-card-stack";
// TODO: items はプレビューと同じ仮のお客様の声です。実データ(id / title / subtitle / body / image / accent)に差し替えてください。image は CSS グラデーションか画像 URL
// TODO: スワイプ結果を受け取るには onSwipe={(item, direction) => …} を渡します(direction は "left" | "right")
// TODO: カードの中身を自作するなら renderCard={(item, { index, total, isTop }) => …} を渡します。枠・スタンプ・ドラッグはそのまま使われます
<SwipeCardStack
visibleCount={3}
showStamps={true}
showControls={true}
loop={true}
threshold={120}
rotateFactor={6}
springStiffness={320}
springDamping={30}
items={[
{
id: "sato",
title: "佐藤 美咲",
subtitle: "プロダクトデザイナー / Nordlight",
body: "デザインから実装までの往復がほぼゼロに。チーム全員が同じ画面を見て話せるようになりました。",
image: "linear-gradient(135deg, #7c3aed 0%, #db2777 55%, #f59e0b 100%)",
accent: "#c084fc",
},
{
id: "takahashi",
title: "高橋 健太",
subtitle: "CTO / Lumen Labs",
body: "導入初週でビルド時間が半分になりました。もう以前の構成には戻れません。",
image: "linear-gradient(135deg, #0ea5e9 0%, #6366f1 60%, #1e1b4b 100%)",
accent: "#60a5fa",
},
{
id: "kobayashi",
title: "小林 葵",
subtitle: "フロントエンドエンジニア / Kite",
body: "アニメーションの細部まで気が利いていて、そのまま本番に出せる品質。レビューが楽しくなりました。",
image: "linear-gradient(135deg, #10b981 0%, #0d9488 50%, #164e63 100%)",
accent: "#34d399",
},
{
id: "yamamoto",
title: "山本 拓海",
subtitle: "創業者 / Mosaic",
body: "LP の転換率が 1.4 倍に。小さなチームでも大きな会社と同じ体験を届けられます。",
image: "linear-gradient(135deg, #f97316 0%, #e11d48 60%, #4c0519 100%)",
accent: "#fb923c",
},
{
id: "nakamura",
title: "中村 さくら",
subtitle: "マーケティングリード / Hanabi",
body: "お客様の声をこの形で見せてから、問い合わせ前の離脱が目に見えて減りました。",
image: "linear-gradient(135deg, #f472b6 0%, #a855f7 55%, #312e81 100%)",
accent: "#f472b6",
},
]}
aria-label="お客様の声"
/>