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

ThumbnailCarousel

Motion

EC の商品ページ / ギャラリー向けのサムネイル付きビューア — 大きなメイン画像(スライド / フェード / ズーム切り替え、スワイプ操作、マウス位置を中心にしたホバー拡大)と、選択中のリングが滑るように移動するサムネイル列。サムネイルはタブとしてキーボード操作できます。framer-motion。

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

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

## コンポーネントについて
- 名前: ThumbnailCarousel
- 説明: EC の商品ページ / ギャラリー向けのサムネイル付きビューア — 大きなメイン画像(スライド / フェード / ズーム切り替え、スワイプ操作、マウス位置を中心にしたホバー拡大)と、選択中のリングが滑るように移動するサムネイル列。サムネイルはタブとしてキーボード操作できます。framer-motion。
- 実行時に必要な npm パッケージ: framer-motion, lucide-react
- 構成ファイル: `components/thumbnail-carousel/index.tsx`, `components/thumbnail-carousel/ThumbnailCarousel.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/thumbnail-carousel/index.tsx`
```tsx
"use client";

import {
  useCallback,
  useEffect,
  useId,
  useRef,
  useState,
  type CSSProperties,
  type FocusEvent,
  type KeyboardEvent,
  type PointerEvent as ReactPointerEvent,
  type ReactNode,
} from "react";
import {
  AnimatePresence,
  MotionConfig,
  motion,
  useIsPresent,
  useReducedMotion,
  type PanInfo,
  type Variants,
} from "framer-motion";
import { ChevronLeft, ChevronRight } from "lucide-react";
import styles from "./ThumbnailCarousel.module.css";

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

export type ThumbnailCarouselItem = {
  id?: string;
  /** Caption title on the main stage; also the thumbnail's accessible name. */
  title?: string;
  subtitle?: string;
  /**
   * A URL (rendered as an <img>) or any CSS background value — gradients,
   * `url(...)`, several comma-separated layers. Omitted → generated art.
   */
  image?: string;
  /** Alt text for the image. Falls back to `title`. */
  alt?: string;
};

export type ThumbnailCarouselTransition = "slide" | "fade" | "zoom";

export type ThumbnailCarouselItemState = {
  index: number;
  total: number;
  /** Main stage: false while the slide is animating out. Thumb: selected. */
  active: boolean;
};

export type ThumbnailCarouselProps = {
  items: readonly ThumbnailCarouselItem[];
  /** Controlled active index. Pair with `onIndexChange`. */
  index?: number;
  /** Initial index when uncontrolled. */
  defaultIndex?: number;
  onIndexChange?: (index: number) => void;
  /** How the main image changes. */
  transition?: ThumbnailCarouselTransition;
  /** Thumbnail strip under the stage, or a vertical column to its left. */
  thumbPosition?: "bottom" | "left";
  /** Thumbnail edge length in px (thumbs are square). */
  thumbSize?: number;
  /** "03 / 06" badge in the stage corner. */
  showCounter?: boolean;
  /** Desktop (mouse) only: magnify the image under the pointer. */
  zoomOnHover?: boolean;
  /** Magnification of the hover zoom. */
  zoomScale?: number;
  /** Prev / next / swipe wrap around at the ends. */
  loop?: boolean;
  /**
   * Advance every `autoPlayInterval` ms. Pauses on hover, focus, offscreen
   * and hidden tabs; never runs under prefers-reduced-motion.
   */
  autoPlay?: boolean;
  autoPlayInterval?: number;
  /**
   * Replaces the main image. It sits inside the zoom layer, so the hover
   * zoom magnifies it; the title / subtitle caption still renders on top.
   */
  renderItem?: (item: ThumbnailCarouselItem, state: ThumbnailCarouselItemState) => ReactNode;
  /** Replaces a thumbnail's content; the frame and active ring stay. */
  renderThumb?: (item: ThumbnailCarouselItem, state: ThumbnailCarouselItemState) => ReactNode;
  className?: string;
  "aria-label"?: string;
};

/** Mirror the stylesheet defaults; vars are only set inline when they differ. */
const DEFAULT_THUMB_SIZE = 64;
const DEFAULT_ZOOM_SCALE = 2;
const DEFAULT_INTERVAL = 5000;
/**
 * A release navigates when offset + velocity × this projects past
 * SWIPE_RATIO of the stage width — a short flick counts like a long drag.
 */
const SWIPE_PROJECTION = 0.2;
const SWIPE_RATIO = 0.22;
/** Rubber band on a side that can't navigate (loop off, at an end). */
const EDGE_ELASTIC = 0.12;
const FREE_ELASTIC = 0.9;

const SLIDE_SPRING = { type: "spring", stiffness: 300, damping: 34, mass: 0.9 } as const;
const RING_SPRING = { type: "spring", stiffness: 520, damping: 40 } as const;
const EASE_OUT = [0.22, 1, 0.36, 1] as const;

const mod = (n: number, m: number) => ((n % m) + m) % m;
const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n));
const pad = (n: number) => String(n).padStart(2, "0");

/** Deterministic fallback art per index (golden-angle hues). */
function fallbackArt(index: number): string {
  const hue = Math.round((index * 137.5 + 220) % 360);
  const hue2 = (hue + 48) % 360;
  return `radial-gradient(90% 70% at 50% 35%, hsl(${hue} 70% 62% / 0.85), transparent 65%), linear-gradient(160deg, hsl(${hue} 55% 34%), hsl(${hue2} 60% 12%))`;
}

/**
 * Anything that already reads as a CSS background value is used verbatim;
 * everything else is treated as an image URL.
 */
function isCssBackground(value: string): boolean {
  return (
    value.includes("gradient(") ||
    /^(url\(|#|rgb|hsl|oklch|oklab|lab\(|lch\(|color\(|var\()/i.test(value.trim())
  );
}

/**
 * +1 = forward. Plain index order, except a wrap between the two ends
 * (last → first via "next") still reads as forward when looping.
 */
function stepDirection(from: number, to: number, total: number, loop: boolean): 1 | -1 {
  const delta = to - from;
  if (loop && total > 2 && Math.abs(delta) === total - 1) return delta > 0 ? -1 : 1;
  return delta >= 0 ? 1 : -1;
}

function slideVariants(transition: ThumbnailCarouselTransition): Variants {
  switch (transition) {
    case "fade":
      return {
        enter: { opacity: 0, x: 0, scale: 1 },
        center: { opacity: 1, x: 0, scale: 1, transition: { duration: 0.4, ease: EASE_OUT } },
        exit: { opacity: 0, transition: { duration: 0.4, ease: EASE_OUT } },
      };
    case "zoom":
      return {
        enter: { opacity: 0, x: 0, scale: 1.14 },
        center: {
          opacity: 1,
          x: 0,
          scale: 1,
          transition: { opacity: { duration: 0.3 }, scale: { duration: 0.6, ease: EASE_OUT } },
        },
        exit: { opacity: 0, scale: 0.94, transition: { duration: 0.32, ease: EASE_OUT } },
      };
    default:
      return {
        enter: (dir: number) => ({ x: `${dir * 100}%`, opacity: 1, scale: 1 }),
        center: { x: 0, opacity: 1, scale: 1, transition: { x: SLIDE_SPRING } },
        // The outgoing slide dims a little so the incoming one leads.
        exit: (dir: number) => ({
          x: `${dir * -100}%`,
          opacity: 0.4,
          transition: { x: SLIDE_SPRING, opacity: { duration: 0.35 } },
        }),
      };
  }
}

/** The default main image / thumbnail art: <img> for URLs, a div otherwise. */
function DefaultMedia({
  item,
  index,
  label,
}: {
  item: ThumbnailCarouselItem;
  index: number;
  /** Accessible name; omitted for decorative copies (thumbnails). */
  label?: string;
}) {
  const image = item.image?.trim();
  if (image && !isCssBackground(image)) {
    return (
      // Plain <img>: registry components stay framework-agnostic,
      // so next/image is not an option here.
      // eslint-disable-next-line @next/next/no-img-element
      <img
        className={styles.media}
        src={image}
        alt={label ?? ""}
        draggable={false}
        decoding="async"
      />
    );
  }
  return (
    <div
      className={styles.media}
      style={{ background: image || fallbackArt(index) }}
      role={label ? "img" : undefined}
      aria-label={label}
    />
  );
}

type SlideProps = {
  item: ThumbnailCarouselItem;
  index: number;
  total: number;
  direction: number;
  variants: Variants;
  canPrev: boolean;
  canNext: boolean;
  onSwipe: (info: PanInfo) => void;
  renderItem?: ThumbnailCarouselProps["renderItem"];
};

/**
 * One main-stage slide. Split out so it can ask `useIsPresent`: while
 * AnimatePresence plays its exit, the old slide is hidden from assistive
 * tech, made inert and can no longer be dragged.
 */
function Slide({
  item,
  index,
  total,
  direction,
  variants,
  canPrev,
  canNext,
  onSwipe,
  renderItem,
}: SlideProps) {
  const isPresent = useIsPresent();
  const label = item.alt ?? item.title;
  return (
    <motion.div
      className={styles.slide}
      role="group"
      aria-roledescription="スライド"
      aria-label={`${index + 1} / ${total}`}
      aria-hidden={isPresent ? undefined : true}
      inert={!isPresent}
      custom={direction}
      variants={variants}
      initial="enter"
      animate="center"
      exit="exit"
      drag={isPresent && total > 1 ? "x" : false}
      dragConstraints={{ left: 0, right: 0 }}
      // Full rubber band toward a neighbour, a stiff one at a dead end.
      dragElastic={{
        left: canNext ? FREE_ELASTIC : EDGE_ELASTIC,
        right: canPrev ? FREE_ELASTIC : EDGE_ELASTIC,
      }}
      dragMomentum={false}
      onDragEnd={(_, info) => onSwipe(info)}
    >
      <div className={styles.zoomLayer}>
        {renderItem ? (
          renderItem(item, { index, total, active: isPresent })
        ) : (
          <DefaultMedia item={item} index={index} label={label} />
        )}
      </div>
      {(item.title || item.subtitle) && (
        <div className={styles.caption}>
          {item.title && <span className={styles.title}>{item.title}</span>}
          {item.subtitle && <span className={styles.subtitle}>{item.subtitle}</span>}
        </div>
      )}
    </motion.div>
  );
}

/**
 * E-commerce / gallery viewer: a large main stage (slide, fade or zoom
 * transitions, swipe to navigate, optional hover-zoom lens) driven by a
 * thumbnail strip whose active ring glides between thumbs. The strip is
 * a tablist controlling the stage tabpanel, with roving focus.
 */
export function ThumbnailCarousel({
  items,
  index,
  defaultIndex = 0,
  onIndexChange,
  transition = "slide",
  thumbPosition = "bottom",
  thumbSize = DEFAULT_THUMB_SIZE,
  showCounter = true,
  zoomOnHover = true,
  zoomScale = DEFAULT_ZOOM_SCALE,
  loop = true,
  autoPlay = false,
  autoPlayInterval = DEFAULT_INTERVAL,
  renderItem,
  renderThumb,
  className,
  "aria-label": ariaLabel = "画像ギャラリー",
}: ThumbnailCarouselProps) {
  const baseId = useId();
  const panelId = `${baseId}-panel`;
  const tabId = (i: number) => `${baseId}-tab-${i}`;
  const total = items.length;
  const vertical = thumbPosition === "left";
  const reduceMotion = useReducedMotion() ?? false;

  const rootRef = useRef<HTMLElement>(null);
  const stageRef = useRef<HTMLDivElement>(null);
  const stripRef = useRef<HTMLDivElement>(null);
  const thumbRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const [innerIndex, setInnerIndex] = useState(defaultIndex);
  const isControlled = index !== undefined;
  const current = total ? clamp(Math.round(isControlled ? index : innerIndex), 0, total - 1) : 0;

  // Slide direction is derived from the previous index kept in state and
  // adjusted during render (React's "store info from previous renders"
  // pattern) — a ref written in goTo() can't be read while rendering.
  const [prevIndex, setPrevIndex] = useState(current);
  const [direction, setDirection] = useState<1 | -1>(1);
  if (prevIndex !== current) {
    setPrevIndex(current);
    setDirection(stepDirection(prevIndex, current, total, loop));
  }

  // Only user-driven changes (buttons, swipe, stage keys) are announced;
  // thumbs announce themselves through focus, autoplay stays silent.
  const [announce, setAnnounce] = useState(false);

  const goTo = useCallback(
    (target: number, announceChange: boolean) => {
      if (!total) return;
      const next = loop ? mod(target, total) : clamp(target, 0, total - 1);
      if (next === current) return;
      if (!isControlled) setInnerIndex(next);
      setAnnounce(announceChange);
      onIndexChange?.(next);
    },
    [total, loop, current, isControlled, onIndexChange],
  );

  const canPrev = loop ? total > 1 : current > 0;
  const canNext = loop ? total > 1 : current < total - 1;

  const handleSwipe = useCallback(
    (info: PanInfo) => {
      const width = stageRef.current?.offsetWidth || 1;
      const projected = info.offset.x + info.velocity.x * SWIPE_PROJECTION;
      if (projected < -width * SWIPE_RATIO) goTo(current + 1, true);
      else if (projected > width * SWIPE_RATIO) goTo(current - 1, true);
    },
    [current, goTo],
  );

  // ------------------------------------------------------------------
  // Keep the active thumb centred in the strip. scrollTo on the strip
  // only — scrollIntoView would also scroll the page around it. The first
  // run jumps so a non-zero defaultIndex starts in view.
  // ------------------------------------------------------------------
  const didScrollRef = useRef(false);
  useEffect(() => {
    const strip = stripRef.current;
    const thumb = thumbRefs.current[current];
    if (!strip || !thumb) return;
    const behavior: ScrollBehavior =
      didScrollRef.current && !reduceMotion ? "smooth" : "auto";
    didScrollRef.current = true;
    if (vertical) {
      strip.scrollTo({
        top: thumb.offsetTop - (strip.clientHeight - thumb.offsetHeight) / 2,
        behavior,
      });
    } else {
      strip.scrollTo({
        left: thumb.offsetLeft - (strip.clientWidth - thumb.offsetWidth) / 2,
        behavior,
      });
    }
  }, [current, vertical, reduceMotion, total]);

  // ------------------------------------------------------------------
  // Hover zoom. Pointer coords go to a ref; one rAF per frame turns them
  // into a transform-origin on CSS variables. React state only flips the
  // zoomed flag on enter / leave, never per frame.
  // ------------------------------------------------------------------
  const [zoomed, setZoomed] = useState(false);
  const pointerRef = useRef({ x: 0, y: 0 });
  const zoomFrameRef = useRef(0);

  useEffect(() => () => cancelAnimationFrame(zoomFrameRef.current), []);

  const applyZoomOrigin = useCallback(() => {
    zoomFrameRef.current = 0;
    const stage = stageRef.current;
    if (!stage) return;
    // Ratios, not px: stays correct inside a transformed (scaled) ancestor.
    const rect = stage.getBoundingClientRect();
    const x = clamp(((pointerRef.current.x - rect.left) / rect.width) * 100, 0, 100);
    const y = clamp(((pointerRef.current.y - rect.top) / rect.height) * 100, 0, 100);
    stage.style.setProperty("--tc-zoom-x", `${x}%`);
    stage.style.setProperty("--tc-zoom-y", `${y}%`);
  }, []);

  const onStagePointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
    // Touch and pen never zoom; a pressed button means a drag is underway.
    if (!zoomOnHover || e.pointerType !== "mouse" || e.buttons !== 0) return;
    // The nav buttons sit over the image — no lens under them.
    if ((e.target as Element).closest("button")) {
      if (zoomed) setZoomed(false);
      return;
    }
    pointerRef.current = { x: e.clientX, y: e.clientY };
    if (!zoomFrameRef.current) {
      zoomFrameRef.current = requestAnimationFrame(applyZoomOrigin);
    }
    if (!zoomed) setZoomed(true);
  };

  // A press starts a swipe; the lens and the drag would fight.
  const onStagePointerDown = () => {
    if (zoomed) setZoomed(false);
  };
  const onStagePointerLeave = () => {
    if (zoomed) setZoomed(false);
  };

  // ------------------------------------------------------------------
  // Keyboard
  // ------------------------------------------------------------------
  const onStageKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.target !== e.currentTarget) return; // let the nav buttons be
    let target: number | null = null;
    if (e.key === "ArrowLeft") target = current - 1;
    else if (e.key === "ArrowRight") target = current + 1;
    else if (e.key === "Home") target = 0;
    else if (e.key === "End") target = total - 1;
    if (target === null) return;
    e.preventDefault();
    goTo(target, true);
  };

  // Tabs follow the APG pattern: arrows always wrap (regardless of `loop`,
  // which governs prev / next / swipe), and selection follows focus.
  const onThumbKeyDown = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
    const prevKey = vertical ? "ArrowUp" : "ArrowLeft";
    const nextKey = vertical ? "ArrowDown" : "ArrowRight";
    let target: number | null = null;
    if (e.key === prevKey) target = i - 1;
    else if (e.key === nextKey) target = i + 1;
    else if (e.key === "Home") target = 0;
    else if (e.key === "End") target = total - 1;
    if (target === null) return;
    e.preventDefault();
    const next = mod(target, total);
    goTo(next, false);
    thumbRefs.current[next]?.focus({ preventScroll: true });
  };

  // ------------------------------------------------------------------
  // Autoplay: pauses on hover, focus within, offscreen and hidden tabs.
  // ------------------------------------------------------------------
  const [hovered, setHovered] = useState(false);
  const [focusWithin, setFocusWithin] = useState(false);
  const [inView, setInView] = useState(true);
  const [pageVisible, setPageVisible] = useState(true);

  useEffect(() => {
    const root = rootRef.current;
    if (!autoPlay || !root) return;
    const io = new IntersectionObserver(([entry]) => setInView(entry.isIntersecting));
    io.observe(root);
    const onVisibility = () => setPageVisible(!document.hidden);
    document.addEventListener("visibilitychange", onVisibility);
    return () => {
      io.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, [autoPlay]);

  const playing =
    autoPlay &&
    !reduceMotion &&
    total > 1 &&
    (loop || current < total - 1) &&
    !hovered &&
    !focusWithin &&
    inView &&
    pageVisible;

  useEffect(() => {
    if (!playing) return;
    const timer = window.setTimeout(
      () => goTo(current + 1, false),
      Math.max(1000, autoPlayInterval),
    );
    return () => window.clearTimeout(timer);
  }, [playing, current, autoPlayInterval, goTo]);

  const onRootBlur = (e: FocusEvent<HTMLElement>) => {
    if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocusWithin(false);
  };

  // ------------------------------------------------------------------
  // Render
  // ------------------------------------------------------------------
  const vars: CSSVars = {};
  if (thumbSize !== DEFAULT_THUMB_SIZE) vars["--tc-thumb"] = `${thumbSize}px`;
  if (zoomScale !== DEFAULT_ZOOM_SCALE) vars["--tc-zoom-scale"] = zoomScale;

  const variants = slideVariants(transition);
  const activeItem = items[current];
  const ringId = `${baseId}-ring`;

  return (
    <MotionConfig reducedMotion="user">
      <section
        ref={rootRef}
        role="region"
        aria-roledescription="カルーセル"
        aria-label={ariaLabel}
        className={[styles.root, className].filter(Boolean).join(" ")}
        style={vars}
        data-thumbs={vertical ? "left" : "bottom"}
        onPointerEnter={() => setHovered(true)}
        onPointerLeave={() => setHovered(false)}
        onFocus={() => setFocusWithin(true)}
        onBlur={onRootBlur}
      >
        {/* Tablist first in the DOM (APG order); CSS places it visually. */}
        <motion.div
          ref={stripRef}
          layoutScroll
          role="tablist"
          aria-label="サムネイル"
          aria-orientation={vertical ? "vertical" : "horizontal"}
          className={styles.strip}
        >
          {items.map((item, i) => {
            const selected = i === current;
            const name = item.alt ?? item.title ?? "画像";
            return (
              <button
                key={item.id ?? i}
                ref={(el) => {
                  thumbRefs.current[i] = el;
                }}
                type="button"
                role="tab"
                id={tabId(i)}
                aria-selected={selected}
                aria-controls={panelId}
                aria-label={`${name}(${i + 1} / ${total})`}
                tabIndex={selected ? 0 : -1}
                className={styles.thumb}
                onClick={() => goTo(i, false)}
                onKeyDown={(e) => onThumbKeyDown(e, i)}
              >
                <span className={styles.thumbMedia} aria-hidden>
                  {renderThumb ? (
                    renderThumb(item, { index: i, total, active: selected })
                  ) : (
                    <DefaultMedia item={item} index={i} />
                  )}
                </span>
                {selected && (
                  <motion.span
                    layoutId={ringId}
                    className={styles.ring}
                    transition={RING_SPRING}
                    aria-hidden
                  />
                )}
              </button>
            );
          })}
        </motion.div>

        <div
          ref={stageRef}
          role="tabpanel"
          id={panelId}
          aria-labelledby={total ? tabId(current) : undefined}
          tabIndex={0}
          className={styles.stage}
          data-zoomable={zoomOnHover || undefined}
          data-zoomed={zoomed || undefined}
          onKeyDown={onStageKeyDown}
          onPointerMove={onStagePointerMove}
          onPointerDown={onStagePointerDown}
          onPointerLeave={onStagePointerLeave}
        >
          <AnimatePresence initial={false} custom={direction}>
            {activeItem && (
              <Slide
                key={current}
                item={activeItem}
                index={current}
                total={total}
                direction={direction}
                variants={variants}
                canPrev={canPrev}
                canNext={canNext}
                onSwipe={handleSwipe}
                renderItem={renderItem}
              />
            )}
          </AnimatePresence>

          {showCounter && total > 0 && (
            <span className={styles.counter} aria-hidden>
              {pad(current + 1)}
              <span className={styles.counterTotal}> / {pad(total)}</span>
            </span>
          )}

          {total > 1 && (
            <>
              <button
                type="button"
                className={`${styles.nav} ${styles.prev}`}
                aria-label="前の画像"
                disabled={!canPrev}
                onClick={() => goTo(current - 1, true)}
              >
                <ChevronLeft size={18} strokeWidth={2.2} aria-hidden />
              </button>
              <button
                type="button"
                className={`${styles.nav} ${styles.next}`}
                aria-label="次の画像"
                disabled={!canNext}
                onClick={() => goTo(current + 1, true)}
              >
                <ChevronRight size={18} strokeWidth={2.2} aria-hidden />
              </button>
            </>
          )}
        </div>

        <div className={styles.srOnly} aria-live="polite" aria-atomic="true">
          {announce && activeItem
            ? `${current + 1} / ${total}${activeItem.title ? `:${activeItem.title}` : ""}`
            : ""}
        </div>
      </section>
    </MotionConfig>
  );
}

export default ThumbnailCarousel;
```

#### `components/thumbnail-carousel/ThumbnailCarousel.module.css`
```css
/* ==========================================================================
   Thumbnail carousel — a product / gallery viewer. One large stage with
   the current image and a thumbnail strip (under it, or a column to its
   left) whose active ring glides between thumbs.

   framer-motion owns the slide transforms and the ring's glide. This file
   sizes, paints, and runs the hover-zoom lens (a CSS transform whose
   origin the component writes to --tc-zoom-x / --tc-zoom-y).
   ========================================================================== */

.root {
  /* The component only sets these inline for non-default values, so the
     default render is exactly this stylesheet. */
  --tc-thumb: 64px;
  --tc-zoom-scale: 2;
  /* Override from CSS / className to reshape the stage or retint. */
  --tc-aspect: 4 / 3;
  --tc-gap: 12px;
  --tc-radius: 20px;
  --tc-thumb-radius: 12px;
  --tc-surface: #111114;
  --tc-accent: #f5f5f7;
  /* Room around the thumbs so the ring and its glow aren't clipped by
     the strip's scroll box. */
  --tc-strip-pad: 6px;

  display: flex;
  flex-direction: column;
  gap: calc(var(--tc-gap) - var(--tc-strip-pad));
  width: 100%;
  min-width: 0;
  font-family:
    "Inter", -apple-system, BlinkMacSystemFont, "Hiragino Sans",
    "Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto,
    sans-serif;
  color: #f5f5f7;
  -webkit-tap-highlight-color: transparent;
}

/* Left column: the strip is first in the DOM, so the grid's natural
   order already puts it left of the stage. */
.root[data-thumbs="left"] {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  align-items: stretch;
}

/* --------------------------------------------------------------------------
   Stage
   -------------------------------------------------------------------------- */

.stage {
  position: relative;
  width: 100%;
  aspect-ratio: var(--tc-aspect);
  overflow: hidden;
  isolation: isolate;
  border-radius: var(--tc-radius);
  background:
    radial-gradient(120% 90% at 50% 0%, rgba(255, 255, 255, 0.06), transparent 60%),
    var(--tc-surface);
  box-shadow:
    inset 0 0 0 1px rgba(255, 255, 255, 0.08),
    0 1px 2px rgba(0, 0, 0, 0.4),
    0 30px 60px -24px rgba(0, 0, 0, 0.7);
  outline: none;
  user-select: none;
  -webkit-user-select: none;
  /* Horizontal swipes are ours; vertical swipes still scroll the page. */
  touch-action: pan-y;
}

/* A hairline highlight on top of the slides (they'd cover an inset shadow
   on the stage itself). */
.stage::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  border-radius: inherit;
  box-shadow:
    inset 0 0 0 1px rgba(255, 255, 255, 0.08),
    inset 0 1px 0 rgba(255, 255, 255, 0.1);
  pointer-events: none;
}

.stage:focus-visible::after {
  box-shadow:
    inset 0 0 0 2px var(--tc-accent),
    inset 0 1px 0 rgba(255, 255, 255, 0.1);
}

/* The strip is first in the DOM (tablist before tabpanel); in the bottom
   layout it moves under the stage visually. */
.root[data-thumbs="bottom"] .strip {
  order: 1;
}

.slide {
  position: absolute;
  inset: 0;
  cursor: grab;
  touch-action: pan-y;
}

.slide:active {
  cursor: grabbing;
}

.stage[data-zoomable] .slide {
  cursor: zoom-in;
}

.stage[data-zoomed] .slide {
  cursor: crosshair;
}

/* The lens: a plain CSS scale around the pointer. Separate from the slide
   so it never fights framer's transform on the slide itself. */
.zoomLayer {
  position: absolute;
  inset: 0;
  overflow: hidden;
  transform-origin: var(--tc-zoom-x, 50%) var(--tc-zoom-y, 50%);
  transition: transform 380ms cubic-bezier(0.22, 1, 0.36, 1);
}

.stage[data-zoomed] .zoomLayer {
  transform: scale(var(--tc-zoom-scale));
}

.media {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  background-position: center;
  background-size: cover;
  pointer-events: none;
}

/* Title / subtitle over a soft bottom scrim. Outside the zoom layer, so
   it stays crisp; it fades away while the lens is active. */
.caption {
  position: absolute;
  inset: auto 0 0;
  display: flex;
  flex-direction: column;
  gap: 3px;
  padding: 56px 20px 18px;
  background: linear-gradient(to top, rgba(6, 6, 8, 0.62), rgba(6, 6, 8, 0));
  pointer-events: none;
  transition: opacity 240ms ease;
}

.stage[data-zoomed] .caption {
  opacity: 0;
}

.title {
  font-size: 15px;
  font-weight: 600;
  line-height: 1.3;
  letter-spacing: -0.01em;
}

.subtitle {
  font-size: 12.5px;
  line-height: 1.4;
  color: rgba(245, 245, 247, 0.62);
}

.counter {
  position: absolute;
  top: 14px;
  right: 14px;
  z-index: 2;
  padding: 5px 10px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 500;
  line-height: 1.2;
  letter-spacing: 0.04em;
  font-variant-numeric: tabular-nums;
  color: rgba(255, 255, 255, 0.92);
  background: rgba(12, 12, 14, 0.5);
  backdrop-filter: blur(12px) saturate(140%);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
  pointer-events: none;
  transition: opacity 240ms ease;
}

.counterTotal {
  color: rgba(255, 255, 255, 0.46);
}

.stage[data-zoomed] .counter {
  opacity: 0;
}

/* Prev / next: glass circles that surface on hover / focus. */
.nav {
  position: absolute;
  top: 50%;
  z-index: 2;
  display: grid;
  place-items: center;
  width: 38px;
  height: 38px;
  margin-top: -19px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  color: #fff;
  background: rgba(12, 12, 14, 0.48);
  backdrop-filter: blur(12px) saturate(140%);
  box-shadow:
    inset 0 0 0 1px rgba(255, 255, 255, 0.14),
    inset 0 1px 0 rgba(255, 255, 255, 0.16),
    0 8px 24px rgba(0, 0, 0, 0.35);
  cursor: pointer;
  opacity: 0;
  transform: scale(0.9);
  transition:
    opacity 200ms ease,
    transform 200ms ease,
    background-color 200ms ease;
}

.prev {
  left: 12px;
}

.next {
  right: 12px;
}

.stage:hover .nav,
.stage:focus-within .nav {
  opacity: 1;
  transform: none;
}

.nav:hover {
  background: rgba(32, 32, 36, 0.72);
}

.nav:active {
  transform: scale(0.94);
}

.nav:focus-visible {
  outline: 2px solid var(--tc-accent);
  outline-offset: 2px;
}

/* No hover on touch: keep the buttons visible. */
@media (hover: none) {
  .nav {
    opacity: 1;
    transform: none;
  }
}

/* After the hover / touch rules on purpose: a dead end stays hidden. */
.stage .nav:disabled {
  opacity: 0;
  pointer-events: none;
}

/* --------------------------------------------------------------------------
   Thumbnail strip
   -------------------------------------------------------------------------- */

.strip {
  /* offsetLeft / offsetTop of the thumbs are measured against this. */
  position: relative;
  display: flex;
  gap: 10px;
  min-width: 0;
  padding: var(--tc-strip-pad);
  overflow-x: auto;
  overflow-y: hidden;
  overscroll-behavior: contain;
  scrollbar-width: none;
}

.strip::-webkit-scrollbar {
  display: none;
}

/* Centred while it fits, scrollable from the first thumb once it doesn't
   (justify-content: center would cut off the start of an overflowing row). */
.root[data-thumbs="bottom"] .thumb:first-child {
  margin-inline-start: auto;
}

.root[data-thumbs="bottom"] .thumb:last-child {
  margin-inline-end: auto;
}

/* The column must not drive the row height (six thumbs would stretch the
   stage): size containment zeroes its intrinsic size, the grid stretches
   it to the stage's height, and it scrolls inside that. */
.root[data-thumbs="left"] .strip {
  flex-direction: column;
  width: calc(var(--tc-thumb) + var(--tc-strip-pad) * 2);
  overflow-x: hidden;
  overflow-y: auto;
  contain: size;
}

.thumb {
  position: relative;
  flex: none;
  width: var(--tc-thumb);
  height: var(--tc-thumb);
  padding: 0;
  border: 0;
  border-radius: var(--tc-thumb-radius);
  color: inherit;
  background: none;
  cursor: pointer;
  outline: none;
}

.thumbMedia {
  position: absolute;
  inset: 0;
  overflow: hidden;
  border-radius: inherit;
  background: var(--tc-surface);
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
  opacity: 0.5;
  filter: saturate(0.75);
  transition:
    opacity 240ms ease,
    filter 240ms ease,
    transform 200ms ease;
}

/* Sits over custom thumb content too (renderThumb). */
.thumbMedia::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}

.thumb:hover .thumbMedia {
  opacity: 0.85;
}

.thumb[aria-selected="true"] .thumbMedia {
  opacity: 1;
  filter: none;
}

.thumb:active .thumbMedia {
  transform: scale(0.95);
}

/* Inside the thumb: an outline outside it would be clipped by the strip. */
.thumb:focus-visible .thumbMedia {
  opacity: 1;
}

.thumb:focus-visible .thumbMedia::after {
  box-shadow: inset 0 0 0 2px var(--tc-accent);
}

/* Active ring — one element shared through framer's layoutId, so it
   glides from the old thumb to the new one. */
.ring {
  position: absolute;
  inset: -3px;
  border-radius: calc(var(--tc-thumb-radius) + 3px);
  box-shadow:
    0 0 0 2px var(--tc-accent),
    0 0 14px -2px color-mix(in srgb, var(--tc-accent) 55%, transparent);
  pointer-events: none;
}

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

/* framer's MotionConfig handles the JS side; this stills the CSS side.
   The lens still works — it just snaps instead of easing in. */
@media (prefers-reduced-motion: reduce) {
  .zoomLayer,
  .caption,
  .counter,
  .nav,
  .thumbMedia {
    transition: none;
  }
}
```

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

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

// TODO: items はプレビューと同じ仮データです。image に商品写真の URL(または CSS グラデーション)を、alt に画像の説明を入れてください
// TODO: プレビューのヘッドホンの絵は renderItem / renderThumb で描いたデモ専用のものです。生成コードでは背景グラデーションとキャプションだけが表示されます
// TODO: 選択中の番号を外で使うなら index={index} onIndexChange={setIndex} で制御します(初期位置だけなら defaultIndex)
<ThumbnailCarousel
  transition="slide"
  thumbPosition="bottom"
  thumbSize={64}
  showCounter={true}
  zoomOnHover={true}
  zoomScale={2}
  loop={true}
  items={[
    {
      id: "front",
      title: "Halo One — ミッドナイト",
      subtitle: "正面",
      image: "radial-gradient(60% 55% at 50% 40%, rgba(139, 92, 246, 0.32), transparent 70%), linear-gradient(160deg, #1d1a2b 0%, #0b0a10 100%)",
      alt: "ミッドナイトカラーのヘッドホンを正面から",
    },
    {
      id: "angle",
      title: "Halo One — ミッドナイト",
      subtitle: "斜め 45°",
      image: "radial-gradient(55% 50% at 38% 45%, rgba(99, 102, 241, 0.34), transparent 70%), linear-gradient(200deg, #191a2c 0%, #09090f 100%)",
      alt: "ミッドナイトカラーのヘッドホンを斜めから",
    },
    {
      id: "side",
      title: "Halo One — ミッドナイト",
      subtitle: "サイド",
      image: "radial-gradient(50% 55% at 50% 55%, rgba(236, 72, 153, 0.22), transparent 70%), linear-gradient(170deg, #1f1726 0%, #0a080d 100%)",
      alt: "ミッドナイトカラーのヘッドホンを真横から",
    },
    {
      id: "detail",
      title: "イヤーカップ",
      subtitle: "メッシュとステッチのディテール",
      image: "radial-gradient(70% 70% at 30% 25%, rgba(167, 139, 250, 0.4), transparent 65%), linear-gradient(150deg, #241d3a 0%, #0c0a14 100%)",
      alt: "イヤーカップのメッシュとステッチの接写",
    },
    {
      id: "sand",
      title: "Halo One — サンド",
      subtitle: "カラーバリエーション",
      image: "radial-gradient(60% 55% at 50% 40%, rgba(255, 236, 200, 0.55), transparent 70%), linear-gradient(160deg, #a38a68 0%, #3a3024 100%)",
      alt: "サンドカラーのヘッドホンを正面から",
    },
    {
      id: "glacier",
      title: "Halo One — グレイシャー",
      subtitle: "カラーバリエーション",
      image: "radial-gradient(60% 55% at 45% 40%, rgba(186, 230, 253, 0.5), transparent 70%), linear-gradient(160deg, #5f86a8 0%, #172636 100%)",
      alt: "グレイシャーカラーのヘッドホンを斜めから",
    },
  ]}
  aria-label="商品画像"
/>
```

### 4. 組み込み手順
1. 使う側は `"use client"` でなくても構いません(コンポーネント自体が `"use client"`)。`<ThumbnailCarousel items={[…]} aria-label="商品画像" />` を商品ページのカラムなどに置く。幅は親いっぱい、高さはメイン画像の縦横比(既定 4:3)で決まる。
2. `items` は `{ id?, title?, subtitle?, image?, alt? }` の配列。`image` は画像 URL(`<img>` で描画)か CSS の背景値(グラデーション・`url(…)`・複数レイヤー)。`alt` は必ず入れる(無ければ `title` が代わりに読まれる)。`title` / `subtitle` を入れるとメイン画像の下部にキャプションが出る。商品写真だけ見せたいなら省略する。
3. 正方形の商品写真なら `className` で `--tc-aspect: 1 / 1` を上書きする(例: CSS Modules / グローバル CSS で `.gallery { --tc-aspect: 1 / 1; }`)。角丸・アクセント色も `--tc-radius` / `--tc-accent` で変えられる。
4. 外部で選択中の番号を使う(カラー選択と同期するなど)場合は `index` と `onIndexChange` で制御する。初期位置だけなら `defaultIndex`。
5. 画像の代わりに独自の中身を出すなら `renderItem(item, { index, total, active })`、サムネイルは `renderThumb`。`renderItem` の中身はホバー拡大の対象になり、キャプションはその上に重なる。
6. Next.js の `next/image` を使いたい場合は `renderItem` / `renderThumb` の中で `<Image fill sizes="…" />` を返す(親は `position: absolute; inset: 0` の箱)。外部ドメインなら `next.config` の `images.remotePatterns` も設定する。
7. `npm run build` が通ることを確認し、実機で「横スワイプで切り替わり、縦スワイプではページがスクロールする」「サムネイルが多いとき選択中が見える位置にスクロールする」を確認する。

#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| サムネイルを押すとページ全体がガクッとスクロールする | 選択中のサムネイルを `scrollIntoView` で見せている | サムネイル列の要素に対して `scrollTo`(`offsetLeft` / `offsetTop` から算出)だけを使う。列は `position: relative` のまま |
| 左配置でメイン画像が縦に伸びる | 縦並びのサムネイル列が行の高さを決めてしまう | 列の `contain: size` と明示幅を外さない(高さはグリッドがメイン画像に合わせる) |
| 選択リングが移動せず瞬間移動する / 列のスクロールでずれる | `layoutId` が複数インスタンスで重複、またはスクロール親に `layoutScroll` が無い | `layoutId` は `useId` 由来のまま、サムネイル列の `motion.div` の `layoutScroll` を外さない |
| ホバー拡大が重い・カクつく | マウス座標を React の state に入れて毎フレーム再描画している | 座標は ref に入れ、rAF で `--tc-zoom-x/y` を書くだけにする。state は拡大のオン / オフ切り替え時だけ |
| スマホで縦スクロールできない | メイン画像の `touch-action` を `none` にした | `touch-action: pan-y` のまま(横ドラッグだけをカルーセルが受け取る) |
| 視差効果オフでもスライドする | framer-motion は JS で動くので CSS の reduced-motion が効かない | `<MotionConfig reducedMotion="user">` を外さない |

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

## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- Next.js(App Router)、React 19、TypeScript、CSS Modules、`framer-motion`(v12 以降)、`lucide-react`(前へ / 次へのシェブロン)
- ファイル: `components/thumbnail-carousel/ThumbnailCarousel.tsx`(`"use client"`)+ `ThumbnailCarousel.module.css` + `index.ts`
- Props: `items`、`index` / `defaultIndex` / `onIndexChange`、`transition`(`"slide" | "fade" | "zoom"`、既定 slide)、`thumbPosition`(`"bottom" | "left"`)、`thumbSize`(px、既定 64)、`showCounter`(既定 true)、`zoomOnHover`(既定 true)、`zoomScale`(既定 2)、`loop`(既定 true)、`autoPlay` / `autoPlayInterval`(既定オフ / 5000ms)、`renderItem`、`renderThumb`、`className`、`aria-label`(既定「画像ギャラリー」)
- 寸法はすべてコンテナ基準(vw / vh は使わない)。CSS 変数の既定値はルートクラスに置き、既定と違う値(`--tc-thumb`、`--tc-zoom-scale`)だけをインラインで渡す

### 見た目(ダーク前提)
- **ルート**: 下配置は縦 flex(サムネイル列を `order: 1` でメインの下へ)、左配置は `grid-template-columns: auto minmax(0, 1fr)`。メインとサムネイルの見た目の間隔は 12px
- **メイン(ステージ)**: `aspect-ratio: var(--tc-aspect)`(既定 `4 / 3`)、角丸 20px、`overflow: hidden`、背景 `#111114` + 上からの淡い白のラジアル。影 `0 30px 60px -24px rgba(0,0,0,.7)`。スライドの上に `::after` で 1px のヘアライン(白 .08)と上辺のハイライト(白 .10)
- **既定のスライド**: URL は `<img object-fit: cover>`、CSS 値は `background` にそのまま。`image` が無ければ黄金角で色相をずらしたグラデーション
- **キャプション**: 下端に `rgba(6,6,8,.62) → 透明` のスクリム、タイトル 15px / 600、サブ 12.5px / 白 .62。拡大中はフェードアウト
- **枚数バッジ**: 右上 14px、ガラスのピル(`rgba(12,12,14,.5)` + `backdrop-filter: blur(12px) saturate(140%)` + 白 .12 の内側線)、12px の等幅数字「03 / 06」(総数は白 .46)
- **前へ / 次へ**: 左右中央に 38px のガラスの円。普段は透明、ステージのホバー / フォーカス時に表示(タッチ端末では常時表示)。`loop={false}` の端では非表示
- **サムネイル**: 正方形 `--tc-thumb`、角丸 12px、非選択は opacity .5 + 彩度 .75、ホバーで .85、選択中は 1。列は 10px 間隔、6px の内側余白(リングと光が切れないため)、スクロールバー非表示。下配置は収まるときは中央寄せ(先頭 / 末尾の `margin-inline: auto`)、あふれたらスクロール
- **選択リング**: サムネイルの外側 3px、`0 0 0 2px var(--tc-accent)` + 同色 55% の 14px の光。`--tc-accent` 既定は `#f5f5f7`

### モーション
- **切り替え**: `AnimatePresence`(`initial={false}`、`custom={direction}`)で現在のスライドだけをマウント
  - slide: 進む方向から `x: ±100%` → 0、出ていく側は反対へ抜けつつ opacity .4。スプリング `stiffness 300 / damping 34 / mass .9`
  - fade: 0.4s のクロスフェード(ease `[.22,1,.36,1]`)
  - zoom: 入る側 `scale 1.14 → 1` + フェードイン、出る側 `scale .94` + フェードアウト
  - 方向は前回の index を state に持ち、レンダー中に比較して決める。ループ時の「最後 → 最初」(次へ)は前進扱い
- **スワイプ**: 現在のスライドに `drag="x"`、`dragConstraints={{ left: 0, right: 0 }}`、`dragMomentum={false}`。`offset.x + velocity.x × 0.2` がステージ幅の 22% を超えたら前後へ移動。進めない側(`loop` オフの端)は `dragElastic` を .12 に下げてゴムのように止める
- **選択リング**: 選択中のサムネイルの中に `motion.span layoutId={useId 由来}` を置き、スプリング `stiffness 520 / damping 40` で滑らせる。サムネイル列は `motion.div layoutScroll`
- **列の追従**: index が変わるたび、列の `scrollTo` で選択中を中央へ(初回は瞬時、以降は smooth、reduced-motion では瞬時)
- **ホバー拡大**: `pointerType === "mouse"` かつボタン未押下のときだけ。座標は ref、rAF で `getBoundingClientRect` 比の % を `--tc-zoom-x/y` に書き、ズーム層(スライド内の別要素)に `transform-origin` + `scale(var(--tc-zoom-scale))`(380ms のトランジション)。押下(ドラッグ開始)・ステージ外・前へ / 次へボタンの上では解除
- **自動再生(任意)**: `autoPlay` 時のみ。ホバー・フォーカス中・画面外(IntersectionObserver)・タブ非表示・reduced-motion では止まる
- `<MotionConfig reducedMotion="user">` で包み、CSS のトランジションも `prefers-reduced-motion: reduce` で無効化(拡大自体は効くが瞬時に切り替わる)

### アクセシビリティ
- ルートは `<section role="region" aria-roledescription="カルーセル" aria-label>`
- サムネイル列は `role="tablist"`(`aria-orientation` は配置に合わせる)、各サムネイルは `<button role="tab" aria-selected aria-controls={パネル id}>`、名前は「alt(n / 総数)」。ローヴィングフォーカス(選択中だけ `tabIndex=0`)、← / →(左配置では ↑ / ↓)・Home / End で移動し、選択がフォーカスに追従。矢印は `loop` に関係なく常に回り込む
- メインは `role="tabpanel" aria-labelledby={選択中のタブ id} tabIndex=0`。フォーカス中は ← / → / Home / End で切り替え
- スライドは `role="group" aria-roledescription="スライド" aria-label="n / 総数"`。退場アニメーション中のスライドは `aria-hidden` + `inert`
- 前へ / 次へ・スワイプ・メインのキー操作による切り替えは、視覚的に隠した `aria-live="polite"` 領域で「n / 総数:タイトル」を読み上げる(自動再生では読み上げない)
- マウント時にフォーカスを奪わない。`:focus-visible` はステージ・サムネイルとも内側 2px の `--tc-accent` の線

### 受け入れ条件
- サムネイルをクリックするとリングが滑って移動し、メイン画像が選んだ方向から切り替わる
- メイン画像を横にドラッグ / スワイプすると前後へ移動し、縦スワイプではページがスクロールする
- マウスを乗せると、ポインタ位置を中心に拡大され、動かすと拡大位置が追従する(タッチ端末では起きない)
- サムネイルが列に入りきらないとき、選択中が常に見える位置へ列だけがスクロールする(ページは動かない)
- 左配置でもメイン画像の縦横比が崩れず、サムネイル列はメインの高さの中でスクロールする
- キーボードだけで全画像を切り替えられ、スクリーンリーダーで「n / 総数」が分かる
インストール
npx shadcn@latest add https://anima-js.vercel.app/r/thumbnail-carousel.json
生成コード
import { ThumbnailCarousel } from "@/components/thumbnail-carousel";

// TODO: items はプレビューと同じ仮データです。image に商品写真の URL(または CSS グラデーション)を、alt に画像の説明を入れてください
// TODO: プレビューのヘッドホンの絵は renderItem / renderThumb で描いたデモ専用のものです。生成コードでは背景グラデーションとキャプションだけが表示されます
// TODO: 選択中の番号を外で使うなら index={index} onIndexChange={setIndex} で制御します(初期位置だけなら defaultIndex)
<ThumbnailCarousel
  transition="slide"
  thumbPosition="bottom"
  thumbSize={64}
  showCounter={true}
  zoomOnHover={true}
  zoomScale={2}
  loop={true}
  items={[
    {
      id: "front",
      title: "Halo One — ミッドナイト",
      subtitle: "正面",
      image: "radial-gradient(60% 55% at 50% 40%, rgba(139, 92, 246, 0.32), transparent 70%), linear-gradient(160deg, #1d1a2b 0%, #0b0a10 100%)",
      alt: "ミッドナイトカラーのヘッドホンを正面から",
    },
    {
      id: "angle",
      title: "Halo One — ミッドナイト",
      subtitle: "斜め 45°",
      image: "radial-gradient(55% 50% at 38% 45%, rgba(99, 102, 241, 0.34), transparent 70%), linear-gradient(200deg, #191a2c 0%, #09090f 100%)",
      alt: "ミッドナイトカラーのヘッドホンを斜めから",
    },
    {
      id: "side",
      title: "Halo One — ミッドナイト",
      subtitle: "サイド",
      image: "radial-gradient(50% 55% at 50% 55%, rgba(236, 72, 153, 0.22), transparent 70%), linear-gradient(170deg, #1f1726 0%, #0a080d 100%)",
      alt: "ミッドナイトカラーのヘッドホンを真横から",
    },
    {
      id: "detail",
      title: "イヤーカップ",
      subtitle: "メッシュとステッチのディテール",
      image: "radial-gradient(70% 70% at 30% 25%, rgba(167, 139, 250, 0.4), transparent 65%), linear-gradient(150deg, #241d3a 0%, #0c0a14 100%)",
      alt: "イヤーカップのメッシュとステッチの接写",
    },
    {
      id: "sand",
      title: "Halo One — サンド",
      subtitle: "カラーバリエーション",
      image: "radial-gradient(60% 55% at 50% 40%, rgba(255, 236, 200, 0.55), transparent 70%), linear-gradient(160deg, #a38a68 0%, #3a3024 100%)",
      alt: "サンドカラーのヘッドホンを正面から",
    },
    {
      id: "glacier",
      title: "Halo One — グレイシャー",
      subtitle: "カラーバリエーション",
      image: "radial-gradient(60% 55% at 45% 40%, rgba(186, 230, 253, 0.5), transparent 70%), linear-gradient(160deg, #5f86a8 0%, #172636 100%)",
      alt: "グレイシャーカラーのヘッドホンを斜めから",
    },
  ]}
  aria-label="商品画像"
/>