カルーセル
RingCarousel
CSS 3D
外から眺めるリングカルーセル — テーブルに置いた回転台のように、円筒に並んだカードを少し上から見ます。手前は大きく明るく、奥は小さく暗く裏側が透ける。自動回転はホバーでゆっくり停止、ドラッグ慣性とカード面へのスナップ、← / → で 1 枚ずつ。純粋な CSS 3D。
AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約54 KB)。
# RingCarousel をこのプロジェクトに追加してください
このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。
## コンポーネントについて
- 名前: RingCarousel
- 説明: 外から眺めるリングカルーセル — テーブルに置いた回転台のように、円筒に並んだカードを少し上から見ます。手前は大きく明るく、奥は小さく暗く裏側が透ける。自動回転はホバーでゆっくり停止、ドラッグ慣性とカード面へのスナップ、← / → で 1 枚ずつ。純粋な CSS 3D。
- 構成ファイル: `components/ring-carousel/index.tsx`, `components/ring-carousel/RingCarousel.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/ring-carousel/index.tsx`
```tsx
"use client";
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type FocusEvent as ReactFocusEvent,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import styles from "./RingCarousel.module.css";
/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;
export type RingCarouselItem = {
id?: string;
title?: string;
subtitle?: string;
/**
* Card art. A CSS background value (`linear-gradient(…)`, `url(…)`) is
* used as-is; anything else is treated as an image URL.
*/
image?: string;
/** Accessible description of the art (the art is a background image). */
alt?: string;
};
export type RingCarouselItemState = {
/** 0-based position of this item in `items`. */
index: number;
total: number;
/** True for the card currently facing the viewer. */
active: boolean;
};
export type RingCarouselProps = {
items: readonly RingCarouselItem[];
/** Ring radius in px. Omit to derive it from the card count and width. */
radius?: number;
/** Card width in px (before the fit-to-container scale). */
cardWidth?: number;
/** Card height = width × aspect. */
aspect?: number;
/** Downward viewing angle in deg — the "carousel on a table" tilt. */
tilt?: number;
autoRotate?: boolean;
/** Auto-rotation speed in deg/s. */
speed?: number;
/** Settle on the nearest card face after a drag or hover stop. */
snap?: boolean;
/** Show the far half of the ring (cards' backs) through the gaps. */
backfaceVisible?: boolean;
/** 0–1: how much cards darken as they turn away from the viewer. */
depthShading?: number;
/** Drag sensitivity in deg of rotation per px. */
dragSensitivity?: number;
/** Inertia decay per 60 fps frame; closer to 1 glides longer. */
friction?: number;
/** Soft glow pooled on the floor under the ring. */
floorShadow?: boolean;
/** Floor glow colour. */
glowColor?: string;
/** Previous / next buttons and the counter under the ring. */
showControls?: boolean;
/** Component height (number = px). The ring scales to fit inside it. */
height?: number | string;
/** Controlled front card. */
index?: number;
/** Uncontrolled initial front card. */
defaultIndex?: number;
/** Fires whenever a different card reaches the front. */
onIndexChange?: (index: number) => void;
/** Replaces the default card content; the card frame and shading stay. */
renderItem?: (item: RingCarouselItem, state: RingCarouselItemState) => ReactNode;
className?: string;
"aria-label"?: string;
};
const DEFAULT_CARD_WIDTH = 210;
const DEFAULT_ASPECT = 1.32;
const DEFAULT_TILT = 10;
const DEFAULT_SPEED = 10;
const DEFAULT_DEPTH_SHADING = 0.65;
const DEFAULT_DRAG_SENSITIVITY = 0.25;
const DEFAULT_FRICTION = 0.95;
const DEFAULT_HEIGHT = 460;
const DEFAULT_GLOW = "#8b5cf6";
/** Gap between neighbouring cards, as a fraction of the card width. */
const CARD_GAP = 0.14;
/** Inner padding of the viewport the ring is fitted into. */
const FIT_PADDING = 16;
/** Pointer travel before a press becomes a drag (keeps clicks clicks). */
const DRAG_THRESHOLD = 5;
/** Critically damped spring used for snapping and stepping. */
const SPRING_K = 90;
const SPRING_C = 2 * Math.sqrt(SPRING_K);
/** Time constant (s) of the auto-rotation easing in and out. */
const AUTO_EASE = 0.45;
/** Autoplay waits this long after a drag / step before easing back in. */
const RESUME_DELAY = 1800;
const MAX_RELEASE_VELOCITY = 720;
const mod = (n: number, m: number) => ((n % m) + m) % m;
function artBackground(image: string) {
return /gradient\(|^url\(/i.test(image.trim()) ? image : `url("${image}")`;
}
/** Deterministic fallback art (golden-angle hues) for items without an image. */
function fallbackArt(index: number) {
const hue = Math.round((index * 137.5) % 360);
return `radial-gradient(120% 80% at 20% 10%, hsl(${(hue + 40) % 360} 85% 70% / .9), transparent 60%), linear-gradient(160deg, hsl(${hue} 70% 45%), hsl(${(hue + 60) % 360} 70% 18%))`;
}
type Geometry = {
radius: number;
cardW: number;
cardH: number;
perspective: number;
/** Projected extent of the ring (incl. floor glow) at scale 1. */
extentW: number;
extentH: number;
/** Projected vertical centre of that extent, relative to the ring centre. */
centerY: number;
};
/**
* Projects the ring's extreme points through the tilt and perspective so the
* component can fit the whole composition into its box — the front card is
* magnified by perspective and the back row rises with the tilt, so the
* un-projected card size would misjudge both.
*/
function computeGeometry(
count: number,
cardW: number,
aspect: number,
tiltDeg: number,
radiusProp: number | undefined,
): Geometry {
const cardH = cardW * aspect;
const autoRadius =
count > 2 ? (cardW * (1 + CARD_GAP)) / (2 * Math.tan(Math.PI / count)) : cardW;
const radius = Math.max(radiusProp ?? autoRadius, cardW * 0.6);
// Scales with the ring so front/back size contrast stays the same at any
// count: front ≈ 1.35×, back ≈ 0.8×.
const perspective = radius * 3 + 400;
const t = (tiltDeg * Math.PI) / 180;
const cos = Math.cos(t);
const sin = Math.sin(t);
// rotateX(-tilt): y' = y·cos + z·sin, z' = z·cos − y·sin.
const projectY = (y: number, z: number) => {
const yp = y * cos + z * sin;
const zp = z * cos - y * sin;
return (yp * perspective) / (perspective - zp);
};
const floorY = cardH / 2 + 18;
const ys = [
projectY(-cardH / 2, radius),
projectY(cardH / 2, radius),
projectY(-cardH / 2, -radius),
projectY(cardH / 2, -radius),
// Front edge of the floor glow.
projectY(floorY, radius * 1.05),
];
const minY = Math.min(...ys);
const maxY = Math.max(...ys);
// Widest points: the side cards (edge-on at x = ±r, near edge at z = w/2)
// and the magnified front card.
const sideX = (radius * perspective) / (perspective - (cardW / 2) * cos);
const frontX = (cardW / 2) * (perspective / (perspective - radius * cos));
const extentW = 2 * Math.max(sideX + 8, frontX);
return {
radius,
cardW,
cardH,
perspective,
extentW,
extentH: maxY - minY,
centerY: (minY + maxY) / 2,
};
}
// useLayoutEffect warns during SSR in older React; the fit has to land
// before paint on the client to avoid a one-frame oversized ring.
const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
export function RingCarousel({
items,
radius: radiusProp,
cardWidth = DEFAULT_CARD_WIDTH,
aspect = DEFAULT_ASPECT,
tilt = DEFAULT_TILT,
autoRotate = true,
speed = DEFAULT_SPEED,
snap = true,
backfaceVisible = true,
depthShading = DEFAULT_DEPTH_SHADING,
dragSensitivity = DEFAULT_DRAG_SENSITIVITY,
friction = DEFAULT_FRICTION,
floorShadow = true,
glowColor = DEFAULT_GLOW,
showControls = true,
height = DEFAULT_HEIGHT,
index,
defaultIndex = 0,
onIndexChange,
renderItem,
className,
"aria-label": ariaLabel = "リングカルーセル",
}: RingCarouselProps) {
const count = Math.max(items.length, 1);
const step = 360 / count;
const geo = useMemo(
() => computeGeometry(count, cardWidth, aspect, tilt, radiusProp),
[count, cardWidth, aspect, tilt, radiusProp],
);
const rootRef = useRef<HTMLElement | null>(null);
const viewportRef = useRef<HTMLDivElement | null>(null);
const spinRef = useRef<HTMLDivElement | null>(null);
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
const shadeCacheRef = useRef<number[]>([]);
// The initial rotation is fixed for the component's lifetime so the
// inline transform React renders never changes between renders — React
// then never overwrites the transform the rAF loop writes.
const [initialIndex] = useState(() => mod(Math.round(index ?? defaultIndex), count));
const [initialRot] = useState(() => -initialIndex * step);
/** Step the current rotation was computed with (detects count changes). */
const stepSeenRef = useRef(step);
// Everything the loop reads lives in refs so the loop never restarts.
const rotationRef = useRef(initialRot);
const velocityRef = useRef(0); // deg/s, inertia + spring
const autoVelRef = useRef(0); // deg/s, eased auto-rotation
const targetRef = useRef<number | null>(null);
const resumeAtRef = useRef(0);
const draggingRef = useRef(false);
const hoveredRef = useRef(false);
const focusedRef = useRef(false);
const visibleRef = useRef(true);
const reducedRef = useRef(false);
const announceRef = useRef(false);
const activeRef = useRef(initialIndex);
const lastReportedRef = useRef(initialIndex);
const rafRef = useRef(0);
const resumeTimerRef = useRef(0);
const unmountedRef = useRef(false);
const lastTimeRef = useRef(0);
const pointerRef = useRef<{
id: number;
startX: number;
lastX: number;
lastT: number;
dragging: boolean;
} | null>(null);
const propsRef = useRef({
step,
count,
autoRotate,
speed,
snap,
depthShading,
dragSensitivity,
friction,
onIndexChange,
});
useEffect(() => {
propsRef.current = {
step,
count,
autoRotate,
speed,
snap,
depthShading,
dragSensitivity,
friction,
onIndexChange,
};
});
const [active, setActive] = useState(initialIndex);
const [announcement, setAnnouncement] = useState("");
const [isDragging, setIsDragging] = useState(false);
const itemsRef = useRef(items);
useEffect(() => {
itemsRef.current = items;
}, [items]);
// ------------------------------------------------------------------
// Frame writer: ring transform, per-card shading, active index
// ------------------------------------------------------------------
const paint = useCallback(() => {
const { step: stepDeg, count: n, depthShading: depth } = propsRef.current;
const rot = rotationRef.current;
const spin = spinRef.current;
if (spin) spin.style.transform = `rotateY(${rot}deg)`;
// Shading is a CSS var the face overlays read: one write per card, and
// only when the quantized value changes, so a slow spin costs a handful
// of style writes per second rather than one per card per frame.
const cards = cardRefs.current;
const cache = shadeCacheRef.current;
for (let i = 0; i < n; i += 1) {
const card = cards[i];
if (!card) continue;
const facing = Math.cos(((i * stepDeg + rot) * Math.PI) / 180); // 1 front, −1 back
const shade = Math.round(((depth * (1 - facing)) / 2) * 40) / 40;
if (cache[i] !== shade) {
cache[i] = shade;
card.style.setProperty("--rc-shade", String(shade));
}
}
const next = mod(Math.round(-rot / stepDeg), n);
if (next !== activeRef.current) {
activeRef.current = next;
lastReportedRef.current = next;
setActive(next);
if (announceRef.current) {
const item = itemsRef.current[next];
setAnnouncement(`${item?.title ? `${item.title} — ` : ""}${next + 1} / ${n}`);
}
propsRef.current.onIndexChange?.(next);
}
}, []);
const nearestFace = useCallback((rot: number) => {
const { step: stepDeg } = propsRef.current;
return Math.round(rot / stepDeg) * stepDeg;
}, []);
// ------------------------------------------------------------------
// Main loop — runs only while something moves and the ring is on screen
// ------------------------------------------------------------------
const tick = useCallback(
// Named so the frame can re-schedule itself without reading `tick`.
function frame(now: number) {
const p = propsRef.current;
const dt = Math.min((now - (lastTimeRef.current || now)) / 1000, 1 / 20);
lastTimeRef.current = now;
const autoAllowed =
p.autoRotate &&
!reducedRef.current &&
!draggingRef.current &&
!hoveredRef.current &&
!focusedRef.current &&
targetRef.current === null &&
now >= resumeAtRef.current;
// Eases in and out instead of starting/stopping dead — hovering
// reads as the carousel coasting to a halt under the cursor.
const autoGoal = autoAllowed ? p.speed : 0;
const prevAuto = autoVelRef.current;
autoVelRef.current += (autoGoal - prevAuto) * (1 - Math.exp(-dt / AUTO_EASE));
if (Math.abs(autoVelRef.current) < 0.05 && autoGoal === 0) {
autoVelRef.current = 0;
// Coasted to a stop on hover/focus: settle onto a face.
if (prevAuto !== 0 && p.snap && !draggingRef.current && targetRef.current === null) {
targetRef.current = nearestFace(rotationRef.current);
}
}
if (!draggingRef.current) {
rotationRef.current -= autoVelRef.current * dt;
const target = targetRef.current;
if (target !== null) {
const diff = target - rotationRef.current;
velocityRef.current += (diff * SPRING_K - velocityRef.current * SPRING_C) * dt;
rotationRef.current += velocityRef.current * dt;
if (Math.abs(diff) < 0.02 && Math.abs(velocityRef.current) < 0.5) {
rotationRef.current = target;
velocityRef.current = 0;
targetRef.current = null;
// Announcements belong to button/keyboard steps only; once the
// step lands, later auto-rotation passes stay silent.
announceRef.current = false;
}
} else if (Math.abs(velocityRef.current) > 0.5) {
rotationRef.current += velocityRef.current * dt;
velocityRef.current *= Math.pow(p.friction, dt * 60);
} else {
velocityRef.current = 0;
}
}
paint();
const moving =
draggingRef.current ||
targetRef.current !== null ||
velocityRef.current !== 0 ||
autoVelRef.current !== 0 ||
(autoAllowed && p.speed !== 0);
if (moving && visibleRef.current) {
rafRef.current = requestAnimationFrame(frame);
} else {
rafRef.current = 0;
}
},
[nearestFace, paint],
);
const kick = useCallback(() => {
if (rafRef.current || !visibleRef.current || unmountedRef.current) return;
lastTimeRef.current = 0;
rafRef.current = requestAnimationFrame(tick);
}, [tick]);
/** Holds autoplay for a moment after user input, then wakes the loop. */
const holdAutoplay = useCallback(() => {
resumeAtRef.current = performance.now() + RESUME_DELAY;
window.clearTimeout(resumeTimerRef.current);
resumeTimerRef.current = window.setTimeout(kick, RESUME_DELAY + 20);
}, [kick]);
const goTo = useCallback(
(to: number, announce: boolean) => {
const { step: stepDeg, count: n } = propsRef.current;
const desired = -mod(to, n) * stepDeg;
// Shortest way round from wherever the ring is now.
const cur = targetRef.current ?? rotationRef.current;
const target = desired + Math.round((cur - desired) / 360) * 360;
announceRef.current = announce;
holdAutoplay();
if (reducedRef.current) {
rotationRef.current = target;
velocityRef.current = 0;
targetRef.current = null;
paint();
announceRef.current = false;
return;
}
targetRef.current = target;
kick();
},
[holdAutoplay, kick, paint],
);
// Reduced motion, visibility, start/stop. Count or geometry changes
// re-run this so the new ring is painted immediately.
useEffect(() => {
unmountedRef.current = false;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const onMotion = () => {
reducedRef.current = mq.matches;
kick();
};
onMotion();
mq.addEventListener("change", onMotion);
const root = rootRef.current;
const io =
root && "IntersectionObserver" in window
? new IntersectionObserver(([entry]) => {
visibleRef.current = entry.isIntersecting;
if (entry.isIntersecting) kick();
})
: null;
if (root && io) io.observe(root);
return () => {
mq.removeEventListener("change", onMotion);
io?.disconnect();
// A pending autoplay-resume timer must not restart the loop later.
unmountedRef.current = true;
window.clearTimeout(resumeTimerRef.current);
cancelAnimationFrame(rafRef.current);
rafRef.current = 0;
};
}, [kick]);
// Prop changes that affect motion or the ring: repaint and wake the loop.
useEffect(() => {
shadeCacheRef.current = [];
// A count change remaps degrees to cards: keep the same card in front.
if (stepSeenRef.current !== step) {
const prevStep = stepSeenRef.current;
stepSeenRef.current = step;
const idx = Math.round(-rotationRef.current / prevStep);
rotationRef.current = -idx * step;
targetRef.current = null;
}
paint();
kick();
}, [step, depthShading, autoRotate, speed, snap, paint, kick]);
// Controlled index: only react when the parent asks for a card we did not
// just report — otherwise echoing onIndexChange back would fight the spin.
useEffect(() => {
if (index === undefined) return;
const want = mod(Math.round(index), count);
if (want === lastReportedRef.current) return;
lastReportedRef.current = want;
goTo(want, false);
}, [index, count, goTo]);
// Fit the projected ring into the viewport box (both axes).
useIsoLayoutEffect(() => {
const vp = viewportRef.current;
const root = rootRef.current;
if (!vp || !root) return;
const fit = () => {
const w = vp.clientWidth - FIT_PADDING * 2;
const h = vp.clientHeight - FIT_PADDING * 2;
if (w <= 0 || h <= 0) return;
const s = Math.min(1, w / geo.extentW, h / geo.extentH);
root.style.setProperty("--rc-fit", s.toFixed(4));
root.style.setProperty("--rc-center-y", `${(-geo.centerY * s).toFixed(2)}px`);
};
fit();
const ro = new ResizeObserver(fit);
ro.observe(vp);
return () => ro.disconnect();
}, [geo]);
// ------------------------------------------------------------------
// Pointer: drag with inertia, snap on release
// ------------------------------------------------------------------
const onPointerDown = (e: ReactPointerEvent<HTMLElement>) => {
if (e.button !== 0 || pointerRef.current) return;
// Don't hijack presses on the controls.
if ((e.target as HTMLElement).closest("button")) return;
pointerRef.current = {
id: e.pointerId,
startX: e.clientX,
lastX: e.clientX,
lastT: e.timeStamp,
dragging: false,
};
};
const onPointerMove = (e: ReactPointerEvent<HTMLElement>) => {
const ptr = pointerRef.current;
if (!ptr || ptr.id !== e.pointerId) return;
if (!ptr.dragging) {
if (Math.abs(e.clientX - ptr.startX) < DRAG_THRESHOLD) return;
// Capture only once it's really a drag, so clicks inside custom
// renderItem content keep working.
ptr.dragging = true;
draggingRef.current = true;
targetRef.current = null;
velocityRef.current = 0;
autoVelRef.current = 0;
setIsDragging(true);
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
/* pointer may already be gone */
}
kick();
}
const dx = e.clientX - ptr.lastX;
const dtMs = Math.max(e.timeStamp - ptr.lastT, 1);
ptr.lastX = e.clientX;
ptr.lastT = e.timeStamp;
const delta = dx * propsRef.current.dragSensitivity;
rotationRef.current += delta;
// Smoothed release velocity (deg/s): one jittery last event shouldn't
// decide the throw.
const instant = (delta / dtMs) * 1000;
velocityRef.current = velocityRef.current * 0.6 + instant * 0.4;
};
const endPointer = (e: ReactPointerEvent<HTMLElement>) => {
const ptr = pointerRef.current;
if (!ptr || ptr.id !== e.pointerId) return;
pointerRef.current = null;
if (!ptr.dragging) return;
draggingRef.current = false;
setIsDragging(false);
// A finger that stopped before lifting shouldn't fling.
if (e.timeStamp - ptr.lastT > 90) velocityRef.current = 0;
const p = propsRef.current;
const v = Math.max(-MAX_RELEASE_VELOCITY, Math.min(MAX_RELEASE_VELOCITY, velocityRef.current));
if (reducedRef.current) {
velocityRef.current = 0;
if (p.snap) rotationRef.current = nearestFace(rotationRef.current);
} else if (p.snap) {
// Snap where the inertia would have carried it, keeping the release
// velocity as the spring's start so the throw flows into the settle.
const f = Math.min(p.friction, 0.995);
const projected = rotationRef.current + (v / 60) * (f / (1 - f));
targetRef.current = nearestFace(projected);
velocityRef.current = v;
} else {
velocityRef.current = v;
}
announceRef.current = false;
holdAutoplay();
kick();
};
const onPointerEnter = (e: ReactPointerEvent<HTMLElement>) => {
// Touch has no hover: a tap must not freeze the autoplay.
if (e.pointerType !== "mouse") return;
hoveredRef.current = true;
kick();
};
const onPointerLeave = (e: ReactPointerEvent<HTMLElement>) => {
if (e.pointerType !== "mouse") return;
hoveredRef.current = false;
kick();
};
// Only keyboard focus pauses: clicking/dragging focuses the region too,
// and that must not stop the autoplay until the user clicks elsewhere.
const onFocus = (e: ReactFocusEvent<HTMLElement>) => {
if ((e.target as HTMLElement).matches(":focus-visible")) {
focusedRef.current = true;
kick();
}
};
const onBlur = (e: ReactFocusEvent<HTMLElement>) => {
if (rootRef.current?.contains(e.relatedTarget as Node | null)) return;
focusedRef.current = false;
kick();
};
const onKeyDown = (e: ReactKeyboardEvent<HTMLElement>) => {
const cur = activeRef.current;
switch (e.key) {
case "ArrowLeft":
e.preventDefault();
goTo(cur - 1, true);
break;
case "ArrowRight":
e.preventDefault();
goTo(cur + 1, true);
break;
case "Home":
e.preventDefault();
goTo(0, true);
break;
case "End":
e.preventDefault();
goTo(count - 1, true);
break;
default:
break;
}
};
// ------------------------------------------------------------------
// Render
// ------------------------------------------------------------------
const rootStyle: CSSVars = {
// Derived from the count, so always inline.
"--rc-step": `${step}deg`,
"--rc-radius": `${geo.radius}px`,
"--rc-perspective": `${geo.perspective}px`,
"--rc-floor-y": `${geo.cardH / 2 + 18}px`,
};
if (cardWidth !== DEFAULT_CARD_WIDTH) rootStyle["--rc-card-w"] = `${cardWidth}px`;
if (cardWidth !== DEFAULT_CARD_WIDTH || aspect !== DEFAULT_ASPECT)
rootStyle["--rc-card-h"] = `${geo.cardH}px`;
if (tilt !== DEFAULT_TILT) rootStyle["--rc-tilt"] = `${tilt}deg`;
if (glowColor !== DEFAULT_GLOW) rootStyle["--rc-glow"] = glowColor;
if (height !== DEFAULT_HEIGHT)
rootStyle["--rc-height"] = typeof height === "number" ? `${height}px` : height;
const rootClassName = [
styles.root,
isDragging ? styles.dragging : "",
showControls ? "" : styles.noControls,
className ?? "",
]
.filter(Boolean)
.join(" ");
const pad = (n: number) => String(n).padStart(2, "0");
return (
<section
ref={rootRef}
className={rootClassName}
style={rootStyle}
tabIndex={0}
aria-roledescription="カルーセル"
aria-label={ariaLabel}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endPointer}
onPointerCancel={endPointer}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={onKeyDown}
>
<div ref={viewportRef} className={styles.viewport}>
<div className={styles.scene}>
<div className={styles.tilt}>
{floorShadow && <div className={styles.floor} aria-hidden="true" />}
<div
ref={spinRef}
className={styles.spin}
style={{ transform: `rotateY(${initialRot}deg)` }}
>
{items.map((item, i) => {
const isActive = i === active;
const facing = Math.cos(((i * step + initialRot) * Math.PI) / 180);
const initialShade =
Math.round(((depthShading * (1 - facing)) / 2) * 40) / 40;
const state: RingCarouselItemState = {
index: i,
total: items.length,
active: isActive,
};
const art = item.image ? artBackground(item.image) : fallbackArt(i);
return (
<div
key={item.id ?? i}
ref={(node) => {
cardRefs.current[i] = node;
}}
className={styles.card}
style={{ "--i": i, "--rc-shade": initialShade } as CSSVars}
role="group"
aria-roledescription="スライド"
aria-label={`${i + 1} / ${items.length}`}
aria-hidden={!isActive}
inert={!isActive}
data-active={isActive || undefined}
>
<div className={`${styles.face} ${styles.front}`}>
{renderItem ? (
renderItem(item, state)
) : (
<div
className={styles.art}
style={{ backgroundImage: art }}
role={item.alt ? "img" : undefined}
aria-label={item.alt}
>
<span className={styles.chip} aria-hidden="true">
{pad(i + 1)}
</span>
{(item.title || item.subtitle) && (
<div className={styles.caption}>
{item.subtitle && (
<span className={styles.subtitle}>{item.subtitle}</span>
)}
{item.title && <span className={styles.title}>{item.title}</span>}
</div>
)}
</div>
)}
<span className={styles.shade} aria-hidden="true" />
</div>
{backfaceVisible && (
<div className={`${styles.face} ${styles.back}`} aria-hidden="true">
<div className={styles.backArt} style={{ backgroundImage: art }} />
<span className={styles.shade} />
</div>
)}
</div>
);
})}
</div>
</div>
</div>
</div>
{showControls && (
<div className={styles.controls}>
<button
type="button"
className={styles.navButton}
aria-label="前のスライド"
onClick={() => goTo(activeRef.current - 1, true)}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M14.5 6l-6 6 6 6" />
</svg>
</button>
<span className={styles.counter} aria-hidden="true">
<span className={styles.counterCurrent}>{pad(active + 1)}</span>
<span className={styles.counterSep} />
{pad(items.length)}
</span>
<button
type="button"
className={styles.navButton}
aria-label="次のスライド"
onClick={() => goTo(activeRef.current + 1, true)}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9.5 6l6 6-6 6" />
</svg>
</button>
</div>
)}
<div className={styles.srOnly} aria-live="polite" aria-atomic="true">
{announcement}
</div>
</section>
);
}
export default RingCarousel;
```
#### `components/ring-carousel/RingCarousel.module.css`
```css
/* ==========================================================================
Ring carousel — cards stand around a cylinder seen from OUTSIDE, like a
carousel on a table: the front card is large and bright, the far side
small, dim and (optionally) seen from behind through the gaps.
3D chain: .scene (perspective) → .tilt → .spin → .card → .face.
Every element above .face keeps `transform-style: preserve-3d`, so none
of them may get overflow / opacity / filter / isolation / mask — any of
those flattens the ring. Rounded corners, clipping and shading live on
the faces (leaves) only.
The component writes the ring's rotation (on .spin) and each card's
--rc-shade from a rAF loop; this file only places and paints.
========================================================================== */
.root {
/* Set inline only for non-default values, so the default render is
exactly this stylesheet. Radius / step / perspective are derived from
the card count and always come inline. */
--rc-height: 460px;
--rc-card-w: 210px;
--rc-card-h: 277.2px; /* 210 × 1.32 */
--rc-tilt: 10deg;
--rc-glow: #8b5cf6;
--rc-radius: 400px;
--rc-step: 36deg;
--rc-perspective: 1600px;
--rc-floor-y: 156.6px;
/* Fit-to-box scale + vertical centring, measured by the component. The
fallback keeps the SSR first paint inside a typical box. */
--rc-fit: 0.62;
--rc-center-y: 0px;
--rc-radius-card: 18px;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: var(--rc-height);
font-family:
"Inter", -apple-system, BlinkMacSystemFont, "Hiragino Sans",
"Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto,
sans-serif;
color: #fff;
user-select: none;
-webkit-user-select: none;
/* Horizontal drags turn the ring; vertical swipes still scroll the page. */
touch-action: pan-y;
cursor: grab;
outline: none;
border-radius: 20px;
}
.root:focus-visible {
box-shadow:
0 0 0 2px rgba(255, 255, 255, 0.85),
0 0 0 6px color-mix(in srgb, var(--rc-glow) 45%, transparent);
}
.dragging {
cursor: grabbing;
}
/* --------------------------------------------------------------------------
Viewport + 3D stack
-------------------------------------------------------------------------- */
.viewport {
position: relative;
flex: 1 1 auto;
min-height: 0;
}
/* A zero-size anchor at the viewport centre. Perspective and the fit scale
share this element: the ring is projected first, then scaled as a whole. */
.scene {
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
perspective: var(--rc-perspective);
perspective-origin: 0 0;
transform: translateY(var(--rc-center-y)) scale(var(--rc-fit));
}
.tilt {
position: absolute;
transform-style: preserve-3d;
/* Negative X rotation lifts the far side: the viewer looks down on it. */
transform: rotateX(calc(var(--rc-tilt) * -1));
}
.spin {
position: absolute;
transform-style: preserve-3d;
will-change: transform;
}
.card {
position: absolute;
left: calc(var(--rc-card-w) / -2);
top: calc(var(--rc-card-h) / -2);
width: var(--rc-card-w);
height: var(--rc-card-h);
transform-style: preserve-3d;
transform: rotateY(calc(var(--i) * var(--rc-step))) translateZ(var(--rc-radius));
}
/* --------------------------------------------------------------------------
Faces (leaves of the 3D tree — safe to clip and shade here)
-------------------------------------------------------------------------- */
.face {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: var(--rc-radius-card);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
background: #121216;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
0 18px 40px -18px rgba(0, 0, 0, 0.8);
}
.back {
transform: rotateY(180deg);
}
/* The front card gets a soft halo in the glow colour. */
.card[data-active] .front {
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.2),
0 26px 60px -22px color-mix(in srgb, var(--rc-glow) 70%, transparent),
0 18px 40px -18px rgba(0, 0, 0, 0.8);
}
.art {
position: absolute;
inset: 0;
background-color: #1a1a20;
background-size: cover;
background-position: center;
}
/* Film grain + a top sheen: keeps flat gradients from reading as clip-art. */
.art::before {
content: "";
position: absolute;
inset: 0;
background-image:
linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0) 32%),
url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='140' height='140'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 .55 0'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
opacity: 0.55;
pointer-events: none;
}
/* Bottom scrim so the caption stays legible on any art. */
.art::after {
content: "";
position: absolute;
inset: 45% 0 0;
background: linear-gradient(180deg, rgba(8, 8, 12, 0), rgba(8, 8, 12, 0.78));
pointer-events: none;
}
.chip {
position: absolute;
top: 12px;
left: 12px;
z-index: 1;
padding: 3px 8px;
border-radius: 999px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
font-variant-numeric: tabular-nums;
color: rgba(255, 255, 255, 0.88);
background: rgba(10, 10, 14, 0.32);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
}
.caption {
position: absolute;
inset: auto 14px 14px;
z-index: 1;
display: flex;
flex-direction: column;
gap: 3px;
}
.subtitle {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.62);
}
.title {
font-size: 16px;
font-weight: 600;
line-height: 1.25;
letter-spacing: -0.01em;
text-wrap: balance;
}
/* The back of a card: the same art, flattened and darker, no text — it
reads as the reverse side rather than mirrored content. */
.backArt {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
transform: scaleX(-1);
filter: saturate(0.7) brightness(0.55);
}
/* Depth shading: --rc-shade (0 front → depthShading back) comes from the
component's loop. Opacity on a leaf layer is compositor-cheap. */
.shade {
position: absolute;
inset: 0;
z-index: 2;
background: #050507;
opacity: var(--rc-shade, 0);
pointer-events: none;
}
/* --------------------------------------------------------------------------
Floor glow — a flat disc lying under the ring, so it foreshortens with
the tilt like a real pool of light on a table.
-------------------------------------------------------------------------- */
.floor {
position: absolute;
left: calc(var(--rc-radius) * -1.25);
top: calc(var(--rc-radius) * -1.25);
width: calc(var(--rc-radius) * 2.5);
height: calc(var(--rc-radius) * 2.5);
border-radius: 50%;
transform: translateY(var(--rc-floor-y)) rotateX(90deg);
background:
radial-gradient(closest-side, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0) 62%),
radial-gradient(
closest-side,
color-mix(in srgb, var(--rc-glow) 55%, transparent),
color-mix(in srgb, var(--rc-glow) 18%, transparent) 55%,
transparent 100%
);
pointer-events: none;
}
/* --------------------------------------------------------------------------
Controls
-------------------------------------------------------------------------- */
.controls {
flex: 0 0 56px;
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
cursor: default;
}
.noControls .viewport {
flex-basis: 100%;
}
.navButton {
display: grid;
place-items: center;
width: 36px;
height: 36px;
padding: 0;
border: 0;
border-radius: 999px;
color: rgba(255, 255, 255, 0.9);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.12), rgba(255, 255, 255, 0.04));
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.22),
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
0 6px 18px rgba(0, 0, 0, 0.35);
cursor: pointer;
transition:
transform 140ms ease,
background-color 140ms ease,
box-shadow 140ms ease;
}
.navButton svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
@media (hover: hover) {
.navButton:hover {
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.3),
inset 0 0 0 1px rgba(255, 255, 255, 0.22),
0 6px 22px color-mix(in srgb, var(--rc-glow) 35%, transparent);
}
}
.navButton:active {
transform: scale(0.94);
}
.navButton:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.9);
outline-offset: 2px;
}
.counter {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 76px;
justify-content: center;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.08em;
font-variant-numeric: tabular-nums;
color: rgba(255, 255, 255, 0.45);
}
.counterCurrent {
color: #fff;
}
.counterSep {
width: 18px;
height: 1px;
background: rgba(255, 255, 255, 0.3);
}
.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) {
.navButton {
transition: none;
}
.spin {
will-change: auto;
}
}
```
代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/ring-carousel.json
```
### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { RingCarousel } from "@/components/ring-carousel";
// TODO: items はプレビューと同じ仮のグラデーション作品です。実データ(id / title / subtitle / image / alt)に差し替えてください。image は画像 URL か CSS の background 値
// TODO: 高さは height(既定 460px)で決まり、リングはその枠に収まるよう自動で縮小されます
// TODO: 正面のカードを受け取るには onIndexChange={(i) => …}、外から動かすには index={i} を渡します
<RingCarousel
cardWidth={210}
tilt={10}
backfaceVisible={true}
depthShading={0.65}
autoRotate={true}
snap={true}
speed={10}
dragSensitivity={0.25}
friction={0.95}
items={[
{
id: "dusk",
title: "夕凪の海",
subtitle: "Shonan · 18:42",
image: "radial-gradient(60% 38% at 50% 62%, #ffd27a 0%, rgba(255,150,90,.6) 40%, transparent 72%), linear-gradient(180deg, #2b1b4d 0%, #b0446b 46%, #ff9a6b 60%, #3a2a5e 61%, #120f2a 100%)",
},
{
id: "aurora",
title: "北の空のオーロラ",
subtitle: "Tromsø · 01:15",
image: "radial-gradient(80% 40% at 30% 30%, rgba(72,255,190,.75), transparent 65%), radial-gradient(60% 35% at 75% 22%, rgba(140,110,255,.7), transparent 70%), linear-gradient(180deg, #050b1f 0%, #0b2a3a 55%, #071018 72%, #02040a 100%)",
},
{
id: "dunes",
title: "砂丘の稜線",
subtitle: "Sahara · 06:30",
image: "radial-gradient(40% 25% at 70% 20%, #fff2c4, transparent 70%), linear-gradient(165deg, transparent 52%, rgba(90,30,10,.55) 52.5%, transparent 75%), linear-gradient(180deg, #f7c98b 0%, #ee9a5a 40%, #c8582f 62%, #7a2d18 100%)",
},
{
id: "neon",
title: "雨上がりのネオン",
subtitle: "Shinjuku · 23:08",
image: "radial-gradient(30% 40% at 25% 40%, rgba(255,60,170,.8), transparent 70%), radial-gradient(28% 45% at 78% 35%, rgba(40,200,255,.75), transparent 70%), linear-gradient(180deg, #0a0618 0%, #1a0b2e 60%, #2a0f3a 78%, #08040f 100%)",
},
{
id: "forest",
title: "朝霧の森",
subtitle: "Yakushima · 05:50",
image: "radial-gradient(70% 45% at 50% 35%, rgba(230,255,235,.55), transparent 70%), linear-gradient(90deg, rgba(5,30,20,.6) 0 8%, transparent 8% 22%, rgba(5,30,20,.5) 22% 27%, transparent 27% 70%, rgba(5,30,20,.55) 70% 78%, transparent 78%), linear-gradient(180deg, #a9cdb8 0%, #4f8a6c 45%, #1d4a36 75%, #0b2219 100%)",
},
{
id: "glacier",
title: "氷河の青",
subtitle: "Patagonia · 14:20",
image: "linear-gradient(160deg, transparent 45%, rgba(255,255,255,.35) 46%, transparent 60%), radial-gradient(70% 50% at 50% 70%, #7fe0ff, transparent 70%), linear-gradient(180deg, #dff6ff 0%, #8fd3f0 35%, #2b7fb8 65%, #0b2c52 100%)",
},
{
id: "lavender",
title: "ラベンダー畑",
subtitle: "Furano · 16:05",
image: "radial-gradient(50% 30% at 50% 28%, #ffe1f0, transparent 70%), repeating-linear-gradient(100deg, rgba(60,20,90,.35) 0 6px, transparent 6px 18px), linear-gradient(180deg, #f6c6e0 0%, #c69cf0 38%, #7b4fc9 58%, #3a1f6b 100%)",
},
{
id: "canyon",
title: "赤い峡谷",
subtitle: "Arizona · 17:40",
image: "radial-gradient(45% 30% at 30% 15%, #ffe0b0, transparent 70%), linear-gradient(170deg, transparent 40%, rgba(60,10,5,.45) 40.5%, transparent 58%), linear-gradient(195deg, transparent 55%, rgba(60,10,5,.5) 55.5%, transparent 80%), linear-gradient(180deg, #ffb27a 0%, #d9573a 40%, #8e2a1c 70%, #3a0f0b 100%)",
},
{
id: "tide",
title: "深海のグラデーション",
subtitle: "Okinawa · 11:00",
image: "radial-gradient(60% 30% at 50% 0%, rgba(200,255,250,.8), transparent 70%), repeating-linear-gradient(170deg, rgba(255,255,255,.07) 0 2px, transparent 2px 22px), linear-gradient(180deg, #4fe0d8 0%, #148fb0 35%, #0a4a7a 65%, #031a33 100%)",
},
{
id: "sakura",
title: "夜桜",
subtitle: "Kyoto · 20:30",
image: "radial-gradient(35% 30% at 30% 35%, rgba(255,190,215,.85), transparent 70%), radial-gradient(30% 28% at 72% 28%, rgba(255,215,230,.7), transparent 70%), radial-gradient(25% 25% at 55% 55%, rgba(255,160,200,.6), transparent 70%), linear-gradient(180deg, #160c22 0%, #2e1538 60%, #0d0814 100%)",
},
]}
aria-label="フォトギャラリー"
/>
```
### 4. 組み込み手順
1. `"use client"` のコンポーネント(またはそのままページ)に `<RingCarousel items={items} aria-label="…" />` を置く。`items` は `{ id?, title?, subtitle?, image?, alt? }[]`。`image` は画像 URL か CSS の background 値(`linear-gradient(…)` / `url(…)`)。画像を使う場合は `alt` も渡す。
2. 高さは `height`(既定 460px、数値は px、`"100%"` なども可)で決まる。リングは枚数・カード幅・傾きから投影後の大きさを計算し、その枠に収まるよう自動で縮小される。幅は親いっぱい(100%)。
3. 半径は省略すると「カード幅 × 1.14 ÷ (2·tan(π/枚数))」で隣のカードと重ならない値になる。固定したい場合だけ `radius` を渡す。
4. 正面のカードを知りたい場合は `onIndexChange`、外から動かす場合は `index`(制御)、初期位置だけなら `defaultIndex`。自動回転中も正面が変わるたびに呼ばれる。
5. カードの中身を自作するなら `renderItem={(item, { index, total, active }) => …}`。角丸・陰影・裏面はそのまま適用される。
6. 背景は暗い面を想定している(床の光 `glowColor` が映える)。明るいページに置く場合は親に暗い背景を敷く。
7. `npm run build` が通ることを確認し、実機で「横ドラッグで回り、縦スワイプではページがスクロールする」「ホバーでゆっくり止まって正面に収まる」「← / → で 1 枚ずつ進む」を確認する。
#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| 奥のカードが消える/裏面が見えない/カードが平面に潰れる | `.scene` 以下(`.tilt` `.spin` `.card`)に `overflow: hidden`・`opacity`・`filter`・`isolation`・`mask` などを足すと `preserve-3d` が強制的に flat になる | 角丸・クリップ・陰影は **面(`.face`)とその子だけ** に付ける。陰影は `--rc-shade` を読む `.shade` レイヤーの opacity で行う |
| 縦スクロールできない(スマホ) | ルートの `touch-action` を `none` にした | `touch-action: pan-y` のまま。横方向だけをドラッグとして拾っている |
| 回転がカクつく | 毎フレーム React の state を更新している | 回転は rAF ループから `.spin` の `transform` へ直接書く。state は正面のカードが変わったときだけ |
| リングがはみ出す/小さすぎる | vw/vh でサイズを決めた | 枠の実寸を ResizeObserver で測り `--rc-fit` に書く方式を外さない |
| 親が index を返すと回転がガタつく | `onIndexChange` で受けた値をそのまま `index` に戻すと、自動回転と取り合いになる | 同梱の「最後に通知した index と同じなら無視する」判定を外さない |
| 視差効果を減らす設定でも回り続ける | 自動回転を CSS ではなく JS で動かしている | `prefers-reduced-motion` を見て自動回転・慣性・スプリングを止める処理を外さない |
### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる
## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- Next.js(App Router)、React 19、TypeScript、CSS Modules。外部依存なし(純粋な CSS 3D + rAF)
- ファイル: `components/ring-carousel/RingCarousel.tsx`(`"use client"`)+ `RingCarousel.module.css` + `index.ts`
- props: `items`、`radius?`、`cardWidth`(210)、`aspect`(1.32)、`tilt`(10deg)、`autoRotate`(true)、`speed`(10 deg/s)、`snap`(true)、`backfaceVisible`(true)、`depthShading`(0.65)、`dragSensitivity`(0.25 deg/px)、`friction`(0.95 / 60fps フレーム)、`floorShadow`(true)、`glowColor`(#8b5cf6)、`showControls`(true)、`height`(460)、`index` / `defaultIndex` / `onIndexChange`、`renderItem`、`className`、`aria-label`
### 見た目
- 3D 構造: `.scene`(幅高さ 0 で枠の中央に置くアンカー、`perspective = 半径 × 3 + 400px`、`transform: translateY(中心補正) scale(fit)`)→ `.tilt`(`rotateX(-tilt)`、奥の列が持ち上がる)→ `.spin`(`rotateY(回転)`)→ `.card`(`rotateY(i × 360/n) translateZ(半径)`)→ 表面 / 裏面(`rotateY(180deg)`)。`.face` 以外はすべて `transform-style: preserve-3d`
- 正面のカードは遠近で約 1.35 倍、真裏は約 0.8 倍に見える
- fit: 手前カードの上下端・奥カードの上下端・床の手前端・左右端のカードを透視投影して外接矩形を求め、`min(1, 枠幅 / 幅, 枠高 / 高さ)` を `--rc-fit` に書く(ResizeObserver)
- 既定のカード: 角丸 18px、画像は `background-size: cover`、上部に白の反射グラデーション + SVG ノイズのグレイン、下 55% に暗いスクリム。左上に番号チップ(01、ガラス調ピル)、左下にサブタイトル(10px・大文字・字間 .12em・白 62%)とタイトル(16px / 600)
- 裏面: 同じ画像を左右反転し `saturate(.7) brightness(.55)`、文字なし。`backfaceVisible={false}` では裏面を描かず手前半分だけが見える
- 陰影: カードごとに `facing = cos(i × step + 回転)`、`shade = depthShading × (1 − facing) / 2` を 1/40 刻みで `--rc-shade` に書き、面内の黒い `.shade` レイヤーの opacity にする
- 正面カードは glowColor の柔らかいハロー(`0 26px 60px -22px`)
- 床: 直径 2.5 × 半径の円を `translateY(カード高/2 + 18px) rotateX(90deg)` で寝かせ、中心が黒、外側が glowColor の放射グラデーション。傾きに合わせて楕円に見える
- 下部に操作列(高さ 56px): 36px の丸いガラスボタン(前 / 次)と「03 — 10」のカウンター(等幅数字)
### モーション
- rAF ループ(時間ベース)。自動回転は `speed` deg/s へ時定数 0.45s で指数的に近づき、ホバー(マウスのみ)・キーボードフォーカス中・ドラッグ中・操作後 1.8 秒は 0 へ同じ速さで減速する
- `snap` 有効時、ホバーで止まりきったら最寄りのカード面へ収まる
- ドラッグ: 5px 動いてからドラッグ扱い(そこで pointer capture)。`回転 += dx × dragSensitivity`、速度は deg/s を指数平滑。離した瞬間 90ms 以上止まっていたら速度 0
- 離したとき: `snap` なら慣性で止まる予定位置 `回転 + v/60 × f/(1−f)` に最も近い面を目標にし、離した速度を初速とした臨界減衰スプリング(k = 90)で収める。`snap` なしなら `v *= friction^(dt×60)` で減速
- キー / ボタン: 1 枚ずつ(最短回り)同じスプリングで移動
- 停止中・画面外(IntersectionObserver)ではループ自体を止める
- `prefers-reduced-motion: reduce`: 自動回転なし、慣性なし、キー / ボタン / スナップは即座に切り替え
### アクセシビリティ
- ルート: `<section tabIndex={0} aria-roledescription="カルーセル" aria-label={…}>`、`:focus-visible` で白 2px + glowColor のリング
- 各カード: `role="group" aria-roledescription="スライド" aria-label="n / total"`。正面以外は `aria-hidden` + `inert`
- ← / → で 1 枚、Home / End で先頭 / 末尾(`preventDefault` でページスクロールを止める)
- ボタン・キー操作で正面が変わったときだけ `aria-live="polite"` の領域に「タイトル — n / total」を読み上げる(自動回転では読み上げない)
- 画像カードは `alt` があれば `role="img" aria-label={alt}`
- マウスクリックやタッチでフォーカスしても自動回転は止めない(`:focus-visible` のときだけ止める)
### 受け入れ条件
- 初回描画から手前のカードが大きく明るく、奥のカードが小さく暗く、裏側が隙間から透けて見える
- 自動でゆっくり回り、ホバーすると滑らかに減速して正面に収まる
- 横ドラッグで回り、勢いよく離すと慣性で回ってからカード面に収まる。縦スワイプではページがスクロールする
- ← / → / ボタンで 1 枚ずつ進み、カウンターと読み上げが更新される
- 枠のサイズを変えてもリングがはみ出さない(vw/vh を使わない)インストール
npx shadcn@latest add https://anima-js.vercel.app/r/ring-carousel.json生成コード
import { RingCarousel } from "@/components/ring-carousel";
// TODO: items はプレビューと同じ仮のグラデーション作品です。実データ(id / title / subtitle / image / alt)に差し替えてください。image は画像 URL か CSS の background 値
// TODO: 高さは height(既定 460px)で決まり、リングはその枠に収まるよう自動で縮小されます
// TODO: 正面のカードを受け取るには onIndexChange={(i) => …}、外から動かすには index={i} を渡します
<RingCarousel
cardWidth={210}
tilt={10}
backfaceVisible={true}
depthShading={0.65}
autoRotate={true}
snap={true}
speed={10}
dragSensitivity={0.25}
friction={0.95}
items={[
{
id: "dusk",
title: "夕凪の海",
subtitle: "Shonan · 18:42",
image: "radial-gradient(60% 38% at 50% 62%, #ffd27a 0%, rgba(255,150,90,.6) 40%, transparent 72%), linear-gradient(180deg, #2b1b4d 0%, #b0446b 46%, #ff9a6b 60%, #3a2a5e 61%, #120f2a 100%)",
},
{
id: "aurora",
title: "北の空のオーロラ",
subtitle: "Tromsø · 01:15",
image: "radial-gradient(80% 40% at 30% 30%, rgba(72,255,190,.75), transparent 65%), radial-gradient(60% 35% at 75% 22%, rgba(140,110,255,.7), transparent 70%), linear-gradient(180deg, #050b1f 0%, #0b2a3a 55%, #071018 72%, #02040a 100%)",
},
{
id: "dunes",
title: "砂丘の稜線",
subtitle: "Sahara · 06:30",
image: "radial-gradient(40% 25% at 70% 20%, #fff2c4, transparent 70%), linear-gradient(165deg, transparent 52%, rgba(90,30,10,.55) 52.5%, transparent 75%), linear-gradient(180deg, #f7c98b 0%, #ee9a5a 40%, #c8582f 62%, #7a2d18 100%)",
},
{
id: "neon",
title: "雨上がりのネオン",
subtitle: "Shinjuku · 23:08",
image: "radial-gradient(30% 40% at 25% 40%, rgba(255,60,170,.8), transparent 70%), radial-gradient(28% 45% at 78% 35%, rgba(40,200,255,.75), transparent 70%), linear-gradient(180deg, #0a0618 0%, #1a0b2e 60%, #2a0f3a 78%, #08040f 100%)",
},
{
id: "forest",
title: "朝霧の森",
subtitle: "Yakushima · 05:50",
image: "radial-gradient(70% 45% at 50% 35%, rgba(230,255,235,.55), transparent 70%), linear-gradient(90deg, rgba(5,30,20,.6) 0 8%, transparent 8% 22%, rgba(5,30,20,.5) 22% 27%, transparent 27% 70%, rgba(5,30,20,.55) 70% 78%, transparent 78%), linear-gradient(180deg, #a9cdb8 0%, #4f8a6c 45%, #1d4a36 75%, #0b2219 100%)",
},
{
id: "glacier",
title: "氷河の青",
subtitle: "Patagonia · 14:20",
image: "linear-gradient(160deg, transparent 45%, rgba(255,255,255,.35) 46%, transparent 60%), radial-gradient(70% 50% at 50% 70%, #7fe0ff, transparent 70%), linear-gradient(180deg, #dff6ff 0%, #8fd3f0 35%, #2b7fb8 65%, #0b2c52 100%)",
},
{
id: "lavender",
title: "ラベンダー畑",
subtitle: "Furano · 16:05",
image: "radial-gradient(50% 30% at 50% 28%, #ffe1f0, transparent 70%), repeating-linear-gradient(100deg, rgba(60,20,90,.35) 0 6px, transparent 6px 18px), linear-gradient(180deg, #f6c6e0 0%, #c69cf0 38%, #7b4fc9 58%, #3a1f6b 100%)",
},
{
id: "canyon",
title: "赤い峡谷",
subtitle: "Arizona · 17:40",
image: "radial-gradient(45% 30% at 30% 15%, #ffe0b0, transparent 70%), linear-gradient(170deg, transparent 40%, rgba(60,10,5,.45) 40.5%, transparent 58%), linear-gradient(195deg, transparent 55%, rgba(60,10,5,.5) 55.5%, transparent 80%), linear-gradient(180deg, #ffb27a 0%, #d9573a 40%, #8e2a1c 70%, #3a0f0b 100%)",
},
{
id: "tide",
title: "深海のグラデーション",
subtitle: "Okinawa · 11:00",
image: "radial-gradient(60% 30% at 50% 0%, rgba(200,255,250,.8), transparent 70%), repeating-linear-gradient(170deg, rgba(255,255,255,.07) 0 2px, transparent 2px 22px), linear-gradient(180deg, #4fe0d8 0%, #148fb0 35%, #0a4a7a 65%, #031a33 100%)",
},
{
id: "sakura",
title: "夜桜",
subtitle: "Kyoto · 20:30",
image: "radial-gradient(35% 30% at 30% 35%, rgba(255,190,215,.85), transparent 70%), radial-gradient(30% 28% at 72% 28%, rgba(255,215,230,.7), transparent 70%), radial-gradient(25% 25% at 55% 55%, rgba(255,160,200,.6), transparent 70%), linear-gradient(180deg, #160c22 0%, #2e1538 60%, #0d0814 100%)",
},
]}
aria-label="フォトギャラリー"
/>