ギャラリー/

InsidePovCarousel

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

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

## コンポーネントについて
- 名前: InsidePovCarousel
- 説明: 内側視点のリングカルーセル — 中心に立って、周囲を回るカードを見渡します。純粋な CSS 3D、ドラッグ慣性、奥行きの陰影。
- 構成ファイル: `components/inside-pov-carousel/index.tsx`, `components/inside-pov-carousel/InsidePovCarousel.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/inside-pov-carousel/index.tsx`
```tsx
"use client";

import {
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
  type CSSProperties,
  type PointerEvent as ReactPointerEvent,
  type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import styles from "./InsidePovCarousel.module.css";

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

export type MediaItem = {
  src: string;
  poster?: string;
  alt?: string;
  /** "video" (default) renders a <video>, "image" renders an <img>. */
  kind?: "video" | "image";
};

export type InsidePovCarouselProps = {
  /**
   * Video cards to display. When omitted, deterministic gradient
   * placeholder cards are rendered instead (see `cardCount`).
   */
  items?: MediaItem[];
  /** Number of placeholder cards when `items` is omitted. */
  cardCount?: number;
  autoRotate?: boolean;
  interactive?: boolean;
  /** Auto-rotation speed in deg/frame. 0.04 ≈ 2.4 deg/s. */
  speed?: number;
  /** Drag sensitivity in deg/px. */
  dragSensitivity?: number;
  /** Inertia decay per frame; closer to 1 glides longer. */
  friction?: number;
  /** CSS perspective in px. Smaller = stronger foreshortening. */
  perspective?: number;
  /** Ring radius in px. Larger = wider Z-depth spread. */
  radius?: number;
  /** Card width in px. */
  cardWidth?: number;
  /** Card height = width × aspect. */
  cardAspect?: number;
  /** Whole-ring X tilt in deg. */
  tiltX?: number;
  /** Card corner radius in px. */
  cardRadius?: number;
  /** Depth shading strength 0–1: front-facing (far) cards darken. */
  depthShading?: number;
  /** Show the drag-hint toggle below the ring. */
  showHint?: boolean;
  className?: string;
};

const DEFAULT_SPEED = 0.04;
const DEFAULT_DRAG_SENSITIVITY = 0.28;
const DEFAULT_FRICTION = 0.94;
const DEFAULT_DEPTH_SHADING = 0.28;
const DEFAULT_CARD_COUNT = 14;
/** Inertia cutoff threshold. */
const VELOCITY_EPSILON = 0.01;

/** Gap below the centered card's projected edge before the drag hint starts. */
const HINT_GAP_PX = 20;
// Mirror the schema/stylesheet defaults — used only as a fallback when one
// geometry prop is explicit but another is left unset.
const DEFAULT_PERSPECTIVE = 900;
const DEFAULT_RADIUS = 850;
const DEFAULT_CARD_ASPECT = 1.7;

/** Deterministic placeholder gradient per card index (golden-angle hues). */
function placeholderGradient(index: number): string {
  const hue = Math.round((index * 137.5) % 360);
  const hue2 = (hue + 42) % 360;
  return `linear-gradient(160deg, hsl(${hue} 62% 52%), hsl(${hue2} 68% 30%))`;
}

export function InsidePovCarousel({
  items,
  cardCount = DEFAULT_CARD_COUNT,
  autoRotate = true,
  interactive = true,
  speed = DEFAULT_SPEED,
  dragSensitivity = DEFAULT_DRAG_SENSITIVITY,
  friction = DEFAULT_FRICTION,
  perspective,
  radius,
  cardWidth,
  cardAspect,
  tiltX,
  cardRadius,
  depthShading = DEFAULT_DEPTH_SHADING,
  showHint = true,
  className,
}: InsidePovCarouselProps) {
  const rootRef = useRef<HTMLElement | null>(null);
  const videoRefs = useRef<(HTMLVideoElement | null)[]>([]);
  const cardRefs = useRef<(HTMLElement | null)[]>([]);
  const brightnessCacheRef = useRef<number[]>([]);
  const frameRef = useRef(0);

  // All values read by the rAF loop live in refs so the loop never restarts.
  const rotationRef = useRef(0);
  const velocityRef = useRef(0);
  const lastXRef = useRef(0);
  const isDraggingRef = useRef(false);
  const isHoveredRef = useRef(false);
  const autoRotatingRef = useRef(autoRotate);
  const speedRef = useRef(speed);
  const dragSensitivityRef = useRef(dragSensitivity);
  const frictionRef = useRef(friction);
  // Effective depth shading: prop value on desktop, 0 on mobile (≤760px).
  const depthRef = useRef(depthShading);
  const isMobileRef = useRef(false);

  const [isDragging, setIsDragging] = useState(false);
  // Cards stay hidden until their video can play through.
  const [loadedCards, setLoadedCards] = useState<Set<number>>(() => new Set());

  const markLoaded = useCallback((index: number) => {
    setLoadedCards((prev) => {
      if (prev.has(index)) return prev;
      const next = new Set(prev);
      next.add(index);
      return next;
    });
  }, []);

  const count = items ? Math.max(items.length, 1) : Math.max(cardCount, 1);
  // Per-card angle, deterministic on the server to avoid hydration mismatch.
  const step = useMemo(() => 360 / count, [count]);
  const stepRef = useRef(step);

  // Sync prop changes into the refs the rAF loop reads. Auto-rotation
  // lives only in a ref — nothing in the JSX depends on it; the Space
  // toggle flips the ref and a prop change overrides it here.
  useEffect(() => {
    autoRotatingRef.current = autoRotate;
  }, [autoRotate]);
  useEffect(() => {
    stepRef.current = step;
  }, [step]);
  useEffect(() => {
    speedRef.current = speed;
  }, [speed]);
  useEffect(() => {
    dragSensitivityRef.current = dragSensitivity;
  }, [dragSensitivity]);
  useEffect(() => {
    frictionRef.current = friction;
  }, [friction]);

  // Depth shading is flattened to 0 on mobile (≤760px) to match the
  // reference look; brightness cache is cleared so stale filters reset.
  useEffect(() => {
    const applyDepth = () => {
      depthRef.current = isMobileRef.current ? 0 : depthShading;
      brightnessCacheRef.current = [];
    };
    const mq = window.matchMedia("(max-width: 760px)");
    const onChange = () => {
      isMobileRef.current = mq.matches;
      applyDepth();
    };
    onChange();
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, [depthShading]);

  // Positions the drag hint just below the centered card's actual
  // perspective-projected edge, so it never overlaps the card regardless of
  // geometry props or viewport width. The centered card sits at the ring's
  // farthest point from the camera (distance = perspective + radius), which
  // gives its true on-screen scale under CSS `perspective` projection —
  // using the un-projected card height instead would overstate its size and
  // push the hint outside the carousel's own bounds on short containers.
  // Only runs when cardWidth is explicit; otherwise the stylesheet's own
  // breakpoint-tuned --pov-hint-offset default (module.css) already matches
  // its clamp()-driven card size.
  useEffect(() => {
    const root = rootRef.current;
    if (!root || cardWidth === undefined) return;
    const aspect = cardAspect ?? DEFAULT_CARD_ASPECT;
    const persp = perspective ?? DEFAULT_PERSPECTIVE;
    const rad = radius ?? DEFAULT_RADIUS;

    const recompute = () => {
      // Mirrors the min(px, 42vw) cap applied to --pov-card-w below, so the
      // hint tracks the card's actual (possibly viewport-shrunk) width.
      const effectiveCardW = Math.min(cardWidth, 0.42 * window.innerWidth);
      const effectiveCardH = effectiveCardW * aspect;
      const scale = persp / (persp + rad);
      const projectedHalfHeight = (effectiveCardH * scale) / 2;
      root.style.setProperty(
        "--pov-hint-offset",
        `${projectedHalfHeight + HINT_GAP_PX}px`,
      );
    };
    recompute();
    window.addEventListener("resize", recompute);
    return () => window.removeEventListener("resize", recompute);
  }, [cardWidth, cardAspect, perspective, radius]);

  // ------------------------------------------------------------------
  // Main animation loop: auto-rotation + drag inertia + depth shading
  // ------------------------------------------------------------------
  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;

    const reduceMotion = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;

    let rafId = 0;

    const tick = () => {
      if (
        !reduceMotion &&
        autoRotatingRef.current &&
        !isDraggingRef.current &&
        !isHoveredRef.current
      ) {
        rotationRef.current += speedRef.current;
      }

      if (
        !isDraggingRef.current &&
        Math.abs(velocityRef.current) > VELOCITY_EPSILON
      ) {
        rotationRef.current += velocityRef.current;
        velocityRef.current *= frictionRef.current;
      } else if (!isDraggingRef.current) {
        velocityRef.current = 0;
      }

      root.style.setProperty("--pov-rotation", `${rotationRef.current}deg`);

      // Depth via brightness: camera-facing (far) cards darken, edge (near)
      // cards brighten. Writing `filter` to every card each frame makes
      // dragging stutter, so updates run every 3rd frame, never while
      // dragging, quantized so styles are only written on change.
      frameRef.current = (frameRef.current + 1) % 3;
      if (frameRef.current === 0 && !isDraggingRef.current) {
        const stepDeg = stepRef.current;
        const cards = cardRefs.current;
        const cache = brightnessCacheRef.current;
        const rot = rotationRef.current;
        const depth = depthRef.current;
        for (let i = 0; i < cards.length; i += 1) {
          const card = cards[i];
          if (!card) continue;
          const angleRad = ((i * stepDeg + rot) * Math.PI) / 180;
          const facing = Math.max(0, Math.cos(angleRad)); // 1=front(far), 0=edge(near)
          const brightness = Math.round((1 - depth * facing) * 50) / 50;
          if (cache[i] !== brightness) {
            cache[i] = brightness;
            card.style.filter = `brightness(${brightness})`;
          }
        }
      }

      rafId = requestAnimationFrame(tick);
    };

    rafId = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(rafId);
  }, []);

  // ------------------------------------------------------------------
  // Video autoplay + tab visibility handling
  // ------------------------------------------------------------------
  useEffect(() => {
    if (!items) return;
    const vids = videoRefs.current.filter(
      (v): v is HTMLVideoElement => v != null,
    );

    const safePlay = (v: HTMLVideoElement) => {
      const p = v.play();
      if (p && typeof p.catch === "function") p.catch(() => {});
    };

    vids.forEach(safePlay);

    const onVisibility = () => {
      if (document.hidden) {
        vids.forEach((v) => v.pause());
      } else {
        vids.forEach(safePlay);
      }
    };

    document.addEventListener("visibilitychange", onVisibility);
    return () => document.removeEventListener("visibilitychange", onVisibility);
  }, [items]);

  // ------------------------------------------------------------------
  // Pointer drag / swipe (interactive only)
  // ------------------------------------------------------------------
  const onPointerDown = useCallback(
    (e: ReactPointerEvent<HTMLElement>) => {
      if (!interactive) return;
      isDraggingRef.current = true;
      velocityRef.current = 0;
      lastXRef.current = e.clientX;
      setIsDragging(true);
      e.currentTarget.setPointerCapture(e.pointerId);
    },
    [interactive],
  );

  const onPointerMove = useCallback(
    (e: ReactPointerEvent<HTMLElement>) => {
      if (!interactive || !isDraggingRef.current) return;
      const dx = e.clientX - lastXRef.current;
      lastXRef.current = e.clientX;
      const delta = dx * dragSensitivityRef.current;
      rotationRef.current += delta;
      velocityRef.current = delta; // initial inertia velocity on release
    },
    [interactive],
  );

  const endDrag = useCallback((e: ReactPointerEvent<HTMLElement>) => {
    if (!isDraggingRef.current) return;
    isDraggingRef.current = false;
    setIsDragging(false);
    try {
      e.currentTarget.releasePointerCapture(e.pointerId);
    } catch {
      /* capture may not be set */
    }
  }, []);

  const onMouseEnter = useCallback(() => {
    isHoveredRef.current = true;
  }, []);
  const onMouseLeave = useCallback(() => {
    isHoveredRef.current = false;
  }, []);

  // ------------------------------------------------------------------
  // Keyboard (interactive only): arrows nudge, Space toggles rotation
  // ------------------------------------------------------------------
  const onKeyDown = useCallback(
    (e: ReactKeyboardEvent<HTMLElement>) => {
      if (!interactive) return;
      switch (e.key) {
        case "ArrowLeft":
          e.preventDefault();
          velocityRef.current = -step * 0.06;
          break;
        case "ArrowRight":
          e.preventDefault();
          velocityRef.current = step * 0.06;
          break;
        case " ":
        case "Spacebar":
          e.preventDefault();
          autoRotatingRef.current = !autoRotatingRef.current;
          break;
        default:
          break;
      }
    },
    [interactive, step],
  );

  // Root CSS variables. --pov-step is deterministic from SSR; the tuning
  // vars are only set when the prop is provided so the stylesheet's
  // responsive clamp() defaults survive for unspecified props.
  //
  // Explicit perspective/radius/cardWidth are capped with `min()` against
  // the same vw ratios the stylesheet's own ≤760px clamp() uses (see
  // InsidePovCarousel.module.css). Without this, a fixed px override wins
  // outright at every viewport width — pinning the ring at its desktop
  // size on phones, which both overflows narrow screens and leaves the
  // module's mobile-tuned .scrollHint offset pointing at empty space
  // below a card that never actually shrank.
  const rootStyle: CSSVars = {
    "--pov-step": `${step}deg`,
  };
  if (perspective !== undefined)
    rootStyle["--pov-perspective"] = `min(${perspective}px, 300vw)`;
  if (radius !== undefined) rootStyle["--pov-radius"] = `min(${radius}px, 110vw)`;
  if (cardWidth !== undefined)
    rootStyle["--pov-card-w"] = `min(${cardWidth}px, 42vw)`;
  if (cardAspect !== undefined)
    rootStyle["--pov-card-h"] = `calc(var(--pov-card-w) * ${cardAspect})`;
  if (tiltX !== undefined) rootStyle["--pov-tilt-x"] = `${tiltX}deg`;
  if (cardRadius !== undefined) rootStyle["--pov-card-radius"] = `${cardRadius}px`;

  const rootClassName = [
    styles.carousel,
    isDragging ? styles.dragging : "",
    className ?? "",
  ]
    .filter(Boolean)
    .join(" ");

  const indices = Array.from({ length: count }, (_, i) => i);

  return (
    <section
      ref={rootRef}
      className={rootClassName}
      style={rootStyle}
      tabIndex={interactive ? 0 : -1}
      aria-label="3D カルーセル"
      onPointerDown={interactive ? onPointerDown : undefined}
      onPointerMove={interactive ? onPointerMove : undefined}
      onPointerUp={interactive ? endDrag : undefined}
      onPointerCancel={interactive ? endDrag : undefined}
      onKeyDown={interactive ? onKeyDown : undefined}
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}
    >
      <div className={styles.stage}>
        <div className={styles.world}>
          {indices.map((index) => {
            const item = items?.[index];
            const loaded = !items || loadedCards.has(index);
            return (
              <figure
                key={item ? `${item.src}-${index}` : index}
                ref={(node) => {
                  cardRefs.current[index] = node;
                }}
                className={`${styles.card}${loaded ? ` ${styles.isLoaded}` : ""}`}
                style={{ "--i": index } as CSSVars}
              >
                {item?.kind === "image" ? (
                  // Plain <img>: registry components stay framework-agnostic,
                  // and object/blob URLs bypass image optimizers anyway.
                  // eslint-disable-next-line @next/next/no-img-element
                  <img
                    ref={(node) => {
                      // Show immediately if already decoded (cache hits).
                      if (node?.complete) markLoaded(index);
                    }}
                    className={styles.image}
                    src={item.src}
                    alt={item.alt ?? ""}
                    draggable={false}
                    onLoad={() => markLoaded(index)}
                  />
                ) : item ? (
                  <video
                    ref={(node) => {
                      videoRefs.current[index] = node;
                      // Show immediately if already buffered (cache hits).
                      if (node && node.readyState >= 4) markLoaded(index);
                    }}
                    className={styles.video}
                    src={item.src}
                    poster={item.poster}
                    aria-label={item.alt}
                    muted
                    loop
                    playsInline
                    // "metadata" rather than "auto": a full ring is up to 20
                    // clips, and fetching them all upfront is wasteful when
                    // playback buffers them anyway.
                    preload="metadata"
                    // Reveal as soon as the first frame exists; canplaythrough
                    // may lag well behind that (or not fire at all) once
                    // preload is weakened. markLoaded is idempotent.
                    onLoadedData={() => markLoaded(index)}
                    onCanPlayThrough={() => markLoaded(index)}
                  />
                ) : (
                  <div
                    className={styles.placeholder}
                    style={{ background: placeholderGradient(index) }}
                  >
                    <span className={styles.placeholderIndex}>{index + 1}</span>
                  </div>
                )}
              </figure>
            );
          })}
        </div>
      </div>

      {showHint && (
        <div className={styles.scrollHint} aria-hidden="true">
          <span className={styles.toggle}>
            <span className={styles.knob} />
          </span>
          <svg
            className={styles.chevron}
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            strokeWidth={2.4}
            strokeLinecap="round"
            strokeLinejoin="round"
          >
            <path d="M9 5l7 7-7 7" />
          </svg>
        </div>
      )}
    </section>
  );
}

export default InsidePovCarousel;
```

#### `components/inside-pov-carousel/InsidePovCarousel.module.css`
```css
/* =================================================================
   Inside-POV 3D Carousel — the viewer stands at the center of a
   ring and looks outward at cards placed on the circumference.
   ================================================================= */

.carousel {
  position: relative;
  width: 100%;
  height: min(74vh, 680px);
  min-height: 480px;
  display: grid;
  place-items: center;
  overflow: hidden;
  isolation: isolate;
  user-select: none;
  touch-action: none;
  cursor: grab;
  outline: none;
  background: transparent;

  /* --- Main tuning variables (desktop baseline: 7 visible cards with
     strong Z-depth). Small perspective + large radius makes the center
     card recede and the side cards advance toward the camera. All are
     vw-linked so the 7-card clipping ratio holds across widths.
     ≈1440px resolves to perspective≈890 / radius≈850. Explicit props
     override these via inline CSS variables on the root element. --- */
  --pov-perspective: clamp(760px, 62vw, 1400px); /* smaller = stronger depth */
  --pov-radius: clamp(680px, 59vw, 1500px);      /* larger = wider Z spread */
  --pov-card-w: clamp(290px, 23.5vw, 450px);
  --pov-card-h: calc(var(--pov-card-w) * 1.7);
  --pov-step: 25.714285deg; /* per-card angle; JS overrides to 360/count */
  --pov-rotation: 0deg;     /* current rotation; JS updates every frame */
  --pov-tilt-x: 0deg;
  --pov-card-radius: 18px;
  --pov-shift-y: 0px;       /* vertical offset (reserves room for the hint) */
  /* Drag-hint offset below center. Tuned for the clamp() card size above;
     overridden via inline style whenever cardWidth is passed explicitly
     (see the geometry effect in InsidePovCarousel.tsx). */
  --pov-hint-offset: 240px;
}

/* ------------------------------------------------------------------
   3D stage (viewpoint = center of perspective-origin)
   ------------------------------------------------------------------ */
.stage {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  perspective: var(--pov-perspective);
  perspective-origin: 50% 50%;
  overflow: hidden;
  transform-style: preserve-3d;
}

.world {
  position: relative;
  width: 0;
  height: 0;
  transform-style: preserve-3d;
  transform: translateY(var(--pov-shift-y)) rotateX(var(--pov-tilt-x))
    rotateY(var(--pov-rotation));
  will-change: transform;
}

/* ------------------------------------------------------------------
   Cards on the ring (negative translateZ pushes them outward, so the
   card facing the camera is the farthest — the tunnel-depth effect)
   ------------------------------------------------------------------ */
.card {
  position: absolute;
  left: 50%;
  top: 50%;
  width: var(--pov-card-w);
  height: var(--pov-card-h);
  margin: 0;
  overflow: hidden;
  border-radius: var(--pov-card-radius);
  /* Hidden until media is ready — no gray placeholder flash. */
  background: transparent;
  opacity: 0;
  transition: opacity 0.5s ease;
  transform-style: preserve-3d;
  transform:
    translate(-50%, -50%)
    rotateY(calc(var(--i) * var(--pov-step)))
    translateZ(calc(var(--pov-radius) * -1));
  box-shadow: none;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  will-change: transform;
}

.card.isLoaded {
  opacity: 1;
}

/* Inner highlight + lower shading for dimensionality */
.card::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 3;
  border-radius: inherit;
  box-shadow:
    inset 0 0 0 1px rgba(255, 255, 255, 0.36),
    inset 0 22px 36px rgba(255, 255, 255, 0.14),
    inset 0 -38px 58px rgba(0, 0, 0, 0.22);
  pointer-events: none;
}

/* Horizontal + vertical gradients for gloss and curvature */
.card::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 4;
  border-radius: inherit;
  background:
    linear-gradient(90deg, rgba(255, 255, 255, 0.18), transparent 18% 78%, rgba(0, 0, 0, 0.18)),
    linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent 35%, rgba(0, 0, 0, 0.14));
  opacity: 0.72;
  pointer-events: none;
}

.video,
.image {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% 50%;
  border-radius: inherit;
  background: transparent;
}

.image {
  -webkit-user-drag: none;
}

/* Media-free placeholder card */
.placeholder {
  display: grid;
  place-items: center;
  width: 100%;
  height: 100%;
  border-radius: inherit;
}

.placeholderIndex {
  font-size: clamp(28px, 3.2vw, 52px);
  font-weight: 700;
  color: rgba(255, 255, 255, 0.4);
  font-variant-numeric: tabular-nums;
}

.dragging {
  cursor: grabbing;
}

/* ------------------------------------------------------------------
   Drag hint below the center card: a decorative toggle whose knob
   slides left→right in a loop — "this ring can be scrolled/dragged".
   Purely an affordance; it is not an on/off switch.
   ------------------------------------------------------------------ */
.scrollHint {
  position: absolute;
  left: 50%;
  top: calc(50% + var(--pov-hint-offset));
  transform: translateX(-50%);
  z-index: 5;
  display: flex;
  align-items: center;
  gap: 9px;
  pointer-events: none;
}

.toggle {
  position: relative;
  width: 54px;
  height: 26px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.14);
  border: 1px solid rgba(255, 255, 255, 0.18);
  box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2);
  backdrop-filter: blur(6px);
}

.knob {
  position: absolute;
  top: 50%;
  left: 4px;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.85);
  transform: translate(0, -50%);
  animation: povKnobDrag 1.8s ease-in-out infinite;
}

@keyframes povKnobDrag {
  0% {
    transform: translate(0, -50%);
    opacity: 0;
  }
  14% {
    opacity: 1;
  }
  68% {
    transform: translate(28px, -50%);
    opacity: 1;
  }
  86% {
    transform: translate(28px, -50%);
    opacity: 0;
  }
  100% {
    transform: translate(0, -50%);
    opacity: 0;
  }
}

.chevron {
  width: 16px;
  height: 16px;
  color: rgba(255, 255, 255, 0.55);
  animation: povChevronNudge 1.8s ease-in-out infinite;
}

@keyframes povChevronNudge {
  0%,
  100% {
    transform: translateX(0);
    opacity: 0.5;
  }
  60% {
    transform: translateX(2px);
    opacity: 0.95;
  }
}

/* ------------------------------------------------------------------
   Responsive: shrink radius/cards so the ring never breaks
   ------------------------------------------------------------------ */
@media (max-width: 1024px) {
  .carousel {
    --pov-radius: clamp(560px, 60vw, 860px);
    --pov-card-w: clamp(230px, 24vw, 320px);
  }
}

/* Mobile: center + one card each side fits cleanly */
@media (max-width: 760px) {
  .carousel {
    width: 100%;
    min-height: 0;
    height: min(54vh, 284px);
    --pov-shift-y: -22px;
    --pov-perspective: clamp(780px, 300vw, 1500px);
    --pov-radius: clamp(360px, 110vw, 620px);
    --pov-card-w: clamp(160px, 42vw, 230px);
    --pov-card-h: calc(var(--pov-card-w) * 1.46);
    --pov-hint-offset: 88px;
  }

  .scrollHint {
    transform: translateX(-50%) scale(0.8);
  }
}

@media (prefers-reduced-motion: reduce) {
  .carousel {
    cursor: default;
  }
  /* Auto-rotation (the large motion) is disabled by the rAF loop under
     reduced motion. The drag hint deliberately keeps animating: it IS
     the signal that the ring can be scrolled, and Windows' "show
     animations" toggle maps to this media query — killing it here left
     the hint frozen for those users. */
}
```

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

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

<InsidePovCarousel
  cardCount={14}
  autoRotate={true}
  interactive={true}
  speed={0.04}
  dragSensitivity={0.28}
  friction={0.94}
  perspective={900}
  radius={850}
  cardWidth={340}
  cardAspect={1.7}
  tiltX={0}
  cardRadius={18}
  depthShading={0.28}
  showHint={true}
/>
```

### 4. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
インストール
npx shadcn@latest add https://anima-js.vercel.app/r/inside-pov-carousel.json
生成コード
import { InsidePovCarousel } from "@/components/inside-pov-carousel";

<InsidePovCarousel
  cardCount={14}
  autoRotate={true}
  interactive={true}
  speed={0.04}
  dragSensitivity={0.28}
  friction={0.94}
  perspective={900}
  radius={850}
  cardWidth={340}
  cardAspect={1.7}
  tiltX={0}
  cardRadius={18}
  depthShading={0.28}
  showHint={true}
/>