カルーセル
ParallaxSlider
Motion
フルブリードのヒーロースライダー — ドラッグや切り替えの間、背景アートがスライドより遅れて動くパララックス。止まると見出しが eyebrow → タイトル → 本文 → CTA の順に立ち上がります。フェード+ズーム / カーテンのワイプにも切り替え可能。プログレスライン付き自動再生、慣性スワイプ、キーボード操作。framer-motion。
AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約56 KB)。
# ParallaxSlider をこのプロジェクトに追加してください
このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。
## コンポーネントについて
- 名前: ParallaxSlider
- 説明: フルブリードのヒーロースライダー — ドラッグや切り替えの間、背景アートがスライドより遅れて動くパララックス。止まると見出しが eyebrow → タイトル → 本文 → CTA の順に立ち上がります。フェード+ズーム / カーテンのワイプにも切り替え可能。プログレスライン付き自動再生、慣性スワイプ、キーボード操作。framer-motion。
- 実行時に必要な npm パッケージ: framer-motion, lucide-react
- 構成ファイル: `components/parallax-slider/index.tsx`, `components/parallax-slider/ParallaxSlider.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/parallax-slider/index.tsx`
```tsx
"use client";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
useSyncExternalStore,
type FocusEvent as ReactFocusEvent,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import {
AnimatePresence,
MotionConfig,
animate,
motion,
useMotionValue,
useTransform,
type AnimationPlaybackControls,
type MotionValue,
type Variants,
} from "framer-motion";
import { ArrowLeft, ArrowRight, ArrowUpRight, Pause, Play } from "lucide-react";
import styles from "./ParallaxSlider.module.css";
export type ParallaxSliderTransition = "parallax" | "fade-zoom" | "curtain";
export type ParallaxSliderItem = {
id?: string;
/** Big caption line. Also used in the live-region announcement. */
title?: string;
/** Supporting copy under the title. */
subtitle?: string;
/** Small label above the title (e.g. "01 — ALPINE DAWN"). */
eyebrow?: string;
/**
* Slide art. A CSS background value (`linear-gradient(…)`, `url(…)`, a
* layered list) is used as-is; anything else is treated as an image URL.
*/
image?: string;
/** Accessible description of `image`. Omit for decorative art. */
alt?: string;
/** Optional call-to-action link, staggered in last. */
cta?: { label: string; href: string };
};
export type ParallaxSliderRenderState = {
/** 0-based position of this item in `items`. */
index: number;
total: number;
/** True for the slide the slider is on (or heading to). */
isActive: boolean;
};
export type ParallaxSliderProps = {
items: readonly ParallaxSliderItem[];
/**
* "parallax": slides move horizontally, the art trails behind.
* "fade-zoom": crossfade with a slow Ken Burns push on the active art.
* "curtain": a clip-path wipe that reveals the next slide.
*/
transition?: ParallaxSliderTransition;
/**
* How much the art lags behind its slide, 0–1. 0 = art moves with the
* slide, 1 = art stays put while the slide frame slides over it.
*/
parallax?: number;
/** Advance automatically. Pauses on hover, keyboard focus, offscreen. */
autoplay?: boolean;
/** Autoplay interval in ms (also the progress line's fill time). */
interval?: number;
/** Wrap around at both ends. Off: prev/next disable at the ends. */
loop?: boolean;
showProgress?: boolean;
showCounter?: boolean;
/** Controlled slide index. Pair with `onIndexChange`. */
index?: number;
/** Initial slide when uncontrolled. */
defaultIndex?: number;
onIndexChange?: (index: number) => void;
/**
* Custom art for a slide (an <img>, <video>, SVG scene…). It replaces
* the `image` background but keeps the parallax / Ken Burns / curtain
* treatment and the default caption.
*/
renderItem?: (
item: ParallaxSliderItem,
state: ParallaxSliderRenderState,
) => ReactNode;
className?: string;
"aria-label"?: string;
};
const DEFAULT_PARALLAX = 0.6;
const DEFAULT_INTERVAL = 6000;
/** Pointer travel before a press becomes a drag (keeps link taps working). */
const DRAG_SLOP_PX = 6;
/** Release speed (px/ms) that counts as a flick regardless of distance. */
const FLICK_VELOCITY = 0.35;
/** Release distance (fraction of a slide) that changes slide without a flick. */
const DISTANCE_THRESHOLD = 0.22;
/** Rubber-band factor when dragging past either end with loop off. */
const EDGE_RESISTANCE = 0.32;
/** Programmatic (button / key / autoplay) transitions, tuned per style. */
const TWEENS: Record<
ParallaxSliderTransition,
{ duration: number; ease: [number, number, number, number] }
> = {
parallax: { duration: 0.95, ease: [0.65, 0.05, 0.25, 1] },
"fade-zoom": { duration: 1.1, ease: [0.4, 0, 0.2, 1] },
curtain: { duration: 1.05, ease: [0.76, 0, 0.24, 1] },
};
function mod(n: number, m: number): number {
return ((n % m) + m) % m;
}
function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n));
}
/**
* Signed distance (in slides) from the current position to slide `i`.
* With loop on, it wraps to the nearest copy so the last slide sits just
* left of the first one.
*/
function slideOffset(i: number, pos: number, count: number, loop: boolean) {
const d = i - pos;
return loop && count > 1 ? d - count * Math.round(d / count) : d;
}
/** Gradients / url() pass through; a bare string is an image URL. */
function toBackground(image: string): string {
return /gradient\(|url\(/.test(image) ? image : `url("${image}")`;
}
const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
function subscribeReducedMotion(onChange: () => void) {
const mq = window.matchMedia(REDUCED_QUERY);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
/**
* Reduced-motion preference that hydrates as `false` and corrects right
* after. framer's useReducedMotion reads the real value on the first client
* render, which mismatches the server markup (pause button, progress mode)
* for users with the setting on.
*/
function usePrefersReducedMotion(): boolean {
return useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia(REDUCED_QUERY).matches,
() => false,
);
}
function pad2(n: number): string {
return String(n).padStart(2, "0");
}
// Caption: hidden on the way out (fast, reversed), staggered in once the
// slide has settled. Only transform/opacity animate.
const captionVariants: Variants = {
hidden: { transition: { staggerChildren: 0.03, staggerDirection: -1 } },
shown: { transition: { staggerChildren: 0.09, delayChildren: 0.05 } },
};
const lineVariants: Variants = {
hidden: { opacity: 0, y: 18, transition: { duration: 0.22 } },
shown: {
opacity: 1,
y: 0,
transition: { duration: 0.7, ease: [0.22, 1, 0.36, 1] },
},
};
// The title rises out of its own mask (the h2 clips it).
const titleVariants: Variants = {
hidden: { opacity: 0, y: "108%", transition: { duration: 0.22 } },
shown: {
opacity: 1,
y: "0%",
transition: { duration: 0.9, ease: [0.22, 1, 0.36, 1] },
},
};
type SlideProps = {
item: ParallaxSliderItem;
index: number;
count: number;
loop: boolean;
transition: ParallaxSliderTransition;
pos: MotionValue<number>;
parallax: MotionValue<number>;
isActive: boolean;
captionShown: boolean;
kenBurnsSeconds: number;
reduced: boolean;
renderItem?: ParallaxSliderProps["renderItem"];
};
/**
* One full-bleed slide. Every visual is derived from the shared `pos`
* motion value, so dragging and transitions never re-render React — the
* per-style transforms are just different read-outs of the same offset.
* `count`, `loop` and `transition` are baked into the transformers; the
* parent remounts slides (via key) when they change.
*/
function Slide({
item,
index,
count,
loop,
transition,
pos,
parallax,
isActive,
captionShown,
kenBurnsSeconds,
reduced,
renderItem,
}: SlideProps) {
const offset = useTransform(pos, (p) => slideOffset(index, p, count, loop));
// Fully out of frame → skip painting it entirely.
const visibility = useTransform(offset, (o) =>
Math.abs(o) >= 0.999 ? "hidden" : "visible",
);
// The slide to the right of the boundary is the one being revealed /
// faded in, so it stacks on top in the overlay styles.
const zIndex = useTransform(offset, (o) => (o > 0 ? 2 : 1));
// parallax: frame slides a full width; art counter-shifts by `parallax`
// of that, so it travels at (1 − parallax) of the slide's speed. The
// frame clips the art, and the visible part is always covered, so the
// art needs no oversizing.
const slideX = useTransform(offset, (o) => `${o * 100}%`);
const artX = useTransform(
[offset, parallax],
([o, p]: number[]) => `${-o * p * 100}%`,
);
const shade = useTransform(offset, (o) => Math.min(1, Math.abs(o)) * 0.55);
// fade-zoom: the incoming slide fades in over a solid outgoing one, so
// the crossfade never dips to the backdrop halfway through.
const opacity = useTransform(offset, (o) => (o > 0 ? 1 - o : 1));
// curtain: the two slides tile the frame and the seam wipes across;
// the revealed art settles from a slight zoom.
const clipPath = useTransform(offset, (o) =>
o >= 0
? `inset(0% 0% 0% ${o * 100}%)`
: `inset(0% ${-o * 100}% 0% 0%)`,
);
const curtainScale = useTransform(
offset,
(o) => 1 + 0.14 * Math.min(1, Math.abs(o)),
);
const seamX = useTransform(offset, (o) => `${o * 100}%`);
const seamOpacity = useTransform(offset, (o) => (o > 0.002 && o < 0.998 ? 1 : 0));
const slideStyle =
transition === "parallax"
? { x: slideX, visibility }
: transition === "fade-zoom"
? { opacity, zIndex, visibility }
: { clipPath, zIndex, visibility };
const artStyle =
transition === "parallax"
? { x: artX }
: transition === "curtain"
? { scale: curtainScale }
: undefined;
// Ken Burns: the active art pushes in slowly for a little longer than
// the interval; the outgoing art resets only after it has faded out.
const kenBurns =
transition === "fade-zoom" && !reduced
? isActive
? { scale: 1.1, transition: { duration: kenBurnsSeconds, ease: "linear" as const } }
: { scale: 1, transition: { duration: 0, delay: 1.2 } }
: { scale: 1, transition: { duration: 0 } };
const state: ParallaxSliderRenderState = { index, total: count, isActive };
return (
<motion.div
className={styles.slide}
style={slideStyle}
role="group"
aria-roledescription="スライド"
aria-label={`${index + 1} / ${count}`}
aria-hidden={!isActive}
inert={!isActive}
>
<motion.div className={styles.art} style={artStyle}>
<motion.div
className={styles.artInner}
initial={{ scale: 1 }}
animate={kenBurns}
>
{renderItem ? (
renderItem(item, state)
) : item.image ? (
<div
className={styles.artImage}
style={{ background: toBackground(item.image) }}
{...(item.alt
? { role: "img", "aria-label": item.alt }
: { "aria-hidden": true })}
/>
) : (
<div className={styles.artFallback} aria-hidden />
)}
</motion.div>
</motion.div>
{transition === "parallax" && (
<motion.div className={styles.shade} style={{ opacity: shade }} aria-hidden />
)}
<div className={styles.scrim} aria-hidden />
{(item.eyebrow || item.title || item.subtitle || item.cta) && (
<motion.div
className={styles.caption}
variants={captionVariants}
initial={false}
animate={captionShown ? "shown" : "hidden"}
>
{item.eyebrow && (
<motion.p className={styles.eyebrow} variants={lineVariants}>
{item.eyebrow}
</motion.p>
)}
{item.title && (
<h2 className={styles.title}>
<motion.span className={styles.titleInner} variants={titleVariants}>
{item.title}
</motion.span>
</h2>
)}
{item.subtitle && (
<motion.p className={styles.subtitle} variants={lineVariants}>
{item.subtitle}
</motion.p>
)}
{item.cta && (
<motion.a
className={styles.cta}
href={item.cta.href}
variants={lineVariants}
draggable={false}
>
{item.cta.label}
<ArrowUpRight size={16} strokeWidth={2.2} aria-hidden />
</motion.a>
)}
</motion.div>
)}
{transition === "curtain" && (
<motion.div
className={styles.seam}
style={{ x: seamX, opacity: seamOpacity }}
aria-hidden
/>
)}
</motion.div>
);
}
type DragState = {
pointerId: number;
startX: number;
startY: number;
startPos: number;
width: number;
/** null until the gesture is classified as horizontal or vertical. */
axis: "x" | "y" | null;
lastX: number;
lastT: number;
velocity: number;
};
export function ParallaxSlider({
items,
transition = "parallax",
parallax = DEFAULT_PARALLAX,
autoplay = true,
interval = DEFAULT_INTERVAL,
loop = true,
showProgress = true,
showCounter = true,
index: controlledIndex,
defaultIndex = 0,
onIndexChange,
renderItem,
className,
"aria-label": ariaLabel = "ヒーロースライダー",
}: ParallaxSliderProps) {
const count = items.length;
const reduced = usePrefersReducedMotion();
const viewportId = useId();
const rootRef = useRef<HTMLElement | null>(null);
const [internalIndex, setInternalIndex] = useState(() =>
clamp(defaultIndex, 0, Math.max(0, count - 1)),
);
const isControlled = controlledIndex !== undefined;
const current = clamp(
isControlled ? controlledIndex : internalIndex,
0,
Math.max(0, count - 1),
);
// Continuous slide position (unbounded with loop on). Everything visual
// reads from it; React state only tracks which slide is "current".
const pos = useMotionValue(current);
// The integer position the slider is resting at / heading to.
const targetRef = useRef(current);
const posAnimRef = useRef<AnimationPlaybackControls | null>(null);
const dragRef = useRef<DragState | null>(null);
const draggedRef = useRef(false);
const parallaxMV = useMotionValue(clamp(parallax, 0, 1));
useEffect(() => {
parallaxMV.set(clamp(parallax, 0, 1));
}, [parallax, parallaxMV]);
// Slide whose caption is staggered in. -1 while a transition runs.
const [shownIndex, setShownIndex] = useState(current);
const [announcement, setAnnouncement] = useState("");
const [dragging, setDragging] = useState(false);
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const [inView, setInView] = useState(true);
const [docHidden, setDocHidden] = useState(false);
const [userPaused, setUserPaused] = useState(false);
// Latest callbacks/props for the imperative helpers below, so they keep a
// stable identity and never capture a stale render.
const latest = useRef({ count, loop, transition, reduced, isControlled, onIndexChange, items });
useEffect(() => {
latest.current = { count, loop, transition, reduced, isControlled, onIndexChange, items };
});
/**
* Moves `pos` to an integer target. `commit` reports the new index
* (state + onIndexChange) — false when the index came in as a prop.
*/
const animateTo = useCallback(
(
target: number,
opts: {
commit: boolean;
announce: boolean;
velocity?: number;
},
) => {
const { count: n, transition: tr, reduced: rm, isControlled: ctl } =
latest.current;
if (n === 0) return;
const nextIndex = mod(target, n);
const prevIndex = mod(targetRef.current, n);
targetRef.current = target;
if (nextIndex !== prevIndex) {
setShownIndex(-1);
if (opts.commit) {
if (!ctl) setInternalIndex(nextIndex);
latest.current.onIndexChange?.(nextIndex);
}
if (opts.announce) {
const title = latest.current.items[nextIndex]?.title;
setAnnouncement(`${nextIndex + 1} / ${n}${title ? `:${title}` : ""}`);
}
}
posAnimRef.current?.stop();
if (rm) {
pos.set(target);
setShownIndex(nextIndex);
return;
}
// onComplete (unlike the finished promise) never fires for an
// animation that a newer gesture stopped, so a stale caption can't
// be revealed.
const onComplete = () => setShownIndex(nextIndex);
posAnimRef.current = animate(
pos,
target,
opts.velocity !== undefined
? {
type: "spring",
stiffness: 210,
damping: 30,
mass: 1,
velocity: opts.velocity,
onComplete,
}
: { type: "tween", ...TWEENS[tr], onComplete },
);
},
[pos],
);
/** Steps one slide. Returns false when blocked by an end (loop off). */
const step = useCallback(
(dir: 1 | -1, announce: boolean) => {
const { count: n, loop: lp } = latest.current;
const target = targetRef.current + dir;
if (!lp && (target < 0 || target > n - 1)) return false;
animateTo(target, { commit: true, announce });
return true;
},
[animateTo],
);
/** Nearest integer position that shows slide `i` (shortest way round). */
const nearestTarget = useCallback((i: number) => {
const { count: n, loop: lp } = latest.current;
const t = targetRef.current;
if (!lp) return i;
return t + slideOffset(i, t, n, true);
}, []);
// Controlled index (or a clamp after `items` shrank): follow it without
// echoing it back through onIndexChange.
useEffect(() => {
if (count === 0 || mod(targetRef.current, count) === current) return;
animateTo(nearestTarget(current), { commit: false, announce: false });
}, [current, count, animateTo, nearestTarget]);
// Loop toggled off while resting on a wrapped position: renormalize so
// the non-looping offsets line up with the visible slide.
useEffect(() => {
if (loop || count === 0) return;
const t = targetRef.current;
if (t >= 0 && t <= count - 1) return;
posAnimRef.current?.stop();
const normalized = mod(t, count);
targetRef.current = normalized;
pos.set(normalized);
}, [loop, count, pos]);
useEffect(() => () => posAnimRef.current?.stop(), []);
// ------------------------------------------------------------------
// Autoplay: a linear 0→1 progress value that pauses where it stands
// ------------------------------------------------------------------
const progress = useMotionValue(0);
const autoplayOn = autoplay && !reduced && count > 1;
const atEnd = !loop && current === count - 1;
const paused =
hovered || focused || !inView || docHidden || dragging || userPaused || atEnd;
// Declared before the runner so a slide change resets first.
useEffect(() => {
progress.set(0);
}, [current, progress]);
useEffect(() => {
if (!autoplayOn || paused) return;
const remaining = Math.max(0, 1 - progress.get()) * (interval / 1000);
const controls = animate(progress, 1, {
duration: remaining,
ease: "linear",
onComplete: () => {
step(1, false);
},
});
return () => controls.stop();
}, [autoplayOn, paused, interval, current, progress, step]);
// Offscreen and background-tab pause.
useEffect(() => {
const root = rootRef.current;
if (!root || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(
([entry]) => setInView(entry.isIntersecting),
{ threshold: 0.25 },
);
io.observe(root);
return () => io.disconnect();
}, []);
useEffect(() => {
const onVisibility = () => setDocHidden(document.hidden);
document.addEventListener("visibilitychange", onVisibility);
return () => document.removeEventListener("visibilitychange", onVisibility);
}, []);
// ------------------------------------------------------------------
// Pointer drag with velocity
// ------------------------------------------------------------------
const onPointerDown = useCallback(
(e: ReactPointerEvent<HTMLElement>) => {
if (count < 2 || e.button !== 0) return;
// The HUD buttons are plain clicks, never a drag.
if ((e.target as Element).closest("button")) return;
draggedRef.current = false;
dragRef.current = {
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
startPos: pos.get(),
// Rendered width, not layout width: inside a scaled preview the
// pointer deltas are in screen pixels.
width: e.currentTarget.getBoundingClientRect().width || 1,
axis: null,
lastX: e.clientX,
lastT: e.timeStamp,
velocity: 0,
};
},
[count, pos],
);
const onPointerMove = useCallback(
(e: ReactPointerEvent<HTMLElement>) => {
const d = dragRef.current;
if (!d || d.pointerId !== e.pointerId) return;
const dx = e.clientX - d.startX;
const dy = e.clientY - d.startY;
if (d.axis === null) {
if (Math.hypot(dx, dy) < DRAG_SLOP_PX) return;
d.axis = Math.abs(dx) > Math.abs(dy) ? "x" : "y";
if (d.axis === "y") {
// Vertical intent: let the page scroll (touch-action: pan-y).
dragRef.current = null;
return;
}
// Capture only now, so a tap on a CTA link still clicks it.
posAnimRef.current?.stop();
d.startPos = pos.get();
d.startX = e.clientX;
draggedRef.current = true;
e.currentTarget.setPointerCapture(e.pointerId);
setDragging(true);
}
const { count: n, loop: lp } = latest.current;
let next = d.startPos - (e.clientX - d.startX) / d.width;
if (!lp) {
if (next < 0) next *= EDGE_RESISTANCE;
else if (next > n - 1) next = n - 1 + (next - (n - 1)) * EDGE_RESISTANCE;
}
pos.set(next);
const dt = e.timeStamp - d.lastT;
if (dt > 0) {
const v = (e.clientX - d.lastX) / dt;
d.velocity = d.velocity * 0.2 + v * 0.8;
}
d.lastX = e.clientX;
d.lastT = e.timeStamp;
},
[pos],
);
const endDrag = useCallback(
(e: ReactPointerEvent<HTMLElement>) => {
const d = dragRef.current;
if (!d || d.pointerId !== e.pointerId) return;
dragRef.current = null;
if (d.axis !== "x") return;
setDragging(false);
// Touch drags produce no trailing click; clear the flag after this
// task so it can't swallow a later keyboard-activated click.
window.setTimeout(() => {
draggedRef.current = false;
}, 0);
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
/* capture may already be gone */
}
const { count: n, loop: lp } = latest.current;
// A pause before letting go means no flick.
const velocity = e.timeStamp - d.lastT > 90 ? 0 : d.velocity;
const base = targetRef.current;
const delta = pos.get() - base;
let dir = 0;
if (velocity < -FLICK_VELOCITY) dir = 1;
else if (velocity > FLICK_VELOCITY) dir = -1;
else if (Math.abs(delta) > DISTANCE_THRESHOLD) dir = Math.sign(delta);
let target = base + dir;
if (!lp) target = clamp(target, 0, n - 1);
animateTo(target, {
commit: true,
announce: false,
// px/ms → slides/s, in pos units (dragging right lowers pos).
velocity: (-velocity * 1000) / d.width,
});
},
[animateTo, pos],
);
// Swallow the click that ends a drag, so a swipe over a CTA doesn't
// follow the link.
const onClickCapture = useCallback((e: ReactMouseEvent) => {
if (!draggedRef.current) return;
draggedRef.current = false;
e.preventDefault();
e.stopPropagation();
}, []);
const onKeyDown = useCallback(
(e: ReactKeyboardEvent<HTMLElement>) => {
if (count < 2) return;
switch (e.key) {
case "ArrowRight":
e.preventDefault();
step(1, true);
break;
case "ArrowLeft":
e.preventDefault();
step(-1, true);
break;
case "Home":
e.preventDefault();
animateTo(nearestTarget(0), { commit: true, announce: true });
break;
case "End":
e.preventDefault();
animateTo(nearestTarget(count - 1), { commit: true, announce: true });
break;
default:
break;
}
},
[count, step, animateTo, nearestTarget],
);
// Pause for keyboard focus only — a mouse click on prev/next shouldn't
// freeze autoplay until the user clicks elsewhere.
const onFocus = useCallback((e: ReactFocusEvent<HTMLElement>) => {
if ((e.target as Element).matches(":focus-visible")) setFocused(true);
}, []);
const onBlur = useCallback((e: ReactFocusEvent<HTMLElement>) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false);
}, []);
const rootClassName = [styles.root, className].filter(Boolean).join(" ");
const kenBurnsSeconds = interval / 1000 + 2;
const canPrev = loop || current > 0;
const canNext = loop || current < count - 1;
// Without autoplay the line doubles as a position indicator.
const progressScale: MotionValue<number> | number = autoplayOn
? progress
: count > 0
? (current + 1) / count
: 0;
return (
<MotionConfig reducedMotion="user">
<section
ref={rootRef}
className={rootClassName}
aria-roledescription="カルーセル"
aria-label={ariaLabel}
tabIndex={0}
data-transition={transition}
data-dragging={dragging || undefined}
onKeyDown={onKeyDown}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onClickCapture={onClickCapture}
onPointerEnter={(e) => {
if (e.pointerType === "mouse") setHovered(true);
}}
onPointerLeave={(e) => {
if (e.pointerType === "mouse") setHovered(false);
}}
onFocus={onFocus}
onBlur={onBlur}
>
<div className={styles.viewport} id={viewportId}>
{items.map((item, i) => (
<Slide
key={`${item.id ?? i}-${transition}-${count}-${loop}`}
item={item}
index={i}
count={count}
loop={loop}
transition={transition}
pos={pos}
parallax={parallaxMV}
isActive={i === current}
captionShown={i === shownIndex}
kenBurnsSeconds={kenBurnsSeconds}
reduced={reduced}
renderItem={renderItem}
/>
))}
</div>
<div className={styles.grain} aria-hidden />
{count > 0 && (
<div className={styles.hud}>
<div className={styles.meta}>
{showCounter && (
<div className={styles.counter} aria-hidden>
<span className={styles.counterCurrent}>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={current}
className={styles.counterDigit}
initial={{ y: "100%", opacity: 0 }}
animate={{ y: "0%", opacity: 1 }}
exit={{ y: "-100%", opacity: 0 }}
transition={{ duration: 0.5, ease: [0.22, 1, 0.36, 1] }}
>
{pad2(current + 1)}
</motion.span>
</AnimatePresence>
</span>
<span className={styles.counterTotal}>/ {pad2(count)}</span>
</div>
)}
{showProgress && (
<div className={styles.progress} aria-hidden>
<motion.span
className={styles.progressFill}
data-static={!autoplayOn || undefined}
style={{ scaleX: progressScale }}
/>
</div>
)}
</div>
{count > 1 && (
<div className={styles.controls}>
{autoplayOn && (
<button
type="button"
className={styles.control}
onClick={() => setUserPaused((p) => !p)}
aria-label={userPaused ? "自動再生を再開" : "自動再生を停止"}
aria-pressed={userPaused}
>
{userPaused ? (
<Play size={16} strokeWidth={2.2} aria-hidden />
) : (
<Pause size={16} strokeWidth={2.2} aria-hidden />
)}
</button>
)}
<button
type="button"
className={styles.control}
onClick={() => step(-1, true)}
disabled={!canPrev}
aria-label="前のスライド"
aria-controls={viewportId}
>
<ArrowLeft size={18} strokeWidth={2} aria-hidden />
</button>
<button
type="button"
className={styles.control}
onClick={() => step(1, true)}
disabled={!canNext}
aria-label="次のスライド"
aria-controls={viewportId}
>
<ArrowRight size={18} strokeWidth={2} aria-hidden />
</button>
</div>
)}
</div>
)}
<div className={styles.srOnly} aria-live="polite" aria-atomic="true">
{announcement}
</div>
</section>
</MotionConfig>
);
}
export default ParallaxSlider;
```
#### `components/parallax-slider/ParallaxSlider.module.css`
```css
/* ==========================================================================
Parallax slider — a full-bleed hero slider. Slides fill the container;
the art trails its slide (parallax), crossfades with a Ken Burns push,
or is revealed by a curtain wipe. Captions stagger in once a slide has
settled; the HUD (counter, progress line, prev/next) sits along the
bottom edge.
framer-motion owns every transform / opacity / clip-path. This file
only sizes, layers and paints.
========================================================================== */
.root {
--ps-radius: 0px;
--ps-backdrop: #07070a;
--ps-fg: #fff;
--ps-muted: rgba(255, 255, 255, 0.62);
/* HUD inset — from the container, never the viewport. */
--ps-pad: clamp(16px, 4.5cqi, 44px);
position: relative;
/* Explicit width: with only `height` set by the consumer, `aspect-ratio`
would otherwise derive the width from it. */
width: 100%;
/* Default shape when the consumer sets no height; any explicit height
(class or style) wins because both dimensions are then definite. */
aspect-ratio: 16 / 9;
min-height: 280px;
overflow: hidden;
isolation: isolate;
border-radius: var(--ps-radius);
background: var(--ps-backdrop);
color: var(--ps-fg);
container-type: inline-size;
font-family:
"Inter", -apple-system, BlinkMacSystemFont, "Hiragino Sans",
"Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto,
sans-serif;
-webkit-font-smoothing: antialiased;
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;
outline: none;
}
.root[data-dragging] {
cursor: grabbing;
}
.root:focus-visible {
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.9);
}
.viewport {
position: absolute;
inset: 0;
}
/* --------------------------------------------------------------------------
Slide
-------------------------------------------------------------------------- */
.slide {
position: absolute;
inset: 0;
overflow: hidden;
contain: paint;
backface-visibility: hidden;
}
.art,
.artInner,
.artImage,
.artFallback {
position: absolute;
inset: 0;
}
.art {
will-change: transform;
}
/* `image` arrives as an inline `background` shorthand (so layered values
work), which resets size/position — hence the !important. */
.artImage {
background-size: cover !important;
background-position: center !important;
}
.artFallback {
background:
radial-gradient(60% 50% at 70% 30%, rgba(167, 139, 250, 0.35), transparent 70%),
linear-gradient(160deg, #1b1630, #07070a);
}
/* Custom art from renderItem fills the frame too. */
.artInner > :is(img, video, svg) {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Parallax only: darkens a slide as it leaves, so the motion reads as depth. */
.shade {
position: absolute;
inset: 0;
background: #000;
pointer-events: none;
}
/* Legibility for the caption and HUD over any art, bright or dark. */
.scrim {
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(90% 70% at 0% 100%, rgba(0, 0, 0, 0.62), transparent 70%),
linear-gradient(0deg, rgba(0, 0, 0, 0.55), transparent 46%),
linear-gradient(180deg, rgba(0, 0, 0, 0.22), transparent 22%);
}
/* Curtain only: a thin lit edge riding the wipe. */
.seam {
position: absolute;
inset: 0;
pointer-events: none;
}
.seam::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: -1px;
width: 2px;
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0),
rgba(255, 255, 255, 0.95) 30%,
rgba(255, 255, 255, 0.95) 70%,
rgba(255, 255, 255, 0)
);
box-shadow:
0 0 18px 2px rgba(255, 255, 255, 0.35),
0 0 60px 8px rgba(190, 170, 255, 0.25);
}
/* --------------------------------------------------------------------------
Caption
-------------------------------------------------------------------------- */
.caption {
position: absolute;
left: var(--ps-pad);
right: var(--ps-pad);
/* Clears the HUD row (counter + progress) under it. */
bottom: calc(var(--ps-pad) + 64px);
max-width: min(680px, 64cqi);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 14px;
text-shadow: 0 1px 24px rgba(0, 0, 0, 0.35);
}
.eyebrow {
margin: 0;
display: inline-flex;
align-items: center;
gap: 12px;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.24em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.78);
}
.eyebrow::before {
content: "";
width: 28px;
height: 1px;
background: currentColor;
opacity: 0.8;
}
/* The mask the title rises out of; padding keeps descenders unclipped. */
.title {
margin: 0;
overflow: hidden;
padding-bottom: 0.08em;
font-size: clamp(30px, 7.4cqi, 96px);
font-weight: 600;
line-height: 1.04;
letter-spacing: -0.025em;
text-wrap: balance;
}
.titleInner {
display: block;
}
.subtitle {
margin: 0;
max-width: 44ch;
font-size: clamp(14px, 1.9cqi, 17px);
line-height: 1.65;
color: rgba(255, 255, 255, 0.8);
text-wrap: pretty;
}
.cta {
margin-top: 6px;
display: inline-flex;
align-items: center;
gap: 8px;
height: 44px;
padding: 0 20px 0 22px;
border-radius: 999px;
background: #fff;
color: #0a0a0a;
font-size: 14px;
font-weight: 600;
text-decoration: none;
text-shadow: none;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.6) inset,
0 10px 30px -8px rgba(0, 0, 0, 0.5);
transition: background-color 0.2s ease;
cursor: pointer;
}
.cta svg {
transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1);
}
.cta:hover {
background: #ececf1;
}
.cta:hover svg {
transform: translate(2px, -2px);
}
.cta:focus-visible {
outline: 2px solid #fff;
outline-offset: 3px;
}
/* --------------------------------------------------------------------------
Grain — a static noise layer so flat gradients don't band
-------------------------------------------------------------------------- */
.grain {
position: absolute;
inset: 0;
z-index: 3;
pointer-events: none;
opacity: 0.09;
mix-blend-mode: overlay;
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%3CfeColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 .9 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* --------------------------------------------------------------------------
HUD
-------------------------------------------------------------------------- */
.hud {
position: absolute;
left: var(--ps-pad);
right: var(--ps-pad);
bottom: var(--ps-pad);
z-index: 4;
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
pointer-events: none;
}
.meta {
display: flex;
flex-direction: column;
gap: 12px;
min-width: 0;
}
.counter {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.08em;
font-variant-numeric: tabular-nums;
}
/* Clips the digit roll. */
.counterCurrent {
position: relative;
display: inline-flex;
overflow: hidden;
min-width: 2ch;
color: var(--ps-fg);
font-weight: 600;
}
.counterDigit {
display: inline-block;
}
.counterTotal {
color: var(--ps-muted);
}
.progress {
position: relative;
width: clamp(96px, 22cqi, 180px);
height: 2px;
overflow: hidden;
border-radius: 2px;
background: rgba(255, 255, 255, 0.2);
}
.progressFill {
position: absolute;
inset: 0;
display: block;
background: #fff;
transform-origin: 0 50%;
box-shadow: 0 0 10px rgba(255, 255, 255, 0.55);
}
/* Position-indicator mode (no autoplay): glide between steps. */
.progressFill[data-static] {
transition: transform 0.7s cubic-bezier(0.22, 1, 0.36, 1);
}
.controls {
display: flex;
gap: 10px;
pointer-events: auto;
}
.control {
display: grid;
place-items: center;
width: 46px;
height: 46px;
padding: 0;
border: 0;
border-radius: 999px;
color: #fff;
background: rgba(255, 255, 255, 0.08);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.22),
inset 0 1px 0 rgba(255, 255, 255, 0.28);
/* Unprefixed only: the build adds -webkit- itself, and writing both
makes Lightning CSS drop the unprefixed one. */
backdrop-filter: blur(14px) saturate(160%);
cursor: pointer;
transition:
background-color 0.2s ease,
transform 0.15s ease,
opacity 0.2s ease;
}
.control:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.18);
}
.control:active:not(:disabled) {
transform: scale(0.94);
}
.control:disabled {
opacity: 0.35;
cursor: default;
}
.control:focus-visible {
outline: 2px solid #fff;
outline-offset: 3px;
}
@supports not (backdrop-filter: blur(1px)) {
.control {
background: rgba(20, 20, 26, 0.72);
}
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* --------------------------------------------------------------------------
Narrow containers
-------------------------------------------------------------------------- */
@container (max-width: 560px) {
.caption {
max-width: none;
gap: 10px;
bottom: calc(var(--ps-pad) + 58px);
}
.eyebrow {
font-size: 11px;
letter-spacing: 0.2em;
}
.subtitle {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.cta {
height: 40px;
padding: 0 16px 0 18px;
font-size: 13px;
}
.control {
width: 40px;
height: 40px;
}
}
@media (prefers-reduced-motion: reduce) {
.progressFill[data-static],
.cta svg,
.control {
transition: none;
}
}
```
代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/parallax-slider.json
```
### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { ParallaxSlider } from "@/components/parallax-slider";
// TODO: items はプレビューと同じ仮のスライドです。実データ(eyebrow / title / subtitle / image / alt / cta)に差し替えてください。image は画像 URL か CSS グラデーション
// TODO: プレビューの山・海・夜景の SVG アートは renderItem={(item, { isActive }) => …} で描いています。生成コードでは image の空グラデーションだけが表示されます。写真や動画を使うなら image に URL を渡すか、renderItem で <img> / <video> を返してください
// TODO: 高さは親で決めます(例: className="h-[80svh]")。未指定なら 16:9 になります
// TODO: 現在のスライドを受け取るには onIndexChange={(i) => …}、外から動かすなら index と組み合わせます
<ParallaxSlider
transition="parallax"
parallax={0.6}
autoplay={true}
interval={6000}
loop={true}
showProgress={true}
showCounter={true}
items={[
{
id: "alpine-dawn",
eyebrow: "01 — Alpine Dawn",
title: "夜明けの稜線へ",
subtitle: "標高 2,900m、雲海の上で迎える最初の光。静けさごと持ち帰る、山岳フォトツアー。",
image: "radial-gradient(40% 30% at 62% 64%, rgba(255,196,140,.9), transparent 70%), linear-gradient(180deg, #1c1b4a 0%, #6b3a73 42%, #f08a6b 72%, #ffd6a0 100%)",
cta: {"label":"ツアーを見る","href":"#alpine-dawn"},
},
{
id: "midnight-sea",
eyebrow: "02 — Midnight Sea",
title: "月が描く、海の道",
subtitle: "満月の夜だけ現れる光の帯。波音だけが聞こえるオーシャンフロントの一室で。",
image: "radial-gradient(18% 22% at 72% 26%, rgba(230,236,255,.55), transparent 70%), linear-gradient(180deg, #030616 0%, #0b1a3d 52%, #0f2c52 58%, #040a1c 100%)",
cta: {"label":"客室を予約","href":"#midnight-sea"},
},
{
id: "neon-city",
eyebrow: "03 — Neon Nights",
title: "Tokyo After Dark",
subtitle: "眠らない街の灯りを、最上階から。ルーフトップで過ごす特別な一夜。",
image: "radial-gradient(60% 40% at 50% 100%, rgba(255,80,180,.45), transparent 70%), linear-gradient(180deg, #07051a 0%, #1d0f3d 55%, #3b1452 100%)",
cta: {"label":"詳しく見る","href":"#neon-city"},
},
{
id: "desert-dusk",
eyebrow: "04 — Desert Dusk",
title: "砂の海に、沈む陽",
subtitle: "風がつくる曲線と、刻々と変わる空の色。ラクダで向かう砂丘のサンセット。",
image: "radial-gradient(30% 26% at 36% 58%, rgba(255,214,150,.95), transparent 70%), linear-gradient(180deg, #2a1846 0%, #a8466a 40%, #f2945a 66%, #c8643c 100%)",
cta: {"label":"プランを見る","href":"#desert-dusk"},
},
{
id: "aurora-fjord",
eyebrow: "05 — Aurora Fjord",
title: "空がほどける夜",
subtitle: "北緯 69 度、フィヨルドの湖面に映るオーロラ。ガラスイグルーで朝まで。",
image: "radial-gradient(70% 40% at 40% 30%, rgba(80,255,190,.35), transparent 70%), linear-gradient(180deg, #020a14 0%, #06233a 50%, #0b3a4a 70%, #021018 100%)",
cta: {"label":"旅を計画する","href":"#aurora-fjord"},
},
]}
aria-label="旅のハイライト"
/>
```
### 4. 組み込み手順
1. ページ(Server Component のままで可)に `<ParallaxSlider items={slides} aria-label="…" />` を置く。コンポーネント自体が `"use client"` なので、items がシリアライズ可能(関数を含まない)ならサーバーから渡せる。`renderItem` / `onIndexChange` を使う場合は呼び出し側も Client Component にする。
2. **高さは親(または className)で決める**。例: `className="h-[80svh] min-h-[520px]"`。未指定なら幅 100% の 16:9。ビューポート単位で中身を組んでいないので、カードやモーダルの中に置いても崩れない。
3. `items` は `{ id?, eyebrow?, title?, subtitle?, image?, alt?, cta?: { label, href } }[]`。`image` は画像 URL か CSS の background 値(`linear-gradient(…)`、`url(…)`、重ね指定も可)。写真に意味があるなら `alt` を書く(装飾なら省略)。
4. 写真の代わりに `<img>` / `<video>` / SVG を出したい場合は `renderItem={(item, { index, total, isActive }) => …}`。戻り値は背景アートの層に入り、パララックス・Ken Burns・カーテンの演出とキャプションはそのまま効く。動画は `muted loop playsInline` を付け、`isActive` のときだけ再生すると軽い。
5. 外から操作する場合は `index` + `onIndexChange`(制御)、初期位置だけなら `defaultIndex`。
6. ページの最初に置くヒーローなら、最初のスライドの画像を `<link rel="preload" as="image">` などで先読みすると LCP が安定する。
7. `npm run build` が通ることを確認し、実機で「縦スクロールが邪魔されない」「素早いスワイプで 1 枚進む」「CTA をタップするとリンクに飛ぶ」を確認する。
#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| スマホで縦スクロールできない | ルートの `touch-action` を `none` にした | **`touch-action: pan-y` のまま**。横ドラッグだけをコンポーネントが拾う |
| CTA をタップしても飛ばない | 押した瞬間に `setPointerCapture` している | キャプチャは 6px 動いて横ドラッグと判定してから。ドラッグ後のクリックだけを `onClickCapture` で握りつぶす |
| 指定した高さが無視される / 幅が縮む | ルートの `width: 100%` を消した | `aspect-ratio` は高さだけ指定されると幅を逆算するので、`width: 100%` は残す |
| ドラッグ中にカクつく | 位置を React state で持った | 位置は `useMotionValue` 1 本。各スライドの transform / opacity / clip-path は `useTransform` で読み出すだけ |
| 戻ってきたキャプションが一瞬だけ出る | 切り替え完了をアニメーションの Promise で待った | 停止されたアニメーションでも解決するため、`onComplete` で表示する |
| 前へ / 次へのぼかしが Chrome で効かない | `backdrop-filter` と `-webkit-backdrop-filter` を併記した | **unprefixed だけ書く**(ビルドで自動付与) |
| 視差効果を減らす設定でも動く | framer の `animate()` に MotionConfig は効かない | 同梱の `usePrefersReducedMotion`(`useSyncExternalStore` + `matchMedia`)で位置を即時セットし、自動再生と Ken Burns を止める実装を外さない |
| 視差効果を減らす設定の端末でハイドレーションエラー | framer の `useReducedMotion()` は初回クライアント描画で実際の値を返し、サーバーの HTML(一時停止ボタン等)と食い違う | サーバー値 `false` で水和してから追従する `useSyncExternalStore` 版を使う |
### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる
## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- React 19、TypeScript、CSS Modules、`framer-motion`(v12 以降)、`lucide-react`
- ファイル: `components/parallax-slider/ParallaxSlider.tsx`(`"use client"`)+ `ParallaxSlider.module.css` + `index.ts`
- Props: `items`(必須)、`transition`(`"parallax" | "fade-zoom" | "curtain"`、既定 parallax)、`parallax`(0–1、既定 0.6)、`autoplay`(既定 true)、`interval`(ms、既定 6000)、`loop`(既定 true)、`showProgress` / `showCounter`(既定 true)、`index` / `defaultIndex` / `onIndexChange`、`renderItem`、`className`、`aria-label`
### 見た目
- ルート: `width: 100%; aspect-ratio: 16/9; min-height: 280px; overflow: hidden; container-type: inline-size`、背景 `#07070a`。スライドは全面(`position: absolute; inset: 0`)
- 背景アート: `image` を `background` に(`cover` / `center`)。下に向かって暗くなるスクリム(左下の放射 + 下からの線形 黒 .55 → 透明 46%)で文字とボタンを読みやすくする
- グレイン: SVG `feTurbulence` のノイズを `mix-blend-mode: overlay`、不透明度 .09 で全体に重ね、グラデーションのバンディングを消す
- キャプション(左下、HUD の上): 最大幅 `min(680px, 64cqi)`、縦並び gap 14px
- eyebrow: 12px / 500 / `letter-spacing: .24em` / 大文字、先頭に 28×1px の線
- タイトル: `clamp(30px, 7.4cqi, 96px)` / 600 / 行間 1.04 / `letter-spacing: -.025em` / `text-wrap: balance`。`h2` が `overflow: hidden` のマスクになる
- 本文: `clamp(14px, 1.9cqi, 17px)` / 行間 1.65 / 白 .8 / 最大 44ch
- CTA: 高さ 44px の白いピル、黒文字 14px / 600、右に lucide `ArrowUpRight`(ホバーで右上に 2px)
- HUD(下端、余白 `clamp(16px, 4.5cqi, 44px)`)
- 左: `02 / 05`(13px、tabular-nums、現在値は白 600・総数は白 .62)。数字は切り替え時に下から上へロール。その下に幅 `clamp(96px, 22cqi, 180px)`・高さ 2px のプログレスライン(トラック白 .2、塗り白 + 淡いグロー、`transform-origin: left` の `scaleX`)
- 右: 46px の円ボタン(前へ / 次へ、自動再生中は一時停止 / 再開も)。白 .08 + `inset` の白 1px 縁 + `backdrop-filter: blur(14px) saturate(160%)`。無効時は不透明度 .35
- コンテナ幅 560px 以下: 本文 2 行で省略、ボタン 40px、CTA 40px
### モーション
- 連続値 `pos`(スライド番号の実数、ループ時は無限)を motion value 1 本で持つ。スライド i のオフセット `o = i − pos`(ループ時は最短側に折り返す)。|o| ≥ 1 のスライドは `visibility: hidden`
- **parallax**: スライドは `x = o × 100%`、背景アートは逆向きに `x = −o × parallax × 100%`(=スライドの (1 − parallax) 倍の速さで動く。フレームが切り抜くので拡大不要)。離れていくスライドに黒の陰影 `|o| × .55`
- **fade-zoom**: 右側(o > 0)のスライドが上に重なり `opacity = 1 − o` で現れる(中間で暗くならない)。アクティブな背景は `scale 1 → 1.1` を `interval + 2s` かけて linear で(Ken Burns)。外れた背景はフェードし終わってから 1 に戻す
- **curtain**: 右のスライドは `clip-path: inset(0 0 0 o×100%)`、左は `inset(0 −o×100% 0 0)` で 2 枚が画面を分け合い、境界が横切る。現れる背景は `scale 1.14 → 1`。境界に 2px の白く光る線
- ボタン / キー / 自動再生: tween(parallax 0.95s `[.65,.05,.25,1]`、fade-zoom 1.1s `[.4,0,.2,1]`、curtain 1.05s `[.76,0,.24,1]`)
- ドラッグ: 6px 動いた時点で縦横を判定し、横ならポインターをキャプチャして `pos = 開始位置 − dx / 幅`。ループなしの両端はゴムのように .32 倍。離した速度が .35px/ms を超えれば向きに 1 枚、そうでなければ移動量 22% 超で 1 枚、それ以外は戻る。スプリング `stiffness 210 / damping 30` に離した速度を引き継ぐ
- キャプション: 切り替え開始で逆順に素早く隠し(.22s)、スライドが止まってから eyebrow → タイトル → 本文 → CTA を 0.09s 間隔で表示(不透明度 + `y: 18px → 0`、タイトルはマスク内で `y: 108% → 0`、`[.22,1,.36,1]`)
- 自動再生: プログレスを 0 → 1 に linear で進め、満ちたら次へ。ホバー・キーボードフォーカス・画面の 25% 未満しか見えない・タブ非表示・ドラッグ中・一時停止ボタン・(ループなしで)最後のスライドでは、その位置で止まって再開時に続きから進む。自動再生オフではラインが `(現在 + 1) / 総数` の位置インジケーターになる
- `<MotionConfig reducedMotion="user">` で包み、さらに `matchMedia("(prefers-reduced-motion: reduce)")`(`useSyncExternalStore`、サーバー値 false)が true なら位置は即時に切り替え、自動再生と Ken Burns をオフ
### アクセシビリティ
- ルートは `<section aria-roledescription="カルーセル" aria-label>`、`tabIndex={0}`。← / → で前後、Home / End で先頭 / 末尾
- 各スライドは `role="group" aria-roledescription="スライド" aria-label="n / 総数"`。現在以外は `aria-hidden` + `inert`(中の CTA にタブで入らない)
- ボタン・キー操作での切り替えは `aria-live="polite"` の非表示領域で「n / 総数:タイトル」を読み上げる(自動再生では読み上げない)
- 前へ / 次へ / 一時停止は `aria-label` 付きの `<button>`(一時停止は `aria-pressed`)。カウンターとプログレスは装飾として `aria-hidden`
- `:focus-visible` で白 2px のリング。マウスのクリックでは自動再生を止めない(キーボードフォーカスのときだけ止める)
### 受け入れ条件
- 横にドラッグすると背景がスライドより遅れて動き、素早く振ると距離が短くても 1 枚進む。縦スワイプではページがスクロールする
- 止まった後にキャプションが順番に立ち上がる。CTA はタップでリンクに飛び、ドラッグの終わりでは飛ばない
- 3 種類の切り替えがいずれも途中で背景色が透けたり、隙間ができたりしない
- 自動再生のラインはホバー・フォーカス・画面外で止まり、戻ると続きから満ちる
- 高さを親で指定しても、指定しなくても(16:9)崩れないインストール
npx shadcn@latest add https://anima-js.vercel.app/r/parallax-slider.json生成コード
import { ParallaxSlider } from "@/components/parallax-slider";
// TODO: items はプレビューと同じ仮のスライドです。実データ(eyebrow / title / subtitle / image / alt / cta)に差し替えてください。image は画像 URL か CSS グラデーション
// TODO: プレビューの山・海・夜景の SVG アートは renderItem={(item, { isActive }) => …} で描いています。生成コードでは image の空グラデーションだけが表示されます。写真や動画を使うなら image に URL を渡すか、renderItem で <img> / <video> を返してください
// TODO: 高さは親で決めます(例: className="h-[80svh]")。未指定なら 16:9 になります
// TODO: 現在のスライドを受け取るには onIndexChange={(i) => …}、外から動かすなら index と組み合わせます
<ParallaxSlider
transition="parallax"
parallax={0.6}
autoplay={true}
interval={6000}
loop={true}
showProgress={true}
showCounter={true}
items={[
{
id: "alpine-dawn",
eyebrow: "01 — Alpine Dawn",
title: "夜明けの稜線へ",
subtitle: "標高 2,900m、雲海の上で迎える最初の光。静けさごと持ち帰る、山岳フォトツアー。",
image: "radial-gradient(40% 30% at 62% 64%, rgba(255,196,140,.9), transparent 70%), linear-gradient(180deg, #1c1b4a 0%, #6b3a73 42%, #f08a6b 72%, #ffd6a0 100%)",
cta: {"label":"ツアーを見る","href":"#alpine-dawn"},
},
{
id: "midnight-sea",
eyebrow: "02 — Midnight Sea",
title: "月が描く、海の道",
subtitle: "満月の夜だけ現れる光の帯。波音だけが聞こえるオーシャンフロントの一室で。",
image: "radial-gradient(18% 22% at 72% 26%, rgba(230,236,255,.55), transparent 70%), linear-gradient(180deg, #030616 0%, #0b1a3d 52%, #0f2c52 58%, #040a1c 100%)",
cta: {"label":"客室を予約","href":"#midnight-sea"},
},
{
id: "neon-city",
eyebrow: "03 — Neon Nights",
title: "Tokyo After Dark",
subtitle: "眠らない街の灯りを、最上階から。ルーフトップで過ごす特別な一夜。",
image: "radial-gradient(60% 40% at 50% 100%, rgba(255,80,180,.45), transparent 70%), linear-gradient(180deg, #07051a 0%, #1d0f3d 55%, #3b1452 100%)",
cta: {"label":"詳しく見る","href":"#neon-city"},
},
{
id: "desert-dusk",
eyebrow: "04 — Desert Dusk",
title: "砂の海に、沈む陽",
subtitle: "風がつくる曲線と、刻々と変わる空の色。ラクダで向かう砂丘のサンセット。",
image: "radial-gradient(30% 26% at 36% 58%, rgba(255,214,150,.95), transparent 70%), linear-gradient(180deg, #2a1846 0%, #a8466a 40%, #f2945a 66%, #c8643c 100%)",
cta: {"label":"プランを見る","href":"#desert-dusk"},
},
{
id: "aurora-fjord",
eyebrow: "05 — Aurora Fjord",
title: "空がほどける夜",
subtitle: "北緯 69 度、フィヨルドの湖面に映るオーロラ。ガラスイグルーで朝まで。",
image: "radial-gradient(70% 40% at 40% 30%, rgba(80,255,190,.35), transparent 70%), linear-gradient(180deg, #020a14 0%, #06233a 50%, #0b3a4a 70%, #021018 100%)",
cta: {"label":"旅を計画する","href":"#aurora-fjord"},
},
]}
aria-label="旅のハイライト"
/>