anima.js
ギャラリー
カルーセル

CoverflowCarousel

CSS 3D

Apple の Cover Flow を現代風に — 正面のスライドの左右で Y 回転したスライドが奥へ重なり、床に映り込む。スプリング遷移、ドラッグでスナップ、横スクロール、クリックで手前へ。純粋な CSS 3D。

静寂のプール

Kumo

AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約48 KB)。
# CoverflowCarousel をこのプロジェクトに追加してください

このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。

## コンポーネントについて
- 名前: CoverflowCarousel
- 説明: Apple の Cover Flow を現代風に — 正面のスライドの左右で Y 回転したスライドが奥へ重なり、床に映り込む。スプリング遷移、ドラッグでスナップ、横スクロール、クリックで手前へ。純粋な CSS 3D。
- 構成ファイル: `components/coverflow-carousel/index.tsx`, `components/coverflow-carousel/CoverflowCarousel.module.css`

## 前提と注意
- React 19 / TypeScript / Next.js(App Router)のプロジェクトを想定しています。Next.js 以外の React プロジェクト(Vite など)でもそのまま動きます。
- スタイルは同梱の CSS Modules(ある場合)で自己完結しており、Tailwind CSS の有無や設定には依存しません。Tailwind の設定変更やグローバル CSS の追加は、この依頼文に明記された分だけにしてください。
- ブラウザ API やインタラクションを使うクライアントコンポーネントです。ファイル先頭の "use client" を維持してください。
- コンポーネント自体の背景は透過です。配置先ページの背景がそのまま透けて見える前提で作られているので、白背景や単色の `div` などで囲わず、コンポーネントだけをそのまま配置してください。
- ソースコードの内容は変更しないでください。例外は、`@/` パスエイリアスが無いプロジェクトで import パスを相対パスに直す場合だけです。

## 手順
### 1. 依存パッケージを追加する
追加の npm パッケージは不要です(React のみ)。

### 2. ファイルを作成する
`@/components` が指すディレクトリ(多くは `src/components/` か `components/`。shadcn の `components.json` があれば `aliases.components` の指す場所)に、以下の 2 ファイルを**一字一句そのまま**作成してください。

#### `components/coverflow-carousel/index.tsx`
```tsx
"use client";

import {
  useCallback,
  useEffect,
  useLayoutEffect,
  useRef,
  useState,
  useSyncExternalStore,
  type CSSProperties,
  type FocusEvent as ReactFocusEvent,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
  type ReactNode,
} from "react";
import styles from "./CoverflowCarousel.module.css";

/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;

export type CoverflowItem = {
  id?: string;
  title?: string;
  subtitle?: string;
  /** An image URL, or any CSS background value (gradients allowed). */
  image?: string;
  alt?: string;
};

export type CoverflowSlideState = {
  index: number;
  /** True for the slide facing the viewer. */
  active: boolean;
  total: number;
};

export type CoverflowCarouselProps = {
  items: CoverflowItem[];
  /** Controlled active index. Pair with `onIndexChange`. */
  index?: number;
  /** Uncontrolled initial index. */
  defaultIndex?: number;
  onIndexChange?: (index: number) => void;
  /** Y rotation of the side slides in deg. */
  rotate?: number;
  /** How far the side slides recede in px. */
  depth?: number;
  /** Gap between stacked side slides, as a fraction of the slide width. */
  spacing?: number;
  /** Slide width in px. Capped to ~38% of the container width. */
  slideWidth?: number;
  /** Slide height = width × aspect. */
  aspect?: number;
  /** Glossy floor reflection under each slide. */
  reflection?: boolean;
  /** Advance on a timer. Pauses on hover / focus / offscreen; off under reduced motion. */
  autoplay?: boolean;
  /** Autoplay interval in ms. */
  interval?: number;
  /** Wrap around past the last slide. */
  loop?: boolean;
  /** Dots + prev/next bar below the slides. */
  showDots?: boolean;
  className?: string;
  "aria-label"?: string;
  /** Custom slide content. The default renders gradient art + title. */
  renderItem?: (item: CoverflowItem, state: CoverflowSlideState) => ReactNode;
};

const DEFAULT_ROTATE = 55;
const DEFAULT_DEPTH = 220;
const DEFAULT_SPACING = 0.3;
const DEFAULT_SLIDE_WIDTH = 260;
const DEFAULT_INTERVAL = 3500;

/**
 * Extra offset (in slide widths) of the first neighbour on top of
 * `spacing`. A 55deg-rotated neighbour still projects ~0.6 of its width,
 * so it needs this much room to tuck behind the front slide instead of
 * cutting through it.
 */
const NEIGHBOUR_GAP = 0.55;
/** Each slide past the first neighbour recedes this fraction of `depth` more. */
const FAR_DEPTH = 0.15;
/** Slides visible on each side before they fade out. */
const MAX_RANGE = 3;
/** Width (in slides) of the fade at the edge of the visible range. */
const FADE = 0.75;
/** Spring: mass 1, ζ ≈ 0.9 — settles with a barely-there overshoot. */
const SPRING_STIFFNESS = 210;
const SPRING_DAMPING = 26;
/** Clamp per-frame dt so a backgrounded tab doesn't explode the spring. */
const MAX_DT = 1 / 30;
const SETTLE_EPSILON = 0.0005;
/** Pointer travel (px) before a press becomes a drag rather than a click. */
const DRAG_THRESHOLD = 6;
/** Release velocity (slides/s) is projected this many seconds ahead. */
const FLICK_PROJECTION = 0.16;
/** Resistance past the ends when not looping. */
const RUBBER_BAND = 0.3;
/** Wheel idle time (ms) before snapping to the nearest slide. */
const WHEEL_SNAP_DELAY = 140;

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const mod = (v: number, n: number) => ((v % n) + n) % n;
/** Trims float noise so SSR and client style strings match. */
const round = (v: number) => Math.round(v * 1000) / 1000;

/** Signed distance of slide `i` from the scroll position, wrapped when looping. */
function offsetOf(i: number, pos: number, n: number, loop: boolean): number {
  const d = i - pos;
  return loop && n > 1 ? mod(d + n / 2, n) - n / 2 : d;
}

/**
 * When looping, slides wrap from one side to the other at ±n/2, so the
 * visible range must end (fully faded) before that point.
 */
function visibleRange(n: number, loop: boolean): number {
  return loop ? clamp((n - 1) / 2 - 0.5, 0.5, MAX_RANGE) : MAX_RANGE;
}

type Geometry = { rotate: number; depth: number; spacing: number; range: number };

type SlideFrame = {
  transform: string;
  opacity: number;
  zIndex: number;
  shade: number;
};

/**
 * The Cover Flow curve: the front slide is flat; within one step a slide
 * swings to `rotate`, jumps out to the neighbour gap and sinks to `depth`;
 * beyond that slides only stack sideways and recede a little more. The X
 * offset is unitless and multiplied by --cf-w in CSS, so the layout scales
 * with the container without JS measuring it.
 */
function slideFrame(d: number, g: Geometry): SlideFrame {
  const a = Math.abs(d);
  const s = Math.sign(d);
  const near = Math.min(a, 1);
  const far = Math.max(a - 1, 0);
  const x = s * (near * (NEIGHBOUR_GAP + g.spacing) + far * g.spacing);
  const z = -(near * g.depth + far * g.depth * FAR_DEPTH);
  // Right-hand slides turn their face toward the centre (outer edge forward).
  const ry = -s * near * g.rotate;
  return {
    transform: `translate3d(calc(${round(x)} * var(--cf-w)), 0, ${round(z)}px) rotateY(${round(ry)}deg)`,
    opacity: round(clamp((g.range + FADE - a) / FADE, 0, 1)),
    zIndex: 100 - Math.round(a * 10),
    shade: round(Math.min(near * 0.42 + far * 0.1, 0.75)),
  };
}

type EngineParams = Geometry & { n: number; loop: boolean; reduced: boolean };

/**
 * Animation state lives outside React: the position is a float of
 * "slides", a spring pulls it to an integer target, and every frame
 * writes transforms straight onto the slide elements. React only
 * re-renders when the committed index changes. The loop stops itself
 * once settled, so an idle carousel costs nothing.
 */
function createEngine() {
  let pos = 0;
  let target = 0;
  let vel = 0;
  let raf = 0;
  let last = 0;
  /** Drag / wheel in progress: the pointer owns `pos`, the spring is off. */
  let held = false;
  let params: EngineParams = {
    rotate: DEFAULT_ROTATE,
    depth: DEFAULT_DEPTH,
    spacing: DEFAULT_SPACING,
    range: MAX_RANGE,
    n: 0,
    loop: false,
    reduced: false,
  };
  const slides: (HTMLElement | null)[] = [];

  const apply = () => {
    for (let i = 0; i < params.n; i += 1) {
      const el = slides[i];
      if (!el) continue;
      const f = slideFrame(offsetOf(i, pos, params.n, params.loop), params);
      el.style.transform = f.transform;
      el.style.opacity = String(f.opacity);
      el.style.zIndex = String(f.zIndex);
      el.style.visibility = f.opacity === 0 ? "hidden" : "";
      el.style.setProperty("--cf-shade", String(f.shade));
    }
  };

  const tick = (t: number) => {
    const dt = Math.min((t - last) / 1000, MAX_DT);
    last = t;
    if (!held) {
      if (params.reduced) {
        pos = target;
        vel = 0;
      } else {
        // Two semi-implicit Euler substeps keep the stiff spring stable at 30fps.
        const h = dt / 2;
        for (let k = 0; k < 2; k += 1) {
          vel += (SPRING_STIFFNESS * (target - pos) - SPRING_DAMPING * vel) * h;
          pos += vel * h;
        }
        if (Math.abs(target - pos) < SETTLE_EPSILON && Math.abs(vel) < SETTLE_EPSILON * 10) {
          pos = target;
          vel = 0;
        }
      }
    }
    apply();
    raf = !held && (pos !== target || vel !== 0) ? requestAnimationFrame(tick) : 0;
  };

  const kick = () => {
    if (raf) return;
    last = performance.now();
    raf = requestAnimationFrame(tick);
  };

  return {
    slides,
    apply,
    kick,
    get pos() {
      return pos;
    },
    get target() {
      return target;
    },
    setParams(next: EngineParams) {
      params = next;
    },
    /** Spring toward `t`, optionally carrying a release velocity (slides/s). */
    setTarget(t: number, velocity?: number) {
      target = t;
      if (velocity !== undefined) vel = velocity;
      held = false;
      kick();
    },
    /** Pointer-driven position; the spring stays off until `setTarget`. */
    hold(p: number) {
      held = true;
      pos = p;
      vel = 0;
      kick();
    },
    /** Jump without animating (knob changes, loop toggles). */
    reset(p: number) {
      pos = p;
      target = p;
      vel = 0;
      apply();
    },
    destroy() {
      cancelAnimationFrame(raf);
      raf = 0;
    },
  };
}

type Engine = ReturnType<typeof createEngine>;

const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
function subscribeReduced(onChange: () => void) {
  const mq = window.matchMedia(REDUCED_QUERY);
  mq.addEventListener("change", onChange);
  return () => mq.removeEventListener("change", onChange);
}
function usePrefersReducedMotion() {
  return useSyncExternalStore(
    subscribeReduced,
    () => window.matchMedia(REDUCED_QUERY).matches,
    () => false,
  );
}

/** Gradients and colors are used as-is; anything else is treated as a URL. */
const CSS_BACKGROUND = /gradient\(|^url\(|^#|^rgba?\(|^hsla?\(|^oklch\(|^var\(/i;

function DefaultSlide({ item }: { item: CoverflowItem }) {
  const image = item.image?.trim();
  const isUrl = !!image && !CSS_BACKGROUND.test(image);
  return (
    <>
      {isUrl ? (
        // Plain <img>: registry components stay framework-agnostic,
        // so next/image isn't available here.
        // eslint-disable-next-line @next/next/no-img-element
        <img
          className={styles.art}
          src={image}
          alt={item.alt ?? item.title ?? ""}
          loading="lazy"
          draggable={false}
        />
      ) : (
        <div
          className={styles.art}
          style={image ? { background: image } : undefined}
          role={item.alt ? "img" : undefined}
          aria-label={item.alt}
        />
      )}
      <div className={styles.gloss} aria-hidden />
      {item.title || item.subtitle ? (
        <div className={styles.caption}>
          {item.title ? <p className={styles.title}>{item.title}</p> : null}
          {item.subtitle ? <p className={styles.subtitle}>{item.subtitle}</p> : null}
        </div>
      ) : null}
    </>
  );
}

function Chevron({ dir }: { dir: "left" | "right" }) {
  return (
    <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden focusable="false">
      <path
        d={dir === "left" ? "M15 5l-7 7 7 7" : "M9 5l7 7-7 7"}
        fill="none"
        stroke="currentColor"
        strokeWidth="2"
        strokeLinecap="round"
        strokeLinejoin="round"
      />
    </svg>
  );
}

/**
 * Apple Cover Flow, modernised: the active slide faces the viewer while
 * its neighbours turn on Y and recede in Z, overlapping like a fanned
 * record crate over a glossy floor. Spring-driven between indices, with
 * drag-to-snap, horizontal wheel / trackpad scrolling, click-to-front,
 * keyboard and optional autoplay. Pure CSS 3D — no animation library.
 */
export function CoverflowCarousel({
  items,
  index,
  defaultIndex = 0,
  onIndexChange,
  rotate = DEFAULT_ROTATE,
  depth = DEFAULT_DEPTH,
  spacing = DEFAULT_SPACING,
  slideWidth,
  aspect,
  reflection = true,
  autoplay = false,
  interval = DEFAULT_INTERVAL,
  loop = false,
  showDots = true,
  className,
  "aria-label": ariaLabel = "カバーフロー",
  renderItem,
}: CoverflowCarouselProps) {
  const n = items.length;
  const isControlled = index !== undefined;
  const [innerIndex, setInnerIndex] = useState(defaultIndex);
  const current = n ? clamp(Math.round(isControlled ? index : innerIndex), 0, n - 1) : 0;
  // First-paint position, so the server already renders the fanned-out
  // layout. After mount the engine owns the transforms; these render-time
  // styles only change when geometry props change, and the layout effect
  // below immediately re-applies the live position over them.
  const [mountIndex] = useState(current);

  const reduced = usePrefersReducedMotion();
  const [announcement, setAnnouncement] = useState("");
  const [hovered, setHovered] = useState(false);
  const [focused, setFocused] = useState(false);
  const [offscreen, setOffscreen] = useState(false);
  const [dragging, setDragging] = useState(false);

  const rootRef = useRef<HTMLElement | null>(null);
  const stageRef = useRef<HTMLDivElement | null>(null);
  const engineRef = useRef<Engine | null>(null);
  if (engineRef.current === null) engineRef.current = createEngine();

  // Latest values for the event handlers, which are bound once.
  const stateRef = useRef({ current, n, loop, items, isControlled, onIndexChange, spacing });
  useLayoutEffect(() => {
    stateRef.current = { current, n, loop, items, isControlled, onIndexChange, spacing };
  });

  const range = visibleRange(n, loop);

  // Geometry → engine, then repaint at the live position.
  useLayoutEffect(() => {
    const engine = engineRef.current!;
    engine.setParams({ rotate, depth, spacing, range, n, loop, reduced });
    engine.apply();
  }, [rotate, depth, spacing, range, n, loop, reduced]);

  // A different slide count or loop mode invalidates virtual positions
  // (looping lets the target run past n), so snap back onto the index.
  useLayoutEffect(() => {
    engineRef.current!.reset(stateRef.current.current);
  }, [n, loop]);

  // Index → spring target. When looping, pick the copy of the index
  // nearest the current target so 8 → 0 steps forward instead of
  // spinning back through every slide.
  useLayoutEffect(() => {
    const engine = engineRef.current!;
    const t = engine.target;
    const vt = loop && n > 0 ? t + (mod(current - t + n / 2, n) - n / 2) : current;
    if (vt !== t) engine.setTarget(vt);
  }, [current, n, loop]);

  useEffect(() => {
    const engine = engineRef.current!;
    return () => engine.destroy();
  }, []);

  /**
   * Moves the spring to virtual index `vt` and publishes the wrapped index.
   * `announce` is false for autoplay so screen readers aren't spammed.
   */
  const commit = useCallback((vt: number, announce: boolean, velocity?: number) => {
    const s = stateRef.current;
    if (!s.n) return;
    const t = s.loop ? vt : clamp(vt, 0, s.n - 1);
    engineRef.current!.setTarget(t, velocity);
    const next = mod(t, s.n);
    if (announce) {
      const title = s.items[next]?.title;
      setAnnouncement(`${next + 1} / ${s.n}${title ? `:${title}` : ""}`);
    }
    if (next !== s.current) {
      if (!s.isControlled) setInnerIndex(next);
      s.onIndexChange?.(next);
    }
  }, []);

  const step = useCallback(
    (delta: number, announce = true) => {
      commit(Math.round(engineRef.current!.target) + delta, announce);
    },
    [commit],
  );

  const goTo = useCallback(
    (i: number) => {
      const s = stateRef.current;
      const t = engineRef.current!.target;
      commit(s.loop ? t + (mod(i - t + s.n / 2, s.n) - s.n / 2) : i, true);
    },
    [commit],
  );

  /** Pixels of drag / wheel travel that move the carousel one slide. */
  const pxPerSlide = useCallback(() => {
    const slide = engineRef.current!.slides.find(Boolean);
    const w = slide?.offsetWidth || DEFAULT_SLIDE_WIDTH;
    return w * (NEIGHBOUR_GAP + stateRef.current.spacing) * 0.8;
  }, []);

  /** Soft resistance past the ends when not looping. */
  const rubberBand = useCallback((p: number) => {
    const s = stateRef.current;
    if (s.loop) return p;
    const max = s.n - 1;
    if (p < 0) return p * RUBBER_BAND;
    if (p > max) return max + (p - max) * RUBBER_BAND;
    return p;
  }, []);

  // ------------------------------------------------------------------
  // Pointer drag. Capture starts only once the press turns into a drag,
  // so a plain click still lands on the slide underneath.
  // ------------------------------------------------------------------
  const dragRef = useRef({
    id: -1,
    startX: 0,
    startY: 0,
    startPos: 0,
    lastX: 0,
    lastT: 0,
    vel: 0,
    active: false,
    unit: 1,
  });
  const suppressClickRef = useRef(false);

  const onPointerDown = useCallback(
    (e: ReactPointerEvent<HTMLDivElement>) => {
      if (e.pointerType === "mouse" && e.button !== 0) return;
      suppressClickRef.current = false;
      dragRef.current = {
        id: e.pointerId,
        startX: e.clientX,
        startY: e.clientY,
        startPos: engineRef.current!.pos,
        lastX: e.clientX,
        lastT: e.timeStamp,
        vel: 0,
        active: false,
        unit: pxPerSlide(),
      };
    },
    [pxPerSlide],
  );

  const onPointerMove = useCallback(
    (e: ReactPointerEvent<HTMLDivElement>) => {
      const d = dragRef.current;
      if (d.id !== e.pointerId) return;
      const dx = e.clientX - d.startX;
      if (!d.active) {
        const dy = e.clientY - d.startY;
        if (Math.abs(dx) < DRAG_THRESHOLD || Math.abs(dx) < Math.abs(dy)) return;
        d.active = true;
        e.currentTarget.setPointerCapture(e.pointerId);
        setDragging(true);
      }
      const dt = Math.max(e.timeStamp - d.lastT, 1) / 1000;
      // Exponential smoothing: one jittery event shouldn't decide the flick.
      const instant = -(e.clientX - d.lastX) / d.unit / dt;
      d.vel = d.vel * 0.7 + instant * 0.3;
      d.lastX = e.clientX;
      d.lastT = e.timeStamp;
      engineRef.current!.hold(rubberBand(d.startPos - dx / d.unit));
    },
    [rubberBand],
  );

  const endDrag = useCallback(
    (e: ReactPointerEvent<HTMLDivElement>) => {
      const d = dragRef.current;
      if (d.id !== e.pointerId) return;
      d.id = -1;
      if (!d.active) return;
      d.active = false;
      suppressClickRef.current = true;
      setDragging(false);
      try {
        e.currentTarget.releasePointerCapture(e.pointerId);
      } catch {
        /* capture may already be gone */
      }
      // A stale velocity (pointer held still before release) shouldn't flick.
      const vel = e.timeStamp - d.lastT > 80 ? 0 : d.vel;
      const projected = Math.round(engineRef.current!.pos + vel * FLICK_PROJECTION);
      commit(projected, true, vel);
    },
    [commit],
  );

  // ------------------------------------------------------------------
  // Horizontal wheel / trackpad: scrubs like a drag, snaps when idle.
  // Native listener because React's onWheel is passive (no preventDefault,
  // which we need to stop the browser's back/forward swipe).
  // ------------------------------------------------------------------
  useEffect(() => {
    const stage = stageRef.current;
    if (!stage) return;
    let timer = 0;
    let unit = 1;
    let scrubbing = false;
    const onWheel = (e: WheelEvent) => {
      if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return; // vertical: page scroll
      e.preventDefault();
      const engine = engineRef.current!;
      if (!scrubbing) {
        scrubbing = true;
        unit = pxPerSlide();
      }
      const px = e.deltaMode === 1 ? e.deltaX * 16 : e.deltaX;
      engine.hold(rubberBand(engine.pos + px / unit));
      window.clearTimeout(timer);
      timer = window.setTimeout(() => {
        scrubbing = false;
        commit(Math.round(engine.pos), true);
      }, WHEEL_SNAP_DELAY);
    };
    stage.addEventListener("wheel", onWheel, { passive: false });
    return () => {
      stage.removeEventListener("wheel", onWheel);
      window.clearTimeout(timer);
    };
  }, [commit, pxPerSlide, rubberBand]);

  const onSlideClick = useCallback(
    (i: number) => {
      if (suppressClickRef.current) {
        suppressClickRef.current = false;
        return;
      }
      if (i !== stateRef.current.current) goTo(i);
    },
    [goTo],
  );

  const onKeyDown = useCallback(
    (e: ReactKeyboardEvent<HTMLElement>) => {
      const s = stateRef.current;
      switch (e.key) {
        case "ArrowLeft":
          e.preventDefault();
          step(-1);
          break;
        case "ArrowRight":
          e.preventDefault();
          step(1);
          break;
        case "Home":
          e.preventDefault();
          goTo(0);
          break;
        case "End":
          e.preventDefault();
          goTo(s.n - 1);
          break;
        default:
          break;
      }
    },
    [goTo, step],
  );

  // ------------------------------------------------------------------
  // Autoplay: one timeout per index, cleared whenever anything pauses it.
  // ------------------------------------------------------------------
  useEffect(() => {
    const root = rootRef.current;
    if (!root || typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver(([entry]) => setOffscreen(!entry.isIntersecting));
    io.observe(root);
    return () => io.disconnect();
  }, []);

  const [hidden, setHidden] = useState(false);
  useEffect(() => {
    const onVisibility = () => setHidden(document.hidden);
    document.addEventListener("visibilitychange", onVisibility);
    return () => document.removeEventListener("visibilitychange", onVisibility);
  }, []);

  const playing =
    autoplay && !reduced && n > 1 && !hovered && !focused && !offscreen && !hidden && !dragging;
  useEffect(() => {
    if (!playing) return;
    const id = window.setTimeout(() => {
      const s = stateRef.current;
      // Without loop, rewind to the start instead of stalling on the last slide.
      if (!s.loop && s.current >= s.n - 1) goTo(0);
      else step(1, false);
    }, Math.max(interval, 500));
    return () => window.clearTimeout(id);
  }, [playing, interval, current, goTo, step]);

  // Hover pause is mouse-only: a touch "enter" would never get its leave.
  const onPointerEnter = useCallback((e: ReactPointerEvent<HTMLElement>) => {
    if (e.pointerType === "mouse") setHovered(true);
  }, []);
  const onPointerLeave = useCallback((e: ReactPointerEvent<HTMLElement>) => {
    if (e.pointerType === "mouse") setHovered(false);
  }, []);
  const onFocus = useCallback(() => setFocused(true), []);
  const onBlur = useCallback((e: ReactFocusEvent<HTMLElement>) => {
    if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocused(false);
  }, []);

  const geometry: Geometry = { rotate, depth, spacing, range };

  const rootStyle: CSSVars = {};
  if (slideWidth !== undefined) rootStyle["--cf-slide-w"] = `${slideWidth}px`;
  if (aspect !== undefined) rootStyle["--cf-aspect"] = aspect;

  const rootClassName = [styles.root, className].filter(Boolean).join(" ");
  const atStart = !loop && current === 0;
  const atEnd = !loop && current === n - 1;

  return (
    <section
      ref={rootRef}
      className={rootClassName}
      style={rootStyle}
      aria-roledescription="カルーセル"
      aria-label={ariaLabel}
      tabIndex={0}
      onKeyDown={onKeyDown}
      onFocus={onFocus}
      onBlur={onBlur}
      onPointerEnter={onPointerEnter}
      onPointerLeave={onPointerLeave}
      data-reflection={reflection ? "" : undefined}
      data-dragging={dragging ? "" : undefined}
    >
      <div
        ref={stageRef}
        className={styles.stage}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={endDrag}
        onPointerCancel={endDrag}
      >
        <div className={styles.floor} aria-hidden />
        {items.map((item, i) => {
          const active = i === current;
          const f = slideFrame(offsetOf(i, mountIndex, n, loop), geometry);
          const slideStyle: CSSVars = {
            transform: f.transform,
            opacity: f.opacity,
            zIndex: f.zIndex,
            visibility: f.opacity === 0 ? "hidden" : undefined,
            "--cf-shade": f.shade,
          };
          const state: CoverflowSlideState = { index: i, active, total: n };
          const content = renderItem ? renderItem(item, state) : <DefaultSlide item={item} />;
          return (
            <div
              key={item.id ?? i}
              ref={(el) => {
                engineRef.current!.slides[i] = el;
              }}
              className={styles.slide}
              style={slideStyle}
              role="group"
              aria-roledescription="スライド"
              aria-label={`${i + 1} / ${n}`}
              // Side slides stay visible and clickable, but their content is
              // inert so Tab never lands inside a slide that isn't in front.
              aria-hidden={active ? undefined : true}
              data-active={active ? "" : undefined}
              onClick={() => onSlideClick(i)}
            >
              <div className={styles.face} inert={!active}>
                {content}
              </div>
              {reflection ? (
                <div className={styles.reflection} aria-hidden inert>
                  <div className={styles.reflectionInner}>{content}</div>
                </div>
              ) : null}
            </div>
          );
        })}
      </div>

      {showDots && n > 1 ? (
        <div className={styles.controls}>
          <button
            type="button"
            className={styles.arrow}
            onClick={() => step(-1)}
            disabled={atStart}
            aria-label="前のスライド"
          >
            <Chevron dir="left" />
          </button>
          <div className={styles.dots}>
            {items.map((item, i) => (
              <button
                key={item.id ?? i}
                type="button"
                className={styles.dot}
                onClick={() => goTo(i)}
                aria-label={`${i + 1} 枚目へ${item.title ? `:${item.title}` : ""}`}
                aria-current={i === current ? "true" : undefined}
              />
            ))}
          </div>
          <button
            type="button"
            className={styles.arrow}
            onClick={() => step(1)}
            disabled={atEnd}
            aria-label="次のスライド"
          >
            <Chevron dir="right" />
          </button>
        </div>
      ) : null}

      <div className={styles.srOnly} aria-live="polite" aria-atomic="true">
        {announcement}
      </div>
    </section>
  );
}

export default CoverflowCarousel;
```

#### `components/coverflow-carousel/CoverflowCarousel.module.css`
```css
/* ==========================================================================
   Coverflow carousel — Apple Cover Flow, modernised

   The front slide faces the viewer; neighbours turn on Y and recede in Z,
   overlapping over a glossy floor. JS writes only transform / opacity /
   z-index / --cf-shade per slide while animating; everything else is here.
   ========================================================================== */

.root {
  /* Defaults. The component sets these inline only for non-default props,
     so the default render is exactly this stylesheet. */
  --cf-slide-w: 260px;
  --cf-aspect: 1;
  --cf-radius: 14px;
  /* Capped to the container so the fan still fits a phone or a small card.
     cqw resolves where --cf-w is used (inside .root, the container), so no
     JS measuring is needed and the server already renders the right size. */
  --cf-w: min(var(--cf-slide-w), 38cqw);
  --cf-h: calc(var(--cf-w) * var(--cf-aspect));

  position: relative;
  width: 100%; /* inline-size containment: never shrink-to-fit */
  container-type: inline-size;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
  padding: 20px 0 18px;
  overflow: hidden;
  isolation: isolate;
  outline: none;
  border-radius: 18px;
  color: #fff;
  font-family:
    -apple-system, BlinkMacSystemFont, "Hiragino Sans",
    "Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto,
    sans-serif;
}

.root:focus-visible {
  outline: 2px solid rgba(255, 255, 255, 0.7);
  outline-offset: -2px;
}

/* --- Stage: perspective container + drag surface. The reflection lives in
   its bottom padding so the edge-fade mask covers it too (a mask clips
   anything painted outside the element's box). --- */
.stage {
  position: relative;
  width: 100%;
  height: var(--cf-h);
  perspective: calc(var(--cf-w) * 3.6);
  perspective-origin: 50% calc(var(--cf-h) / 2);
  /* Horizontal drags are ours; vertical swipes still scroll the page. */
  touch-action: pan-y;
  cursor: grab;
  user-select: none;
  -webkit-user-select: none;
  -webkit-tap-highlight-color: transparent;
  mask-image: linear-gradient(
    90deg,
    transparent,
    #000 11%,
    #000 89%,
    transparent
  );
}

.root[data-reflection] .stage {
  padding-bottom: calc(var(--cf-h) * 0.34);
}

.root[data-dragging] .stage,
.root[data-dragging] .slide {
  cursor: grabbing;
}

/* --- Glossy floor: a faint horizon line + a cool pool of light where the
   reflections fall. Decorative; hidden without reflections. --- */
.floor {
  position: absolute;
  top: var(--cf-h);
  left: 8%;
  right: 8%;
  height: calc(var(--cf-h) * 0.34);
  pointer-events: none;
  background:
    linear-gradient(
      90deg,
      transparent,
      rgba(255, 255, 255, 0.16) 50%,
      transparent
    )
    top / 100% 1px no-repeat,
    radial-gradient(55% 100% at 50% 0%, rgba(170, 150, 255, 0.12), transparent 72%);
}

.root:not([data-reflection]) .floor {
  display: none;
}

/* --- Slides. Centred on the stage; the engine moves them from here. --- */
.slide {
  position: absolute;
  top: 0;
  left: 50%;
  width: var(--cf-w);
  height: var(--cf-h);
  margin-left: calc(var(--cf-w) / -2);
  cursor: pointer;
  will-change: transform, opacity;
  --cf-shade: 0;
}

.slide[data-active] {
  cursor: inherit;
}

.face {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: var(--cf-radius);
  background: #15131c;
  box-shadow:
    0 28px 50px -18px rgba(0, 0, 0, 0.75),
    0 8px 18px -10px rgba(0, 0, 0, 0.6);
}

/* Hairline rim + top light, and the depth shade (driven per frame). */
.face::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background: rgba(6, 5, 12, var(--cf-shade));
  box-shadow:
    inset 0 0 0 1px rgba(255, 255, 255, 0.1),
    inset 0 1px 0 rgba(255, 255, 255, 0.22);
}

/* --- Reflection: a flipped copy fading into the floor. Standard CSS
   instead of the non-standard -webkit-box-reflect. Dims with the slide's
   shade so far reflections don't outshine their slides. --- */
.reflection {
  position: absolute;
  top: 100%;
  left: 0;
  width: 100%;
  height: 100%;
  margin-top: 3px;
  pointer-events: none;
  opacity: calc(0.3 * (1 - var(--cf-shade)));
  mask-image: linear-gradient(to bottom, #000 0%, transparent 36%);
}

.reflectionInner {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: var(--cf-radius);
  transform: scaleY(-1);
}

/* --- Default slide: gradient art + gloss + grain + caption --- */
.art {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  background: linear-gradient(150deg, #3b2a6b, #0f0c1d);
}

.gloss {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(
    155deg,
    rgba(255, 255, 255, 0.2) 0%,
    rgba(255, 255, 255, 0.04) 30%,
    transparent 44%
  );
}

/* Film grain keeps flat gradients from banding and reads as printed art. */
.gloss::after {
  content: "";
  position: absolute;
  inset: 0;
  opacity: 0.16;
  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%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  background-size: 160px 160px;
}

.caption {
  position: absolute;
  inset: auto 0 0;
  padding: 36% calc(var(--cf-w) * 0.065) calc(var(--cf-w) * 0.06);
  background: linear-gradient(to top, rgba(6, 5, 12, 0.74), rgba(6, 5, 12, 0));
}

.title {
  margin: 0;
  font-size: clamp(11px, calc(var(--cf-w) * 0.074), 22px);
  font-weight: 650;
  line-height: 1.2;
  letter-spacing: -0.01em;
  text-wrap: balance;
}

.subtitle {
  margin: 0.35em 0 0;
  font-size: clamp(9px, calc(var(--cf-w) * 0.048), 14px);
  line-height: 1.3;
  letter-spacing: 0.02em;
  color: rgba(255, 255, 255, 0.66);
}

/* --- Control bar: prev · dots · next in a frosted pill --- */
.controls {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 4px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.06);
  box-shadow:
    inset 0 0 0 1px rgba(255, 255, 255, 0.09),
    inset 0 1px 0 rgba(255, 255, 255, 0.08);
  /* Unprefixed only: the build adds -webkit-, and writing both makes
     Lightning CSS drop the unprefixed one. */
  backdrop-filter: blur(12px);
}

.arrow {
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: rgba(255, 255, 255, 0.82);
  cursor: pointer;
  transition:
    background-color 0.2s ease,
    opacity 0.2s ease;
}

.arrow:hover:not(:disabled) {
  background: rgba(255, 255, 255, 0.1);
}

.arrow:disabled {
  opacity: 0.3;
  cursor: default;
}

.dots {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 0 4px;
}

.dot {
  position: relative;
  width: 6px;
  height: 6px;
  padding: 0;
  border: 0;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.28);
  cursor: pointer;
  transition:
    width 0.4s cubic-bezier(0.2, 0.8, 0.2, 1),
    background-color 0.3s ease;
}

/* Larger hit area than the 6px dot. */
.dot::before {
  content: "";
  position: absolute;
  inset: -9px -3px;
}

.dot:hover {
  background: rgba(255, 255, 255, 0.5);
}

.dot[aria-current="true"] {
  width: 20px;
  background: #fff;
}

.arrow:focus-visible,
.dot:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 2px;
}

.srOnly {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

/* The spring already snaps under reduced motion (see the engine); this
   covers the CSS-side transitions. */
@media (prefers-reduced-motion: reduce) {
  .arrow,
  .dot {
    transition: none;
  }
}
```

代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/coverflow-carousel.json
```

### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { CoverflowCarousel } from "@/components/coverflow-carousel";

// TODO: items の image には画像 URL か CSS の background 値(グラデーション可)を渡せます。カスタム表示は renderItem で
<CoverflowCarousel
  rotate={55}
  depth={220}
  spacing={0.3}
  slideWidth={260}
  reflection={true}
  showDots={true}
  loop={false}
  autoplay={false}
  interval={3500}
  items={[
    { title: "Neon Drive", subtitle: "Midnight Arcade", image: "radial-gradient(90% 55% at 50% 100%, #ff3cac 0%, rgba(255,60,172,0) 70%), repeating-linear-gradient(0deg, rgba(255,255,255,.07) 0 1px, transparent 1px 16px), linear-gradient(180deg, #0e0322 0%, #3a0b5c 55%, #ff6a88 100%)" },
    { title: "夜明けの街", subtitle: "灯火", image: "radial-gradient(45% 45% at 68% 38%, #ffe2b0 0%, rgba(255,226,176,0) 70%), linear-gradient(180deg, #17244a 0%, #5e4686 42%, #ee9579 78%, #ffd3a5 100%)" },
    { title: "Glass Garden", subtitle: "Mirei", image: "radial-gradient(55% 55% at 28% 30%, #7cf5c4 0%, rgba(124,245,196,0) 65%), radial-gradient(60% 60% at 78% 72%, #3b82f6 0%, rgba(59,130,246,0) 65%), linear-gradient(135deg, #04131a, #0b3d3a)" },
    { title: "Saturn Hours", subtitle: "Orbitals", image: "radial-gradient(circle at 50% 50%, #0b0b14 0 24%, rgba(11,11,20,0) 24.5%), conic-gradient(from 200deg at 50% 50%, #f7c873, #e2725b, #6a3093, #2b5876, #f7c873)" },
    { title: "静寂のプール", subtitle: "Kumo", image: "linear-gradient(180deg, rgba(255,255,255,0) 58%, rgba(255,255,255,.16) 58.5%, rgba(255,255,255,0) 74%), radial-gradient(40% 30% at 50% 40%, #ffffff 0%, rgba(255,255,255,0) 70%), linear-gradient(165deg, #a1c4fd 0%, #5b7fd6 48%, #1e2a5a 100%)" },
    { title: "Afterglow", subtitle: "Lumen", image: "radial-gradient(38% 38% at 50% 46%, #fff3c4 0%, #ffb347 32%, rgba(255,95,109,0) 72%), linear-gradient(180deg, #2b0f2f 0%, #7a1e48 58%, #ff5f6d 100%)" },
    { title: "Static Bloom", subtitle: "Noise Unit", image: "radial-gradient(70% 70% at 70% 30%, rgba(8,6,20,0) 0%, rgba(8,6,20,.85) 75%), conic-gradient(from 90deg at 32% 68%, #00f5d4, #9b5de5, #f15bb5, #fee440, #00f5d4)" },
    { title: "東京ナイトフライト", subtitle: "Sora", image: "radial-gradient(2px 2px at 22% 28%, #fff 50%, transparent), radial-gradient(1.5px 1.5px at 72% 18%, #fff 50%, transparent), radial-gradient(1.5px 1.5px at 44% 52%, #fff 50%, transparent), radial-gradient(1px 1px at 86% 44%, #fff 50%, transparent), radial-gradient(85% 50% at 50% 100%, #3a7bd5 0%, rgba(58,123,213,0) 72%), linear-gradient(180deg, #050816, #0f1c3f)" },
    { title: "Velvet Room", subtitle: "Marlowe", image: "radial-gradient(70% 70% at 30% 20%, #c471f5 0%, rgba(196,113,245,0) 62%), radial-gradient(70% 70% at 80% 82%, #fa71cd 0%, rgba(250,113,205,0) 62%), linear-gradient(135deg, #1a0526, #3d0a3f)" },
  ]}
  defaultIndex={4}
  aria-label="アルバム"
/>
```

### 4. 組み込み手順
1. 使うコンポーネント側で `items`(`{ id?, title?, subtitle?, image?, alt? }[]`)を用意し、`<CoverflowCarousel items={items} />` を置く。`image` は画像 URL か CSS の background 値(`linear-gradient(…)` など)。画像が無くてもグラデーション+タイトルの既定スライドで表示される。
2. 幅は親要素いっぱい(`width: 100%`)に広がり、スライド幅は `slideWidth` とコンテナ幅の 38% の小さい方。親を `display: flex` で中央寄せする場合も幅が 0 にならないよう、親に幅を持たせる(コンテナクエリを使っているため、中身の幅では広がらない)。
3. 現在のスライドを外で持ちたい場合は `index` と `onIndexChange` を組で渡す(制御モード)。`onIndexChange` で state を更新しないとスライドが元の位置に戻らないので注意。初期位置だけなら `defaultIndex`。
4. 独自の見た目は `renderItem={(item, { index, active, total }) => …}` で描画する。映り込みを有効にしていると `renderItem` の結果が反転コピー用にもう一度描画される(`aria-hidden` + `inert`)。動画や重い要素を入れる場合は `reflection={false}` を検討する。
5. 画像は `<img loading="lazy">` で描画される。Next.js の `next/image` を使いたい場合は `renderItem` の中で使う。
6. `npm run build` が通ることを確認し、実機で「横ドラッグで最寄りにスナップ」「縦スワイプでページがスクロールする」「トラックパッドの横スクロールで送れる」「左右のスライドをクリックで手前に来る」を確認する。

#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| スライドがすべて中央に重なる / 幅 0 になる | ルートは `container-type: inline-size` なので、shrink-to-fit な親(`inline-flex` や幅指定の無い flex アイテム)だと幅が 0 になる | ルートの `width: 100%` を外さず、親に幅を与える |
| ドラッグ中にカクつく / React が毎フレーム再レンダリングされる | 位置を state に入れている | 位置はエンジン(`createEngine`)が保持し、`transform` を要素へ直接書く。state は確定したインデックスだけ |
| 左右のスライドをクリックしても反応しない | ドラッグ開始前に `setPointerCapture` している(click の発火先がステージに変わる) | キャプチャは 6px 動いてドラッグと判定してから |
| トラックパッドの横スクロールでブラウザの「戻る」が発動する | React の `onWheel` は passive で `preventDefault` できない | `addEventListener("wheel", …, { passive: false })` を外さない。縦優位のホイールは素通しする |
| ループ時に 9 → 1 枚目で全スライドが逆回転する | 目標をインデックスそのものにしている | 目標は「仮想インデックス」。現在の目標から最も近い同値の位置を選ぶ |
| 端のスライドが反対側へ瞬間移動して見える | ループ時の折り返し位置(±n/2)より手前でフェードしきっていない | `visibleRange` で可視範囲を n に応じて狭める |
| 映り込みがステージの外で切れる | `mask-image` は要素のボックス外を描画しない | 映り込みはステージの `padding-bottom` の中に収める |

### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる

## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- Next.js(App Router)、React 19、TypeScript、CSS Modules。アニメーションライブラリは使わない(純粋な CSS 3D + requestAnimationFrame)
- ファイル: `components/coverflow-carousel/CoverflowCarousel.tsx`(`"use client"`)+ `CoverflowCarousel.module.css` + `index.ts`
- Props: `items`、`index` / `defaultIndex` / `onIndexChange`、`rotate`(55deg)、`depth`(220px)、`spacing`(0.3 = スライド幅比)、`slideWidth`(260px)、`aspect`(1)、`reflection`(true)、`autoplay`(false)+ `interval`(3500ms)、`loop`(false)、`showDots`(true)、`className`、`aria-label`、`renderItem(item, { index, active, total })`

### 見た目
- ルート: `width: 100%`、`container-type: inline-size`、`overflow: hidden`、縦 flex(ステージ → コントロールバー、gap 14px)
- スライド幅 `--cf-w: min(slideWidth, 38cqw)`、高さ `--cf-w × aspect`。JS で測らないので SSR の初回描画から正しいサイズ
- ステージ: `perspective: calc(--cf-w × 3.6)`、左右 11% を `mask-image` でフェード。`touch-action: pan-y`、`cursor: grab`
- 配置(d = スライドの中心からの距離、|d| ≤ 1 で near、それ以降 far):
  - X = `sign(d) × (near × (0.55 + spacing) + far × spacing) × --cf-w`
  - Z = `-(near × depth + far × depth × 0.15)`
  - rotateY = `-sign(d) × near × rotate`(右側は外側の縁が手前、正面を中央へ向ける)
  - z-index = `100 − round(|d| × 10)`、可視範囲(既定 3 枚、ループ時は n に応じて縮小)の外 0.75 枚でフェードアウト
  - 陰影 `--cf-shade = min(near × 0.42 + far × 0.1, 0.75)` を面の上に `rgba(6,5,12,shade)` で重ねる
- 既定スライド: 角丸 14px、`background` にグラデーション(URL なら `<img object-fit: cover>`)、左上からの光沢(白 .20 → 0 at 44%)、SVG `feTurbulence` のフィルムグレイン(overlay、.16)、下部に黒 .74 → 0 のグラデーションとタイトル(幅 × 0.074、600〜650)/ サブタイトル(幅 × 0.048、白 .66)。縁は `inset 0 0 0 1px` 白 .10 + 上辺ハイライト、影 `0 28px 50px -18px rgba(0,0,0,.75)`
- 映り込み: スライド直下(3px 空ける)に同じ内容を `scaleY(-1)` で複製、`mask-image: linear-gradient(to bottom, #000, transparent 36%)`、不透明度 `0.3 × (1 − shade)`。`-webkit-box-reflect` は使わない
- 床: スライド下端に中央が明るい 1px の水平線(白 .16)と、薄い紫の放射グラデーション
- コントロールバー: すりガラスのピル(白 .06 + `backdrop-filter: blur(12px)`)に「‹ ドット ›」。ドットは 6px、現在のものは幅 20px の白ピルへ伸びる(0.4s)

### モーション
- 位置は浮動小数の「スライド単位」。スプリング(質量 1、剛性 210、減衰 26 ≈ ζ0.9)で整数の目標へ。落ち着いたら rAF を止める
- ドラッグ: 6px 以上かつ横優位で開始し、そこで pointer capture。1 枚ぶん = スライド幅 × (0.55 + spacing) × 0.8 px。端ではラバーバンド(0.3 倍)。離したら速度 × 0.16 秒先を四捨五入した位置へ、その速度を引き継いでスプリング
- ホイール: 横優位の `deltaX` だけ `preventDefault` してスクラブ、140ms 止まったら最寄りへスナップ
- クリック: 側面のスライドをクリックでそのスライドへ(ドラッグ直後のクリックは無視)
- 自動再生: `interval` ごとに次へ。ホバー(マウスのみ)・フォーカス中・画面外(IntersectionObserver)・タブ非表示・ドラッグ中は停止。ループ無しなら最後の次は先頭へ戻る
- `prefers-reduced-motion: reduce`: スプリングを使わず即座に移動、自動再生はオフ

### アクセシビリティ
- ルート `<section aria-roledescription="カルーセル" aria-label tabIndex={0}>`。← / → で前後、Home / End で先頭 / 末尾
- 各スライド `role="group" aria-roledescription="スライド" aria-label="n / total"`。正面以外は `aria-hidden`、中身は `inert`(クリックは受けるがタブ移動では入らない)
- 矢印ボタン `aria-label="前のスライド" / "次のスライド"`(ループ無しの端では disabled)、ドットは `aria-label="n 枚目へ:タイトル"` + `aria-current`
- ユーザー操作での変更は `aria-live="polite"` で「n / total:タイトル」を読み上げ(自動再生では読み上げない)
- 映り込みと床は `aria-hidden`

### 受け入れ条件
- 初回描画(JS 前)から扇状に並び、正面のスライドの左右に 3 枚ずつ重なって見える
- ドラッグ・フリック・横スクロール・クリック・キーボード・ドットのどれでも、スプリングで滑らかに最寄りのスライドへ止まる
- 縦スワイプでページがスクロールする
- 映り込みが床に向かって自然に消え、奥のスライドほど暗い
- 狭いコンテナでもはみ出さず、スライドが縮む
インストール
npx shadcn@latest add https://anima-js.vercel.app/r/coverflow-carousel.json
生成コード
import { CoverflowCarousel } from "@/components/coverflow-carousel";

// TODO: items の image には画像 URL か CSS の background 値(グラデーション可)を渡せます。カスタム表示は renderItem で
<CoverflowCarousel
  rotate={55}
  depth={220}
  spacing={0.3}
  slideWidth={260}
  reflection={true}
  showDots={true}
  loop={false}
  autoplay={false}
  interval={3500}
  items={[
    { title: "Neon Drive", subtitle: "Midnight Arcade", image: "radial-gradient(90% 55% at 50% 100%, #ff3cac 0%, rgba(255,60,172,0) 70%), repeating-linear-gradient(0deg, rgba(255,255,255,.07) 0 1px, transparent 1px 16px), linear-gradient(180deg, #0e0322 0%, #3a0b5c 55%, #ff6a88 100%)" },
    { title: "夜明けの街", subtitle: "灯火", image: "radial-gradient(45% 45% at 68% 38%, #ffe2b0 0%, rgba(255,226,176,0) 70%), linear-gradient(180deg, #17244a 0%, #5e4686 42%, #ee9579 78%, #ffd3a5 100%)" },
    { title: "Glass Garden", subtitle: "Mirei", image: "radial-gradient(55% 55% at 28% 30%, #7cf5c4 0%, rgba(124,245,196,0) 65%), radial-gradient(60% 60% at 78% 72%, #3b82f6 0%, rgba(59,130,246,0) 65%), linear-gradient(135deg, #04131a, #0b3d3a)" },
    { title: "Saturn Hours", subtitle: "Orbitals", image: "radial-gradient(circle at 50% 50%, #0b0b14 0 24%, rgba(11,11,20,0) 24.5%), conic-gradient(from 200deg at 50% 50%, #f7c873, #e2725b, #6a3093, #2b5876, #f7c873)" },
    { title: "静寂のプール", subtitle: "Kumo", image: "linear-gradient(180deg, rgba(255,255,255,0) 58%, rgba(255,255,255,.16) 58.5%, rgba(255,255,255,0) 74%), radial-gradient(40% 30% at 50% 40%, #ffffff 0%, rgba(255,255,255,0) 70%), linear-gradient(165deg, #a1c4fd 0%, #5b7fd6 48%, #1e2a5a 100%)" },
    { title: "Afterglow", subtitle: "Lumen", image: "radial-gradient(38% 38% at 50% 46%, #fff3c4 0%, #ffb347 32%, rgba(255,95,109,0) 72%), linear-gradient(180deg, #2b0f2f 0%, #7a1e48 58%, #ff5f6d 100%)" },
    { title: "Static Bloom", subtitle: "Noise Unit", image: "radial-gradient(70% 70% at 70% 30%, rgba(8,6,20,0) 0%, rgba(8,6,20,.85) 75%), conic-gradient(from 90deg at 32% 68%, #00f5d4, #9b5de5, #f15bb5, #fee440, #00f5d4)" },
    { title: "東京ナイトフライト", subtitle: "Sora", image: "radial-gradient(2px 2px at 22% 28%, #fff 50%, transparent), radial-gradient(1.5px 1.5px at 72% 18%, #fff 50%, transparent), radial-gradient(1.5px 1.5px at 44% 52%, #fff 50%, transparent), radial-gradient(1px 1px at 86% 44%, #fff 50%, transparent), radial-gradient(85% 50% at 50% 100%, #3a7bd5 0%, rgba(58,123,213,0) 72%), linear-gradient(180deg, #050816, #0f1c3f)" },
    { title: "Velvet Room", subtitle: "Marlowe", image: "radial-gradient(70% 70% at 30% 20%, #c471f5 0%, rgba(196,113,245,0) 62%), radial-gradient(70% 70% at 80% 82%, #fa71cd 0%, rgba(250,113,205,0) 62%), linear-gradient(135deg, #1a0526, #3d0a3f)" },
  ]}
  defaultIndex={4}
  aria-label="アルバム"
/>