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

FanCarousel

Motion

手札のように弧を描いて広がるカードの扇 — 中央のカードが浮き上がってまっすぐに立ち、隣のカードは距離に応じて傾きます。ドラッグ・横スクロールでバネのように追従、クリックで選択、ホバーでちらっと覗く。画面に入ると山札から配られるイントロ付き。framer-motion。

0 · The Fool愚者
I · The Magician魔術師
II · High Priestess女教皇
III · The Empress女帝
VI · The Lovers恋人
X · Wheel of Fortune運命の輪
XVII · The Star星
XVIII · The Moon月
XIX · The Sun太陽

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

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

## コンポーネントについて
- 名前: FanCarousel
- 説明: 手札のように弧を描いて広がるカードの扇 — 中央のカードが浮き上がってまっすぐに立ち、隣のカードは距離に応じて傾きます。ドラッグ・横スクロールでバネのように追従、クリックで選択、ホバーでちらっと覗く。画面に入ると山札から配られるイントロ付き。framer-motion。
- 実行時に必要な npm パッケージ: framer-motion, lucide-react
- 構成ファイル: `components/fan-carousel/index.tsx`, `components/fan-carousel/FanCarousel.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/fan-carousel/index.tsx`
```tsx
"use client";

import {
  useCallback,
  useEffect,
  useRef,
  useState,
  type CSSProperties,
  type KeyboardEvent as ReactKeyboardEvent,
  type PointerEvent as ReactPointerEvent,
  type ReactNode,
} from "react";
import {
  MotionConfig,
  animate,
  motion,
  useMotionValue,
  useMotionValueEvent,
  useReducedMotion,
  useSpring,
  useTransform,
  type MotionValue,
} from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react";
import styles from "./FanCarousel.module.css";

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

export type FanCarouselItem = {
  /** Stable key; falls back to the array position. */
  id?: string;
  /** Default card: the large caption. Also part of the slide's label. */
  title?: string;
  /** Default card: the small line above the title. */
  subtitle?: string;
  /**
   * Card art. A CSS background value (gradients, layered `url(…)`s with
   * positions/sizes) is used as-is; anything else is treated as an image
   * URL and covers the card.
   */
  image?: string;
  /** Accessible description of the art, when it carries meaning. */
  alt?: string;
};

export type FanCarouselItemState = {
  /** 0-based position of this item in `items`. */
  index: number;
  total: number;
  /** The lifted, front-most card. */
  active: boolean;
  /** Signed distance from the active card in whole cards (render-time). */
  offset: number;
};

export type FanCarouselProps = {
  items: readonly FanCarouselItem[];
  /** Controlled active card. Pair with `onIndexChange`. */
  index?: number;
  /** Uncontrolled starting card. */
  defaultIndex?: number;
  /** Fires when the user settles on a new card (drag, wheel, click, keys). */
  onIndexChange?: (index: number) => void;
  /** Total fan angle in deg across the cards visible on both sides. */
  spread?: number;
  /** Distance in px from each card's centre to the pivot below the hand. */
  radius?: number;
  /** Card width in px (the fan scales down to fit narrower containers). */
  cardWidth?: number;
  /** Card height = width × aspect. */
  aspect?: number;
  /** Px the active card rises out of the hand. */
  lift?: number;
  /** Cards rendered on each side of the active one. */
  maxVisible?: number;
  /** Deal the cards from a stacked deck into the fan when scrolled into view. */
  dealIn?: boolean;
  /** Spring of the fan following drag / settling on a card. */
  springStiffness?: number;
  springDamping?: number;
  /** Prev / next buttons and the counter under the fan. */
  showControls?: boolean;
  /** Replaces the default card face; the frame, motion and shading stay. */
  renderItem?: (item: FanCarouselItem, state: FanCarouselItemState) => ReactNode;
  className?: string;
  "aria-label"?: string;
};

// Mirror the stylesheet defaults: geometry vars are only set inline when a
// prop differs, so the default render is exactly the stylesheet.
const DEFAULT_SPREAD = 56;
const DEFAULT_RADIUS = 600;
const DEFAULT_CARD_WIDTH = 160;
const DEFAULT_ASPECT = 1.55;
const DEFAULT_LIFT = 32;
const DEFAULT_MAX_VISIBLE = 4;

/** Extra angle (fraction of one step) opened between the active card and
 *  its neighbours, so the lifted card reads as pulled out of the hand. */
const ACTIVE_GAP = 0.45;
/** Px a hovered (non-active) card rises — the "peek". */
const PEEK_PX = 14;
/** Pre-deal deck sits this far below the fan's resting line. */
const DEAL_DROP_PX = 64;
const DEAL_STAGGER_S = 0.07;
/** Movement before a press becomes a drag; below it, it's a click. */
const DRAG_SLOP_PX = 6;
/** Release velocity (px/ms) is projected this many ms ahead for the snap. */
const FLING_PROJECTION_MS = 160;
/** Wheel gestures settle on the nearest card after this idle time. */
const WHEEL_SETTLE_MS = 140;
/** Past the first / last card the fan follows the finger at this ratio. */
const RUBBER_BAND = 0.35;

const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi);

/** Fan angle for a card `o` cards away from the (fractional) active one.
 *  Continuous in `o`, so dragging never jumps. */
function angleFor(o: number, step: number) {
  return o * step + Math.sign(o) * Math.min(Math.abs(o), 1) * step * ACTIVE_GAP;
}

/** Only the card at (or sliding into) the centre rises. */
function liftFor(o: number, lift: number) {
  return lift * Math.max(0, 1 - Math.abs(o));
}

/** Deterministic tilt per card for the stacked pre-deal deck (no
 *  Math.random — it must match between server and client). */
function deckJitter(i: number) {
  return (((i * 47) % 9) - 4) * 0.8;
}

function artBackground(image: string) {
  return /gradient\(|url\(/i.test(image)
    ? image
    : `url("${image.replace(/"/g, '\\"')}") center / cover no-repeat`;
}

/** Deterministic placeholder art per card (golden-angle hues). */
function placeholderArt(index: number) {
  const hue = Math.round((index * 137.5) % 360);
  return `radial-gradient(120% 80% at 30% 15%, hsl(${hue} 70% 62% / .85), transparent 60%), linear-gradient(160deg, hsl(${hue} 55% 34%), hsl(${(hue + 50) % 360} 60% 14%))`;
}

/**
 * Bounding box of the resting fan around the active card's centre (y down),
 * computed from props alone so the server and the first client render agree.
 * Every visible card's four corners are rotated about the pivot; the top
 * also reserves room for the lift and a hover peek.
 */
function fanBounds(
  w: number,
  h: number,
  radius: number,
  step: number,
  lift: number,
  span: number,
) {
  let minX = -w / 2;
  let maxX = w / 2;
  let minY = -h / 2 - lift - PEEK_PX;
  let maxY = h / 2 + DEAL_DROP_PX;
  for (let o = -span; o <= span; o += 1) {
    const a = (angleFor(o, step) * Math.PI) / 180;
    const cos = Math.cos(a);
    const sin = Math.sin(a);
    const l = liftFor(o, lift);
    for (const cx of [-w / 2, w / 2]) {
      for (const cy of [-h / 2, h / 2]) {
        // Corner relative to the pivot, which sits `radius` below the centre.
        const px = cx;
        const py = cy - l - radius;
        const x = px * cos - py * sin;
        const y = px * sin + py * cos + radius;
        minX = Math.min(minX, x);
        maxX = Math.max(maxX, x);
        minY = Math.min(minY, y);
        maxY = Math.max(maxY, y);
      }
    }
  }
  return { width: maxX - minX, height: maxY - minY, anchorY: -minY };
}

type DealPhase = "waiting" | "dealing" | "done";

function DefaultFace({
  item,
  state,
}: {
  item: FanCarouselItem;
  state: FanCarouselItemState;
}) {
  const pad = (n: number) => String(n).padStart(2, "0");
  return (
    <>
      <span
        className={styles.art}
        style={{
          background: item.image
            ? artBackground(item.image)
            : placeholderArt(state.index),
        }}
        role={item.alt ? "img" : undefined}
        aria-label={item.alt}
        aria-hidden={item.alt ? undefined : true}
      />
      <span className={styles.grain} aria-hidden />
      <span className={styles.frame} aria-hidden />
      <span className={styles.numeral} aria-hidden>
        {pad(state.index + 1)}
      </span>
      {(item.title || item.subtitle) && (
        <span className={styles.caption}>
          {item.subtitle && <span className={styles.subtitle}>{item.subtitle}</span>}
          {item.title && <span className={styles.title}>{item.title}</span>}
        </span>
      )}
    </>
  );
}

type FanCardProps = {
  item: FanCarouselItem;
  index: number;
  total: number;
  active: boolean;
  offset: number;
  hidden: boolean;
  position: MotionValue<number>;
  step: number;
  lift: number;
  maxVisible: number;
  phase: DealPhase;
  dealOrder: number;
  reduced: boolean;
  dragging: boolean;
  renderItem?: FanCarouselProps["renderItem"];
  onActivate: (index: number) => void;
  registerRef: (index: number, el: HTMLDivElement | null) => void;
};

/**
 * One card. Split out so each card owns its motion values (hooks can't run
 * inside `items.map`). The whole pose is ONE transform string,
 * `rotate(θ) translateY(−lift)`, with the origin at the shared pivot far
 * below the hand: rotating first and translating second makes the lift and
 * the hover peek travel along the card's own radius, so a peeking card
 * slides out of the hand instead of straight up.
 */
function FanCard({
  item,
  index,
  total,
  active,
  offset,
  hidden,
  position,
  step,
  lift,
  maxVisible,
  phase,
  dealOrder,
  reduced,
  dragging,
  renderItem,
  onActivate,
  registerRef,
}: FanCardProps) {
  const peek = useSpring(0, { stiffness: 420, damping: 30 });
  // Cards that mount after the deal (the window slid) start dealt.
  const deal = useMotionValue(phase === "done" ? 1 : 0);

  useEffect(() => {
    if (reduced) {
      deal.jump(1);
      return;
    }
    if (phase === "waiting") {
      deal.jump(0);
      return;
    }
    if (deal.get() >= 1) return;
    // Centre card first, then outward in pairs; when the phase flips to
    // "done" mid-flight the card finishes without the stagger delay.
    const controls = animate(deal, 1, {
      type: "spring",
      stiffness: 150,
      damping: 20,
      delay: phase === "dealing" ? dealOrder * DEAL_STAGGER_S : 0,
    });
    return () => controls.stop();
  }, [phase, reduced, dealOrder, deal]);

  const transform = useTransform([position, deal, peek], ([p, d, k]: number[]) => {
    const o = index - p;
    const fanned = angleFor(o, step);
    const jitter = deckJitter(index);
    const angle = jitter + (fanned - jitter) * d;
    const rise = (liftFor(o, lift) + k) * d - DEAL_DROP_PX * (1 - d);
    return `rotate(${angle.toFixed(3)}deg) translateY(${(-rise).toFixed(2)}px)`;
  });
  // Front-most = closest to the active card; finer than whole cards so the
  // order stays right mid-drag.
  const zIndex = useTransform(position, (p) => 1000 - Math.round(Math.abs(index - p) * 10));
  // The outermost rendered ring fades instead of popping in / out.
  const opacity = useTransform(position, (p) =>
    clamp(maxVisible + 1 - Math.abs(index - p), 0, 1),
  );
  const shade = useTransform(position, (p) => Math.min(Math.abs(index - p) * 0.12, 0.6));

  const setPeek = (v: number) => {
    if (reduced) peek.jump(v);
    else peek.set(v);
  };

  // Drop a stale peek when the card becomes active or a drag starts.
  useEffect(() => {
    if (active || dragging) peek.set(0);
  }, [active, dragging, peek]);

  const state: FanCarouselItemState = { index, total, active, offset };
  const title = item.title ? `:${item.title}` : "";

  return (
    <motion.div
      ref={(el) => registerRef(index, el)}
      className={styles.card}
      style={{ transform, zIndex, opacity }}
      role="group"
      aria-roledescription="スライド"
      aria-label={`${index + 1} / ${total}${title}`}
      aria-hidden={hidden || undefined}
      inert={hidden}
      tabIndex={active ? 0 : -1}
      data-active={active || undefined}
      onClick={() => onActivate(index)}
      // Peek only for real hover pointers; touch has no hover state.
      onPointerEnter={(e) => {
        if (e.pointerType === "mouse" && !active && !dragging) setPeek(PEEK_PX);
      }}
      onPointerLeave={() => setPeek(0)}
    >
      <span className={styles.glow} aria-hidden />
      <span className={styles.face}>
        {renderItem ? renderItem(item, state) : <DefaultFace item={item} state={state} />}
        <motion.span className={styles.shade} style={{ opacity: shade }} aria-hidden />
        <span className={styles.sheen} aria-hidden />
      </span>
    </motion.div>
  );
}

type DragState = {
  pointerId: number;
  startX: number;
  startPos: number;
  moved: boolean;
  samples: { x: number; t: number }[];
};

export function FanCarousel({
  items,
  index,
  defaultIndex = 0,
  onIndexChange,
  spread = DEFAULT_SPREAD,
  radius = DEFAULT_RADIUS,
  cardWidth = DEFAULT_CARD_WIDTH,
  aspect = DEFAULT_ASPECT,
  lift = DEFAULT_LIFT,
  maxVisible = DEFAULT_MAX_VISIBLE,
  dealIn = true,
  springStiffness = 260,
  springDamping = 28,
  showControls = true,
  renderItem,
  className,
  "aria-label": ariaLabel = "カードの扇",
}: FanCarouselProps) {
  const total = items.length;
  const maxIndex = Math.max(total - 1, 0);
  const clampIndex = useCallback(
    (n: number) => clamp(Math.round(n), 0, maxIndex),
    [maxIndex],
  );
  const visible = Math.max(1, Math.round(maxVisible));

  const [internalIndex, setInternalIndex] = useState(() =>
    clamp(Math.round(defaultIndex), 0, maxIndex),
  );
  const isControlled = index !== undefined;
  const current = clampIndex(isControlled ? index : internalIndex);

  const reduced = useReducedMotion() ?? false;

  // `target` is where the fan should be (written directly while dragging);
  // `position` springs after it and drives every card.
  const target = useMotionValue(current);
  const position = useSpring(target, {
    stiffness: springStiffness,
    damping: springDamping,
  });

  // Window of rendered cards follows the rounded position — React state
  // only changes when the centre card changes, never per frame.
  const [liveIndex, setLiveIndex] = useState(current);
  const liveIndexRef = useRef(current);
  useMotionValueEvent(position, "change", (p) => {
    const r = clampIndex(p);
    if (r !== liveIndexRef.current) {
      liveIndexRef.current = r;
      setLiveIndex(r);
    }
  });

  // Bumped on every commit so the resync below also runs when a controlled
  // parent rejects the change (index unchanged → the deck snaps back).
  const [settleTick, setSettleTick] = useState(0);
  const draggingRef = useRef(false);
  const [dragging, setDragging] = useState(false);

  /** Writes the fan target; under reduced motion the spring is skipped
   *  (useSpring ignores MotionConfig, so it's done by hand). */
  const moveTo = useCallback(
    (v: number) => {
      target.set(v);
      if (reduced) position.jump(v);
    },
    [target, position, reduced],
  );

  useEffect(() => {
    if (draggingRef.current) return;
    moveTo(current);
  }, [current, settleTick, moveTo]);

  const currentRef = useRef(current);
  const onIndexChangeRef = useRef(onIndexChange);
  useEffect(() => {
    currentRef.current = current;
    onIndexChangeRef.current = onIndexChange;
  });

  const [liveText, setLiveText] = useState("");

  const commit = useCallback(
    (n: number, announce = false) => {
      const next = clampIndex(n);
      if (!isControlled) setInternalIndex(next);
      setSettleTick((t) => t + 1);
      if (announce) {
        const title = items[next]?.title;
        setLiveText(`${next + 1} / ${total}${title ? `:${title}` : ""}`);
      }
      if (next !== currentRef.current) onIndexChangeRef.current?.(next);
    },
    [clampIndex, isControlled, items, total],
  );

  // ------------------------------------------------------------------
  // Geometry: fan bounds from props, fit-to-container from a ResizeObserver
  // ------------------------------------------------------------------
  const cardHeight = cardWidth * aspect;
  const span = Math.min(visible, maxIndex);
  const step = spread / Math.max(1, Math.min(maxIndex, visible * 2));
  const bounds = fanBounds(cardWidth, cardHeight, radius, step, lift, span);

  const rootRef = useRef<HTMLElement | null>(null);
  const stageRef = useRef<HTMLDivElement | null>(null);
  const [containerWidth, setContainerWidth] = useState(0);
  useEffect(() => {
    const el = rootRef.current;
    if (!el) return;
    const ro = new ResizeObserver(([entry]) => {
      const w = entry.contentRect.width;
      // Hidden tabs report 0 — keep the last good fit instead of collapsing.
      if (w > 0) setContainerWidth(w);
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);
  const fit = containerWidth > 0 ? Math.min(1, containerWidth / bounds.width) : 1;

  /** Screen px of drag that moves the fan by one card (≈ the arc between
   *  two neighbouring card centres, kept within a comfortable range). */
  const pxPerCard =
    clamp(((radius + cardHeight / 2) * step * Math.PI) / 180, 48, cardWidth) * fit;
  const pxPerCardRef = useRef(pxPerCard);
  const commitRef = useRef(commit);
  useEffect(() => {
    pxPerCardRef.current = pxPerCard;
    commitRef.current = commit;
  });

  // ------------------------------------------------------------------
  // Deal-in: stacked deck → fan, once, when the carousel scrolls into view
  // ------------------------------------------------------------------
  const [dealState, setDealState] = useState<DealPhase>(dealIn ? "waiting" : "done");
  // Re-arm when `dealIn` is switched on later (adjust-state-during-render,
  // so the deck doesn't flash fanned for a frame).
  const [prevDealIn, setPrevDealIn] = useState(dealIn);
  if (prevDealIn !== dealIn) {
    setPrevDealIn(dealIn);
    setDealState(dealIn ? "waiting" : "done");
  }
  // Reduced motion is NOT folded in here: useReducedMotion can differ
  // between the server and the first client render, and the cards' initial
  // pose must hydrate identically. Each card jumps to dealt in an effect.
  const phase: DealPhase = dealIn ? dealState : "done";

  useEffect(() => {
    if (phase !== "waiting" || reduced) return;
    const el = rootRef.current;
    if (!el) return;
    const io = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setDealState("dealing");
          io.disconnect();
        }
      },
      { threshold: 0.35 },
    );
    io.observe(el);
    return () => io.disconnect();
  }, [phase, reduced]);

  useEffect(() => {
    if (phase !== "dealing") return;
    const ms = ((visible + 1) * DEAL_STAGGER_S + 1.2) * 1000;
    const t = window.setTimeout(() => setDealState("done"), ms);
    return () => window.clearTimeout(t);
  }, [phase, visible]);

  // ------------------------------------------------------------------
  // Focus: follow the active card only when focus is already on a card
  // ------------------------------------------------------------------
  const cardEls = useRef(new Map<number, HTMLDivElement>());
  const registerRef = useCallback((i: number, el: HTMLDivElement | null) => {
    if (el) cardEls.current.set(i, el);
    else cardEls.current.delete(i);
  }, []);
  const pendingFocusRef = useRef(false);
  useEffect(() => {
    if (!pendingFocusRef.current) return;
    pendingFocusRef.current = false;
    cardEls.current.get(current)?.focus({ preventScroll: true });
  }, [current]);

  const focusIsOnCard = () => {
    const active = document.activeElement;
    for (const el of cardEls.current.values()) if (el === active) return true;
    return false;
  };

  // ------------------------------------------------------------------
  // Pointer drag (springy follow, rubber band, velocity-projected snap)
  // ------------------------------------------------------------------
  const dragRef = useRef<DragState | null>(null);
  const suppressClickRef = useRef(false);

  const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
    if (total < 2) return;
    if (e.pointerType === "mouse" && e.button !== 0) return;
    dragRef.current = {
      pointerId: e.pointerId,
      startX: e.clientX,
      // Grab the fan where it visually is, even mid-spring.
      startPos: position.get(),
      moved: false,
      samples: [{ x: e.clientX, t: e.timeStamp }],
    };
  };

  const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
    const d = dragRef.current;
    if (!d || d.pointerId !== e.pointerId) return;
    const dx = e.clientX - d.startX;
    if (!d.moved) {
      if (Math.abs(dx) < DRAG_SLOP_PX) return;
      // Capture only once it's a drag: capturing on press would retarget
      // the click to the stage and break click-to-activate.
      d.moved = true;
      draggingRef.current = true;
      setDragging(true);
      try {
        e.currentTarget.setPointerCapture(e.pointerId);
      } catch {
        /* pointer already gone */
      }
    }
    let raw = d.startPos - dx / pxPerCardRef.current;
    if (raw < 0) raw *= RUBBER_BAND;
    else if (raw > maxIndex) raw = maxIndex + (raw - maxIndex) * RUBBER_BAND;
    moveTo(raw);
    d.samples.push({ x: e.clientX, t: e.timeStamp });
    // Keep ~100ms of history for the release velocity.
    while (d.samples.length > 2 && e.timeStamp - d.samples[0].t > 100) d.samples.shift();
  };

  const endDrag = (e: ReactPointerEvent<HTMLDivElement>) => {
    const d = dragRef.current;
    if (!d || d.pointerId !== e.pointerId) return;
    dragRef.current = null;
    if (!d.moved) return;
    const first = d.samples[0];
    const last = d.samples[d.samples.length - 1];
    const dt = last.t - first.t;
    const velocity = dt > 0 ? (last.x - first.x) / dt : 0; // px/ms
    const projected =
      target.get() - (velocity * FLING_PROJECTION_MS) / pxPerCardRef.current;
    draggingRef.current = false;
    setDragging(false);
    suppressClickRef.current = true;
    window.setTimeout(() => {
      suppressClickRef.current = false;
    }, 0);
    commit(projected);
  };

  const onActivate = useCallback(
    (i: number) => {
      if (suppressClickRef.current) return;
      commit(i);
    },
    [commit],
  );

  // ------------------------------------------------------------------
  // Wheel: horizontal (trackpad / shift+wheel) only, so vertical page
  // scroll is never hijacked. Non-passive to stop the browser's
  // history-swipe on horizontal trackpad gestures.
  // ------------------------------------------------------------------
  useEffect(() => {
    const el = stageRef.current;
    if (!el || total < 2) return;
    let timer = 0;
    const onWheel = (e: WheelEvent) => {
      if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
      e.preventDefault();
      const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? el.clientWidth : 1;
      const next = clamp(
        target.get() + (e.deltaX * unit) / pxPerCardRef.current,
        -RUBBER_BAND,
        maxIndex + RUBBER_BAND,
      );
      target.set(next);
      if (reduced) position.jump(next);
      window.clearTimeout(timer);
      timer = window.setTimeout(() => commitRef.current(target.get()), WHEEL_SETTLE_MS);
    };
    el.addEventListener("wheel", onWheel, { passive: false });
    return () => {
      el.removeEventListener("wheel", onWheel);
      window.clearTimeout(timer);
    };
  }, [total, maxIndex, reduced, target, position]);

  // ------------------------------------------------------------------
  // Keyboard on the region: ← → step, Home / End jump
  // ------------------------------------------------------------------
  const onKeyDown = (e: ReactKeyboardEvent<HTMLElement>) => {
    let next: number | null = null;
    if (e.key === "ArrowLeft") next = current - 1;
    else if (e.key === "ArrowRight") next = current + 1;
    else if (e.key === "Home") next = 0;
    else if (e.key === "End") next = maxIndex;
    if (next === null) return;
    e.preventDefault();
    if (focusIsOnCard()) pendingFocusRef.current = true;
    commit(next, true);
  };

  // ------------------------------------------------------------------
  // Render
  // ------------------------------------------------------------------
  const rootStyle: CSSVars = {
    "--fc-stage-h": `${(bounds.height * fit).toFixed(2)}px`,
    "--fc-anchor-y": `${bounds.anchorY.toFixed(2)}px`,
    "--fc-fit": fit.toFixed(4),
  };
  if (cardWidth !== DEFAULT_CARD_WIDTH) rootStyle["--fc-card-w"] = `${cardWidth}px`;
  if (aspect !== DEFAULT_ASPECT) rootStyle["--fc-aspect"] = aspect;
  if (radius !== DEFAULT_RADIUS) rootStyle["--fc-radius"] = `${radius}px`;

  const pad = (n: number) => String(n).padStart(2, "0");
  const rendered: number[] = [];
  for (let i = 0; i < total; i += 1) {
    // Union of where the fan is and where it's going, so the destination
    // card exists (and can take focus) before the spring gets there.
    if (Math.abs(i - liveIndex) <= visible + 1 || Math.abs(i - current) <= visible + 1) {
      rendered.push(i);
    }
  }

  return (
    <MotionConfig reducedMotion="user">
      <section
        ref={rootRef}
        className={[styles.root, className].filter(Boolean).join(" ")}
        style={rootStyle}
        aria-roledescription="カルーセル"
        aria-label={ariaLabel}
        onKeyDown={onKeyDown}
      >
        <div
          ref={stageRef}
          className={styles.stage}
          data-dragging={dragging || undefined}
          onPointerDown={onPointerDown}
          onPointerMove={onPointerMove}
          onPointerUp={endDrag}
          onPointerCancel={endDrag}
        >
          <div className={styles.fan}>
            {rendered.map((i) => {
              const item = items[i];
              return (
                <FanCard
                  key={item.id ?? i}
                  item={item}
                  index={i}
                  total={total}
                  active={i === current}
                  offset={i - current}
                  hidden={Math.abs(i - current) > visible}
                  position={position}
                  step={step}
                  lift={lift}
                  maxVisible={visible}
                  phase={phase}
                  dealOrder={Math.abs(i - current)}
                  reduced={reduced}
                  dragging={dragging}
                  renderItem={renderItem}
                  onActivate={onActivate}
                  registerRef={registerRef}
                />
              );
            })}
          </div>
        </div>

        {showControls && total > 1 && (
          <div className={styles.controls}>
            <button
              type="button"
              className={styles.navButton}
              onClick={() => commit(current - 1, true)}
              disabled={current === 0}
              aria-label="前のカード"
            >
              <ChevronLeft size={18} strokeWidth={2} aria-hidden />
            </button>
            <span className={styles.counter} aria-hidden>
              <span className={styles.counterCurrent}>{pad(current + 1)}</span>
              <span className={styles.counterSep}>/</span>
              {pad(total)}
            </span>
            <button
              type="button"
              className={styles.navButton}
              onClick={() => commit(current + 1, true)}
              disabled={current === maxIndex}
              aria-label="次のカード"
            >
              <ChevronRight size={18} strokeWidth={2} aria-hidden />
            </button>
          </div>
        )}

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

export default FanCarousel;
```

#### `components/fan-carousel/FanCarousel.module.css`
```css
/* ==========================================================================
   Fan carousel — cards held like a hand of playing cards. Every card shares
   one pivot far below the hand; the active card rises out of it.

   framer-motion owns every card transform (fan angle, lift, peek, deal).
   This file sizes, positions and paints. Geometry vars below mirror the
   component's defaults; the component sets them inline only when a prop
   differs. --fc-stage-h / --fc-anchor-y / --fc-fit are computed from the
   props and the container width, so they're always inline.
   ========================================================================== */

.root {
  --fc-card-w: 160px;
  --fc-aspect: 1.55;
  --fc-card-h: calc(var(--fc-card-w) * var(--fc-aspect));
  /* Pivot distance from each card's centre. */
  --fc-radius: 600px;
  --fc-card-radius: 14px;
  --fc-surface: #15121a;
  /* Frame hairlines, numeral and the active card's halo. */
  --fc-accent: #f1d29a;

  position: relative;
  /* Fill the parent so the ResizeObserver measures the space available,
     not the fan's own width (flex parents would shrink-wrap it). */
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 18px;
  font-family:
    "Inter", -apple-system, BlinkMacSystemFont, "Hiragino Sans",
    "Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto,
    sans-serif;
  color: #fff;
}

/* The drag surface. Height comes from the fan's computed bounds × fit. */
.stage {
  position: relative;
  width: 100%;
  height: var(--fc-stage-h, 400px);
  cursor: grab;
  user-select: none;
  -webkit-user-select: none;
  /* Horizontal drags fan the deck; vertical swipes still scroll the page. */
  touch-action: pan-y;
  -webkit-tap-highlight-color: transparent;
}

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

/* Zero-size anchor at the stage's horizontal centre; scaling it from its
   top edge shrinks the whole hand to fit narrow containers. */
.fan {
  position: absolute;
  top: 0;
  left: 50%;
  width: 0;
  height: 0;
  transform: scale(var(--fc-fit, 1));
  transform-origin: 0 0;
}

.card {
  position: absolute;
  left: calc(var(--fc-card-w) / -2);
  top: calc(var(--fc-anchor-y, 200px) - var(--fc-card-h) / 2);
  width: var(--fc-card-w);
  height: var(--fc-card-h);
  /* The shared pivot: `radius` below the card's centre. */
  transform-origin: 50% calc(50% + var(--fc-radius));
  border-radius: var(--fc-card-radius);
  cursor: pointer;
  outline: none;
  will-change: transform;
}

.stage[data-dragging] .card {
  cursor: grabbing;
}

.card:focus-visible {
  outline: 2px solid rgba(255, 255, 255, 0.9);
  outline-offset: 4px;
}

/* Halo behind the active card — a separate layer so only its opacity
   animates (box-shadow transitions would repaint every frame). */
.glow {
  position: absolute;
  inset: -1px;
  border-radius: inherit;
  background: linear-gradient(
    160deg,
    color-mix(in srgb, var(--fc-accent) 90%, transparent),
    color-mix(in srgb, var(--fc-accent) 10%, transparent) 45%,
    color-mix(in srgb, var(--fc-accent) 70%, transparent)
  );
  box-shadow:
    0 0 28px -4px color-mix(in srgb, var(--fc-accent) 55%, transparent),
    0 30px 60px -18px rgba(0, 0, 0, 0.85);
  opacity: 0;
  transition: opacity 0.35s ease;
  pointer-events: none;
}

.card[data-active] .glow {
  opacity: 1;
}

.face {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: inherit;
  background: var(--fc-surface);
  box-shadow:
    inset 0 0 0 1px rgba(255, 255, 255, 0.1),
    inset 0 1px 0 rgba(255, 255, 255, 0.16),
    0 1px 2px rgba(0, 0, 0, 0.5),
    0 18px 36px -14px rgba(0, 0, 0, 0.75);
}

.card[data-active] .face {
  /* Inset by the halo's 1px so the gradient ring shows around the face. */
  inset: 1px;
  border-radius: calc(var(--fc-card-radius) - 1px);
}

.art {
  position: absolute;
  inset: 0;
}

/* Film grain so flat gradients read as printed card stock. */
.grain {
  position: absolute;
  inset: 0;
  opacity: 0.22;
  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");
  pointer-events: none;
}

/* Double hairline frame — the collectible-card border. */
.frame {
  position: absolute;
  inset: 7px;
  border: 1px solid color-mix(in srgb, var(--fc-accent) 55%, transparent);
  border-radius: calc(var(--fc-card-radius) - 6px);
  pointer-events: none;
}

.frame::after {
  content: "";
  position: absolute;
  inset: 3px;
  border: 1px solid color-mix(in srgb, var(--fc-accent) 20%, transparent);
  border-radius: calc(var(--fc-card-radius) - 9px);
}

.numeral {
  position: absolute;
  top: 14px;
  left: 50%;
  transform: translateX(-50%);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.24em;
  font-variant-numeric: tabular-nums;
  color: color-mix(in srgb, var(--fc-accent) 85%, transparent);
  text-shadow: 0 1px 6px rgba(0, 0, 0, 0.6);
}

.caption {
  position: absolute;
  inset: auto 0 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
  padding: 40px 12px 16px;
  text-align: center;
  background: linear-gradient(180deg, transparent, rgba(6, 4, 10, 0.82) 70%);
}

.subtitle {
  font-size: 9px;
  font-weight: 600;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: color-mix(in srgb, var(--fc-accent) 80%, transparent);
}

.title {
  font-size: 16px;
  font-weight: 650;
  letter-spacing: 0.04em;
  line-height: 1.25;
  color: #fff;
  text-wrap: balance;
}

/* Depth: cards further from the active one sink into shadow. */
.shade {
  position: absolute;
  inset: 0;
  background: #07050b;
  pointer-events: none;
}

/* Static diagonal gloss, strongest on the lifted card. */
.sheen {
  position: absolute;
  inset: 0;
  background: linear-gradient(
    125deg,
    rgba(255, 255, 255, 0.16) 0%,
    rgba(255, 255, 255, 0.03) 28%,
    transparent 46%
  );
  opacity: 0.55;
  transition: opacity 0.35s ease;
  pointer-events: none;
}

.card[data-active] .sheen {
  opacity: 1;
}

/* ---------- Controls ---------- */

.controls {
  display: flex;
  align-items: center;
  gap: 14px;
}

.navButton {
  display: grid;
  place-items: center;
  width: 38px;
  height: 38px;
  padding: 0;
  border: 0;
  border-radius: 999px;
  color: rgba(255, 255, 255, 0.85);
  background: rgba(255, 255, 255, 0.06);
  box-shadow:
    inset 0 0 0 1px rgba(255, 255, 255, 0.1),
    inset 0 1px 0 rgba(255, 255, 255, 0.08);
  backdrop-filter: blur(10px);
  cursor: pointer;
  transition:
    background-color 0.2s ease,
    color 0.2s ease,
    transform 0.15s ease;
}

.navButton:hover:not(:disabled) {
  color: #fff;
  background: rgba(255, 255, 255, 0.12);
}

.navButton:active:not(:disabled) {
  transform: scale(0.94);
}

.navButton:disabled {
  opacity: 0.35;
  cursor: default;
}

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

.counter {
  min-width: 64px;
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.14em;
  font-variant-numeric: tabular-nums;
  text-align: center;
  color: rgba(255, 255, 255, 0.45);
}

.counterCurrent {
  color: #fff;
}

.counterSep {
  margin: 0 6px;
  color: rgba(255, 255, 255, 0.25);
}

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

@media (prefers-reduced-motion: reduce) {
  .glow,
  .sheen,
  .navButton {
    transition: none;
  }
}
```

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

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

// TODO: items はプレビューと同じ仮のタロットカードです。image は CSS の background 値(グラデーション、位置・サイズ付きの url(…) の重ね)か画像 URL を渡せます。プレビューではグラデーションの上に SVG の紋章と格子模様を url("data:image/svg+xml,…") で重ねています
// TODO: 選択中のカードを受け取るには onIndexChange={(index) => …} を渡します。外から制御するなら index と組み合わせます
// TODO: カードの絵柄を自作するなら renderItem={(item, { index, total, active, offset }) => …} を渡します。扇の動き・陰影・枠の光はそのまま使われます
<FanCarousel
  cardWidth={160}
  spread={56}
  radius={600}
  lift={32}
  maxVisible={4}
  dealIn={true}
  springStiffness={260}
  springDamping={28}
  items={[
    {
      id: "fool",
      title: "愚者",
      subtitle: "0 · The Fool",
      image: "linear-gradient(165deg, #3a7bd5 0%, #1f3f7a 45%, #0d1430 100%)",
    },
    {
      id: "magician",
      title: "魔術師",
      subtitle: "I · The Magician",
      image: "linear-gradient(165deg, #c2410c 0%, #7c1d2c 50%, #2a0a16 100%)",
    },
    {
      id: "priestess",
      title: "女教皇",
      subtitle: "II · High Priestess",
      image: "linear-gradient(165deg, #4f46e5 0%, #2e1065 50%, #0f0726 100%)",
    },
    {
      id: "empress",
      title: "女帝",
      subtitle: "III · The Empress",
      image: "linear-gradient(165deg, #0f9f6e 0%, #0b5345 50%, #04201c 100%)",
    },
    {
      id: "lovers",
      title: "恋人",
      subtitle: "VI · The Lovers",
      image: "linear-gradient(165deg, #e2557b 0%, #8a1c4a 50%, #2b0718 100%)",
    },
    {
      id: "wheel",
      title: "運命の輪",
      subtitle: "X · Wheel of Fortune",
      image: "linear-gradient(165deg, #b7791f 0%, #6b3a12 50%, #1f0f06 100%)",
    },
    {
      id: "star",
      title: "星",
      subtitle: "XVII · The Star",
      image: "linear-gradient(165deg, #0ea5b7 0%, #134e6f 50%, #071827 100%)",
    },
    {
      id: "moon",
      title: "月",
      subtitle: "XVIII · The Moon",
      image: "linear-gradient(165deg, #6d5bd0 0%, #2c2a6b 50%, #0b0b24 100%)",
    },
    {
      id: "sun",
      title: "太陽",
      subtitle: "XIX · The Sun",
      image: "linear-gradient(165deg, #f59e0b 0%, #b45309 45%, #3b1505 100%)",
    },
  ]}
  defaultIndex={4}
  aria-label="タロットカード"
/>
```

### 4. 組み込み手順
1. 扇を置くコンポーネントで `<FanCarousel items={[…]} aria-label="…" />` を描画する。`onIndexChange` / `renderItem` は関数なので、渡すなら呼び出し側も `"use client"` にする(`items` だけなら Server Component から渡せる)。
2. `items` は `{ id?, title?, subtitle?, image?, alt? }` の配列。`image` は CSS の background 値(`linear-gradient(…)`、`url(…) 50% 40% / 66% auto no-repeat, linear-gradient(…)` のような重ね)をそのまま使い、それ以外は画像 URL として `center / cover` で敷く。`background` として表示されるので `next/image` の最適化は効かない。大きな画像は事前に縮小しておく。
3. ルートは親の幅いっぱい(`width: 100%`)に広がり、その幅に扇が収まらなければ自動で縮小する。高さは扇の形(`spread` / `radius` / `cardWidth` / `aspect` / `lift` / `maxVisible`)から計算されるので、親に固定の高さは要らない。
4. 外側のカードは扇の外まで回り込み、配るイントロでは山札が少し下から出てくる。**祖先に `overflow: hidden` があると切れる**ので、切りたくない場合は余白を取るか `overflow: visible` にする。
5. 選択中のカードを外から制御するなら `index` + `onIndexChange` を組み合わせる。`onIndexChange` で state を更新しないと、離した後に元のカードへ戻る(意図した挙動)。
6. 色味は CSS 変数で変える: `className` で `--fc-accent`(枠線・番号・光)、`--fc-surface`、`--fc-card-radius` を上書きする。
7. `npm run build` が通ることを確認し、「ドラッグでバネのように追従して近いカードに止まる」「カードのクリックで選択」「← / → / Home / End」「スマホで縦スクロールが妨げられない」「トラックパッドの横スクロールで送れる」を確認する。

#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| カードをクリックしても選択されない | 押した瞬間に `setPointerCapture` すると click の対象がステージに変わる | キャプチャは 6px 以上動いてドラッグになってから。ドラッグ直後の click は無視する |
| 浮き上がり・ホバーがまっすぐ上に動き、扇から外れて見える | `translateY` を `rotate` より前に書いている | `rotate(θ) translateY(−lift)` の順で 1 本の transform にし、`transform-origin` を手元の回転軸(`50% calc(50% + radius)`)にする |
| カードが回転軸の方へずれる / 小さく沈む | 遠い回転軸を基準に `scale()` している | 奥行きは `scale` ではなく暗幕(opacity)で表す |
| ページを縦にスクロールできない / ホイールが奪われる | ステージの `touch-action` を外した、または縦ホイールまで拾っている | `touch-action: pan-y` を外さない。ホイールは `|deltaX| > |deltaY|` のときだけ処理する(`passive: false` は横スワイプでの「戻る」を止めるため) |
| 視差効果オフでもバネで動く・配られる | `useSpring` と `animate(motionValue)` は `MotionConfig` の reducedMotion を見ない | `useReducedMotion()` を見て `position.jump()` で即時移動し、配る演出は最初から完了状態にする |
| ドラッグ中にカクつく | 毎フレーム React の state を更新している | 位置は MotionValue だけで動かす。state は中央のカード番号が変わったときだけ |
| サーバーとクライアントで見た目が違う(ハイドレーション警告) | 山札の傾きに `Math.random` を使った、レンダー中に `window` を読んだ | 傾きはインデックスから決め打ち。サイズ合わせは ResizeObserver(エフェクト内)で行う |

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

## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- Next.js(App Router)、React 19、TypeScript、CSS Modules、`framer-motion`(v12 以降)、`lucide-react`
- ファイル: `components/fan-carousel/FanCarousel.tsx`(`"use client"`)+ `FanCarousel.module.css` + `index.ts`
- props: `items`(`{ id?, title?, subtitle?, image?, alt? }[]`)、`index?` / `defaultIndex = 0` / `onIndexChange?`、`spread = 56`(deg、表示される左右全体の開き)、`radius = 600`(px、カード中心から回転軸まで)、`cardWidth = 160`、`aspect = 1.55`、`lift = 32`(px)、`maxVisible = 4`(片側)、`dealIn = true`、`springStiffness = 260`、`springDamping = 28`、`showControls = true`、`renderItem?(item, { index, total, active, offset })`、`className`、`aria-label = "カードの扇"`
- 状態: `target`(MotionValue。ドラッグ中は直接書く)→ `useSpring` の `position` が全カードを動かす。React の state は確定したカード番号と、中央のカード番号(丸めた position が変わったときだけ)

### 見た目
- CSS 変数(ルートが既定値、既定と違う値だけインライン): `--fc-card-w: 160px`、`--fc-aspect: 1.55`、`--fc-radius: 600px`、`--fc-card-radius: 14px`、`--fc-surface: #15121a`、`--fc-accent: #f1d29a`。`--fc-stage-h` / `--fc-anchor-y` / `--fc-fit` は計算値なので常にインライン
- 角度: 1 枚あたり `step = spread / min(枚数 − 1, maxVisible × 2)`。アクティブから o 枚離れたカードは `o × step + sign(o) × min(|o|, 1) × step × 0.45`(アクティブの両脇だけ少し隙間を開ける)
- 浮き上がり: `lift × max(0, 1 − |o|)`。ホバー中のカードは +14px(マウスのみ)
- 各カードの transform は `rotate(θ) translateY(−浮き上がり)`、`transform-origin: 50% calc(50% + var(--fc-radius))`
- 外枠: 表示中の全カードの四隅を回転軸まわりに回して境界を求め(props だけから計算)、ステージの高さにする。親の幅より広ければ `scale(fit)` で縮める
- 重なり順: アクティブに近いほど前(`1000 − round(|o| × 10)`)。奥行きはカード上の暗幕 `#07050b` の opacity `min(|o| × 0.12, 0.6)`。`maxVisible` を超える 1 枚はフェードアウト
- 既定のカード: 角丸 14px、`image` を背景に(無ければ黄金角で色相をずらしたグラデーション)、フィルムグレイン(SVG `feTurbulence`、opacity .22、overlay)、アクセント色の二重の細枠(内側 7px と 10px)、上部中央に `01` の番号、下部にグラデーションの帯と subtitle(9px・大文字・字間 .22em・アクセント色)+ title(16px / 650)、斜めの光沢
- アクティブ: アクセント色のグラデーションの縁取りと発光(別レイヤーの opacity を切り替え)、光沢を強める
- 操作: 下に丸いガラスの前 / 次ボタン(38px、端では無効)と `03 / 09` のカウンター

### モーション
- ドラッグ: ステージで pointer を拾い、6px 動いたらドラッグ開始(キャプチャ)。`target = 開始位置 − dx / 1 枚あたりの px`(隣のカード中心間の弧の長さを 48px〜カード幅に丸め、fit を掛ける)。両端の外はラバーバンド(0.35 倍)。離したら直近 100ms の速度を 160ms 先まで投影して一番近いカードに確定
- ホイール: 横方向のみ(`|deltaX| > |deltaY|`)、140ms 止まったら一番近いカードに確定
- クリックしたカードがアクティブに。キーボードは ← / → / Home / End
- 配るイントロ: `dealIn` のとき、最初は山札(各カード deterministic な ±3° の傾き、64px 下)。ルートが 35% 見えたら、アクティブのカードから外側へ 0.07s ずつずらしてバネで扇の位置へ飛んでいく(一度だけ。`dealIn` をオンにし直すと再度)
- `<MotionConfig reducedMotion="user">` で包み、さらに `useReducedMotion()` のときはバネを使わず即時移動、配る演出なし、ホバーも即時

### アクセシビリティ
- ルートは `<section aria-roledescription="カルーセル" aria-label>`。キー操作はルートで受ける
- 各カードは `role="group" aria-roledescription="スライド" aria-label="n / 全体:タイトル"`。アクティブだけ `tabIndex={0}`、他は `-1`。`maxVisible` の外のカードは `aria-hidden` + `inert`
- カードにフォーカスがあるときに矢印キーで送ったら、新しいアクティブのカードへフォーカスを移す(それ以外やマウント時は動かさない)
- ボタンは `aria-label`(前のカード / 次のカード)、`:focus-visible` は白 2px アウトライン
- 視覚的に隠した `aria-live="polite"` 領域で、ボタン・キー操作のときに「n / 全体:タイトル」を読み上げる
- `alt` があるアートは `role="img"` で読み上げ、無ければ装飾として隠す

### 受け入れ条件
- 静止状態で手札のように弧を描いて広がり、中央のカードが浮き上がってまっすぐ立つ
- ドラッグで指に吸い付くようにバネで追従し、離すと勢いを考慮して近いカードに止まる。両端では引っ張るほど重くなる
- 隣のカードをクリックするとそれが浮き上がり、ホバーでわずかに覗く(タッチ端末では覗かない)
- 画面に入ると山札から扇へ配られる。動きを減らす設定では最初から扇
- 狭い親では扇全体が縮小して収まり、スマホで縦スクロールが妨げられない
インストール
npx shadcn@latest add https://anima-js.vercel.app/r/fan-carousel.json
生成コード
import { FanCarousel } from "@/components/fan-carousel";

// TODO: items はプレビューと同じ仮のタロットカードです。image は CSS の background 値(グラデーション、位置・サイズ付きの url(…) の重ね)か画像 URL を渡せます。プレビューではグラデーションの上に SVG の紋章と格子模様を url("data:image/svg+xml,…") で重ねています
// TODO: 選択中のカードを受け取るには onIndexChange={(index) => …} を渡します。外から制御するなら index と組み合わせます
// TODO: カードの絵柄を自作するなら renderItem={(item, { index, total, active, offset }) => …} を渡します。扇の動き・陰影・枠の光はそのまま使われます
<FanCarousel
  cardWidth={160}
  spread={56}
  radius={600}
  lift={32}
  maxVisible={4}
  dealIn={true}
  springStiffness={260}
  springDamping={28}
  items={[
    {
      id: "fool",
      title: "愚者",
      subtitle: "0 · The Fool",
      image: "linear-gradient(165deg, #3a7bd5 0%, #1f3f7a 45%, #0d1430 100%)",
    },
    {
      id: "magician",
      title: "魔術師",
      subtitle: "I · The Magician",
      image: "linear-gradient(165deg, #c2410c 0%, #7c1d2c 50%, #2a0a16 100%)",
    },
    {
      id: "priestess",
      title: "女教皇",
      subtitle: "II · High Priestess",
      image: "linear-gradient(165deg, #4f46e5 0%, #2e1065 50%, #0f0726 100%)",
    },
    {
      id: "empress",
      title: "女帝",
      subtitle: "III · The Empress",
      image: "linear-gradient(165deg, #0f9f6e 0%, #0b5345 50%, #04201c 100%)",
    },
    {
      id: "lovers",
      title: "恋人",
      subtitle: "VI · The Lovers",
      image: "linear-gradient(165deg, #e2557b 0%, #8a1c4a 50%, #2b0718 100%)",
    },
    {
      id: "wheel",
      title: "運命の輪",
      subtitle: "X · Wheel of Fortune",
      image: "linear-gradient(165deg, #b7791f 0%, #6b3a12 50%, #1f0f06 100%)",
    },
    {
      id: "star",
      title: "星",
      subtitle: "XVII · The Star",
      image: "linear-gradient(165deg, #0ea5b7 0%, #134e6f 50%, #071827 100%)",
    },
    {
      id: "moon",
      title: "月",
      subtitle: "XVIII · The Moon",
      image: "linear-gradient(165deg, #6d5bd0 0%, #2c2a6b 50%, #0b0b24 100%)",
    },
    {
      id: "sun",
      title: "太陽",
      subtitle: "XIX · The Sun",
      image: "linear-gradient(165deg, #f59e0b 0%, #b45309 45%, #3b1505 100%)",
    },
  ]}
  defaultIndex={4}
  aria-label="タロットカード"
/>