カルーセル
WheelCarousel
CSS 3D
iOS のピッカーホイールのような縦向き 3D ドラム — 円筒の外周に並んだ行が回り、中央のガラス帯で止まります。ドラッグ・ホイール・トラックパッドの慣性とスナップ、キーボード操作。テキスト行とサムネイル付きカード行、横に並べて日時ピッカーにも。純粋な CSS 3D。
Reservation
ご来店日時
6月14日(日)18:30· 2名
Playlist
Late Night
再生中 · Night Drive
AI に貼るだけで導入Claude Code / Cursor / ChatGPT などにそのまま貼り付けてください。ソースコード一式と現在の設定、組み込み手順が含まれています(約43 KB)。
# WheelCarousel をこのプロジェクトに追加してください
このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。
## コンポーネントについて
- 名前: WheelCarousel
- 説明: iOS のピッカーホイールのような縦向き 3D ドラム — 円筒の外周に並んだ行が回り、中央のガラス帯で止まります。ドラッグ・ホイール・トラックパッドの慣性とスナップ、キーボード操作。テキスト行とサムネイル付きカード行、横に並べて日時ピッカーにも。純粋な CSS 3D。
- 構成ファイル: `components/wheel-carousel/index.tsx`, `components/wheel-carousel/WheelCarousel.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/wheel-carousel/index.tsx`
```tsx
"use client";
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
type CSSProperties,
type KeyboardEvent as ReactKeyboardEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import styles from "./WheelCarousel.module.css";
/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;
export type WheelCarouselItem = {
/** Stable id; falls back to the row position. */
id?: string;
/** Row label. Also the option's accessible name. */
title?: string;
/** Card variant: second line under the title. */
subtitle?: string;
/**
* Card variant thumbnail. A CSS gradient (`linear-gradient(…)`) or
* `url(…)` is used as-is; anything else is treated as an image URL.
*/
image?: string;
/** Accessible name override (e.g. when `title` is abbreviated). */
alt?: string;
};
export type WheelCarouselRenderState = {
/** 0-based position of this item in `items`. */
index: number;
total: number;
/** The committed (centre-band) row. Updates once per settle decision. */
selected: boolean;
variant: WheelCarouselVariant;
};
export type WheelCarouselVariant = "text" | "card";
export type WheelCarouselProps = {
/** Rows. Plain strings are shorthand for `{ title }`. */
items: readonly (WheelCarouselItem | string)[];
/** Controlled selected index. Changing it glides the wheel there. */
index?: number;
/** Uncontrolled initial index. */
defaultIndex?: number;
/** Fires once per decision (release, key, click, wheel settle) — never per frame. */
onIndexChange?: (index: number) => void;
/** "text" = picker row, "card" = thumbnail + title + subtitle. */
variant?: WheelCarouselVariant;
/** Row pitch in px at the centre band. Default 40 (text) / 64 (card). */
rowHeight?: number;
/** Rows visible across the drum (odd; even values round up). Default 5. */
visibleRows?: number;
/** Infinite wrap. Needs at least `visibleRows + 2` items, else it stays bounded. */
loop?: boolean;
/** CSS perspective in px. Smaller = stronger curvature. */
perspective?: number;
/** Glass band with hairlines behind the selected row. */
highlight?: boolean;
/** Inertia decay per frame (0.8–0.98). Closer to 1 = longer flicks. */
friction?: number;
/** Replaces the default row content; the 3D pose and dimming stay. */
renderItem?: (item: WheelCarouselItem, state: WheelCarouselRenderState) => ReactNode;
className?: string;
"aria-label"?: string;
};
const DEFAULT_VISIBLE_ROWS = 5;
const DEFAULT_ROW_HEIGHT: Record<WheelCarouselVariant, number> = {
text: 40,
card: 64,
};
const DEFAULT_PERSPECTIVE = 500;
const DEFAULT_FRICTION = 0.94;
/** Px of pointer travel before a press becomes a drag instead of a tap. */
const TAP_SLOP = 5;
/** Past either end (no loop) the drag only moves this fraction of the finger. */
const RUBBER_BAND = 0.35;
/** A drag that paused this long before release carries no fling velocity. */
const FLING_STALE_MS = 90;
/** Trackpad scrolling settles to a row once it has been quiet this long. */
const WHEEL_IDLE_MS = 140;
/** A single wheel event at least this large is a mouse notch → one row. */
const WHEEL_NOTCH_PX = 50;
/** Floor for the settle spring (1/ms) so taps and keys never feel sluggish. */
const MIN_SETTLE_OMEGA = 0.011;
const mod = (n: number, m: number) => ((n % m) + m) % m;
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
function normalize(item: WheelCarouselItem | string): WheelCarouselItem {
return typeof item === "string" ? { title: item } : item;
}
function thumbBackground(image: string) {
return /gradient\(|^url\(/i.test(image.trim()) ? image : `url("${image}")`;
}
/** Deterministic placeholder art per row (golden-angle hues). */
function placeholderArt(index: number) {
const hue = Math.round((index * 137.5 + 260) % 360);
return `linear-gradient(140deg, hsl(${hue} 80% 62%), hsl(${(hue + 48) % 360} 70% 34%))`;
}
/**
* Signed row distance from the wheel position. In loop mode it wraps into
* [-n/2, n/2) so the drum shows the nearest copy of each row.
*/
function rowOffset(i: number, pos: number, n: number, loop: boolean) {
const d = i - pos;
return loop ? mod(d + n / 2, n) - n / 2 : d;
}
type Glide = {
/** Spring start time, position error and velocity (rows/ms). */
t0: number;
e0: number;
v0: number;
target: number;
omega: number;
};
/** Velocity (rows/ms) of a running glide, so retargeting stays smooth. */
function glideVelocity(g: Glide | null, now: number) {
if (!g) return 0;
const t = now - g.t0;
return (g.v0 - g.omega * (g.v0 + g.omega * g.e0) * t) * Math.exp(-g.omega * t);
}
export function WheelCarousel({
items,
index,
defaultIndex = 0,
onIndexChange,
variant = "text",
rowHeight,
visibleRows = DEFAULT_VISIBLE_ROWS,
loop = false,
perspective,
highlight = true,
friction = DEFAULT_FRICTION,
renderItem,
className,
"aria-label": ariaLabel,
}: WheelCarouselProps) {
const baseId = useId();
const n = items.length;
const visible = Math.max(1, Math.round(visibleRows) | 1);
// Loop needs enough rows to fill the window without the same row having
// to appear twice; below that the wheel stays bounded instead of gapping.
const looping = loop && n >= visible + 2;
const rowH = rowHeight ?? DEFAULT_ROW_HEIGHT[variant];
// Rows beyond this offset are edge-on (90°) or behind the drum.
const range = (visible + 2) / 2;
const controlled = index !== undefined;
const [innerIndex, setInnerIndex] = useState(() =>
clamp(Math.round(defaultIndex), 0, Math.max(n - 1, 0)),
);
const current = n ? clamp(Math.round(controlled ? index : innerIndex), 0, n - 1) : 0;
const [announce, setAnnounce] = useState("");
const viewportRef = useRef<HTMLDivElement | null>(null);
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
// The pose the rows were first rendered with. It never changes, so React
// never rewrites the per-row vars and JS owns them after mount.
const [initialPos] = useState(current);
// Physics state lives in refs: nothing per-frame goes through React.
const posRef = useRef(current);
const targetRef = useRef(current);
const glideRef = useRef<Glide | null>(null);
const rafRef = useRef(0);
const dragRef = useRef<{
pointerId: number;
startY: number;
lastY: number;
lastT: number;
velocity: number;
dragging: boolean;
tapIndex: number | null;
} | null>(null);
const wheelTimerRef = useRef(0);
const reduceMotionRef = useRef(false);
// Latest props for callbacks that outlive a render (rAF, native listeners).
// Synced in a layout effect declared before every other effect, so they
// all read this render's values.
const live = useRef({ n, looping, rowH, friction, range, controlled, onIndexChange, items });
useLayoutEffect(() => {
live.current = { n, looping, rowH, friction, range, controlled, onIndexChange, items };
});
/** Writes every row's pose for the current position. One var pair per row. */
const paint = useCallback(() => {
const { n: count, looping: wrap, range: r } = live.current;
const pos = posRef.current;
const rows = rowRefs.current;
for (let i = 0; i < count; i += 1) {
const row = rows[i];
if (!row) continue;
const d = rowOffset(i, pos, count, wrap);
const off = Math.abs(d) >= r;
// Off rows park at the edge so they never overlap the front face.
const shown = off ? Math.sign(d) * r : d;
row.style.setProperty("--d", shown.toFixed(4));
row.style.setProperty("--a", Math.min(Math.abs(d), 1).toFixed(3));
if (off) row.dataset.off = "";
else delete row.dataset.off;
}
}, []);
const stopGlide = useCallback(() => {
cancelAnimationFrame(rafRef.current);
rafRef.current = 0;
glideRef.current = null;
}, []);
/** Springs from the current position/velocity to an absolute row target. */
const glideTo = useCallback(
(target: number, velocity: number) => {
targetRef.current = target;
cancelAnimationFrame(rafRef.current);
if (reduceMotionRef.current) {
glideRef.current = null;
rafRef.current = 0;
posRef.current = target;
paint();
return;
}
// The friction knob maps to the decay rate a flick would have; the
// spring never runs slower than MIN_SETTLE_OMEGA.
const omegaFriction = -Math.log(clamp(live.current.friction, 0.5, 0.995)) / 16.667;
const g: Glide = {
t0: performance.now(),
e0: posRef.current - target,
v0: velocity,
target,
omega: Math.max(omegaFriction, MIN_SETTLE_OMEGA),
};
glideRef.current = g;
const step = (now: number) => {
if (glideRef.current !== g) return;
// Critically damped spring, solved analytically: no overshoot and
// frame-rate independent.
const t = Math.max(now - g.t0, 0);
const decay = Math.exp(-g.omega * t);
const e = (g.e0 + (g.v0 + g.omega * g.e0) * t) * decay;
const v = (g.v0 - g.omega * (g.v0 + g.omega * g.e0) * t) * decay;
if (Math.abs(e) < 0.0015 && Math.abs(v) < 0.00005) {
posRef.current = g.target;
glideRef.current = null;
rafRef.current = 0;
paint();
return;
}
posRef.current = g.target + e;
paint();
rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
},
[paint],
);
/** Turns an absolute row target into an item index and reports it. */
const commit = useCallback((target: number, source: "keyboard" | "pointer") => {
const { n: count, controlled: isControlled, onIndexChange: cb, items: list } = live.current;
if (!count) return;
const next = mod(Math.round(target), count);
if (!isControlled) setInnerIndex(next);
// The listbox's aria-activedescendant already speaks keyboard moves;
// pointer and wheel changes would otherwise be silent.
if (source === "pointer") {
const item = normalize(list[next]);
setAnnounce(item.alt ?? item.title ?? `${next + 1}`);
}
cb?.(next);
}, []);
/** Nearest absolute target for an item index (shortest way round when looping). */
const targetFor = useCallback((i: number) => {
const { n: count, looping: wrap } = live.current;
if (!wrap) return clamp(i, 0, count - 1);
const base = targetRef.current;
return base + (mod(i - base + count / 2, count) - count / 2);
}, []);
const clampTarget = useCallback((t: number) => {
const { n: count, looping: wrap } = live.current;
return wrap ? t : clamp(t, 0, count - 1);
}, []);
// Reduced motion: glides jump; direct drag still follows the finger.
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const sync = () => {
reduceMotionRef.current = mq.matches;
};
sync();
mq.addEventListener("change", sync);
return () => mq.removeEventListener("change", sync);
}, []);
// Repaint before the browser paints whenever the row set or wrap mode
// changes (new rows were rendered with the initial pose). Also pulls the
// position back in range when items shrink — e.g. 31 → 28 days.
useLayoutEffect(() => {
if (!n) return;
if (!looping) {
const max = n - 1;
if (targetRef.current > max || targetRef.current < 0) {
const t = clamp(Math.round(targetRef.current), 0, max);
targetRef.current = t;
if (glideRef.current) glideTo(t, 0);
else posRef.current = clamp(posRef.current, 0, max);
}
}
paint();
}, [n, looping, range, paint, glideTo]);
// A controlled index (or an uncontrolled reset after items shrank) that
// disagrees with where the wheel is heading glides there.
useEffect(() => {
if (!n || dragRef.current?.dragging) return;
if (mod(Math.round(targetRef.current), n) === current) return;
glideTo(targetFor(current), glideVelocity(glideRef.current, performance.now()));
}, [current, n, glideTo, targetFor]);
useEffect(() => () => {
cancelAnimationFrame(rafRef.current);
window.clearTimeout(wheelTimerRef.current);
}, []);
// ------------------------------------------------------------------
// Wheel / trackpad. Native and non-passive: React's onWheel is passive
// and can't stop the page from scrolling under the wheel.
// ------------------------------------------------------------------
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
const { n: count, looping: wrap, rowH: h } = live.current;
if (!count || dragRef.current?.dragging) return;
const dy = e.deltaMode === 1 ? e.deltaY * 16 : e.deltaMode === 2 ? e.deltaY * h * 5 : e.deltaY;
if (dy === 0) return;
// At an end of a bounded wheel, hand the gesture back to the page.
const heading = glideRef.current ? targetRef.current : posRef.current;
if (!wrap && ((dy < 0 && heading <= 0) || (dy > 0 && heading >= count - 1))) return;
e.preventDefault();
if (Math.abs(dy) >= WHEEL_NOTCH_PX) {
// Mouse notch: exactly one row per click, queued onto the target.
const t = clampTarget(Math.round(targetRef.current) + Math.sign(dy));
glideTo(t, glideVelocity(glideRef.current, performance.now()));
commit(t, "pointer");
return;
}
// Trackpad: follow the fingers 1:1, settle once the stream goes quiet.
stopGlide();
let p = posRef.current + dy / h;
if (!wrap) p = clamp(p, -0.3, count - 0.7);
posRef.current = p;
targetRef.current = p;
paint();
window.clearTimeout(wheelTimerRef.current);
wheelTimerRef.current = window.setTimeout(() => {
const t = clampTarget(Math.round(posRef.current));
glideTo(t, 0);
commit(t, "pointer");
}, WHEEL_IDLE_MS);
};
el.addEventListener("wheel", onWheel, { passive: false });
return () => el.removeEventListener("wheel", onWheel);
}, [clampTarget, commit, glideTo, paint, stopGlide]);
// ------------------------------------------------------------------
// Pointer drag with inertia; a press that doesn't travel is a tap
// ------------------------------------------------------------------
const onPointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
if (!n || (e.pointerType === "mouse" && e.button !== 0)) return;
const rowEl = (e.target as HTMLElement).closest<HTMLElement>("[data-wc-index]");
const now = performance.now();
dragRef.current = {
pointerId: e.pointerId,
startY: e.clientY,
lastY: e.clientY,
lastT: now,
// Catching a spinning wheel inherits its speed until the finger moves.
velocity: 0,
dragging: false,
tapIndex: rowEl ? Number(rowEl.dataset.wcIndex) : null,
};
// Grabbing a gliding wheel stops it under the finger (and isn't a tap).
if (glideRef.current) {
stopGlide();
dragRef.current.tapIndex = null;
targetRef.current = posRef.current;
}
e.currentTarget.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== e.pointerId) return;
if (!drag.dragging) {
if (Math.abs(e.clientY - drag.startY) < TAP_SLOP) return;
drag.dragging = true;
drag.lastY = e.clientY;
drag.lastT = performance.now();
window.clearTimeout(wheelTimerRef.current);
return;
}
const now = performance.now();
const dt = Math.max(now - drag.lastT, 1);
let delta = -(e.clientY - drag.lastY) / rowH;
drag.lastY = e.clientY;
drag.lastT = now;
if (!looping && (posRef.current < 0 || posRef.current > n - 1)) delta *= RUBBER_BAND;
posRef.current += delta;
targetRef.current = posRef.current;
// Smoothed so one jittery sample doesn't decide the fling.
drag.velocity = drag.velocity * 0.6 + (delta / dt) * 0.4;
paint();
};
const endPointer = (e: ReactPointerEvent<HTMLDivElement>, cancelled: boolean) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== e.pointerId) return;
dragRef.current = null;
try {
e.currentTarget.releasePointerCapture(e.pointerId);
} catch {
/* capture may already be gone */
}
if (!drag.dragging) {
if (!cancelled && drag.tapIndex !== null) {
const t = targetFor(drag.tapIndex);
glideTo(t, 0);
commit(t, "pointer");
} else if (Math.abs(posRef.current - Math.round(posRef.current)) > 0.001) {
// Grabbed mid-glide and let go: settle where it stopped.
const t = clampTarget(Math.round(posRef.current));
glideTo(t, 0);
commit(t, "pointer");
}
return;
}
const v = performance.now() - drag.lastT > FLING_STALE_MS ? 0 : drag.velocity;
// Project where friction alone would stop the wheel, then snap that to
// a row: the spring below starts with the fling velocity, so the hand-off
// from finger to inertia is seamless.
const omega = -Math.log(clamp(friction, 0.5, 0.995)) / 16.667;
const t = clampTarget(Math.round(posRef.current + v / omega));
glideTo(t, v);
commit(t, "pointer");
};
// ------------------------------------------------------------------
// Keyboard — listbox conventions
// ------------------------------------------------------------------
const onKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
if (!n) return;
const base = Math.round(targetRef.current);
let t: number;
switch (e.key) {
case "ArrowUp":
t = base - 1;
break;
case "ArrowDown":
t = base + 1;
break;
case "PageUp":
t = base - visible;
break;
case "PageDown":
t = base + visible;
break;
case "Home":
t = targetFor(0);
break;
case "End":
t = targetFor(n - 1);
break;
default:
return;
}
e.preventDefault();
t = clampTarget(t);
if (t === targetRef.current && !glideRef.current) return;
glideTo(t, glideVelocity(glideRef.current, performance.now()));
commit(t, "keyboard");
};
const vars: CSSVars = {};
if (rowHeight !== undefined) vars["--wc-row-h"] = `${rowHeight}px`;
if (visible !== DEFAULT_VISIBLE_ROWS) vars["--wc-visible"] = visible;
if (perspective !== undefined && perspective !== DEFAULT_PERSPECTIVE)
vars["--wc-perspective"] = `${perspective}px`;
const rootClassName = [
styles.root,
variant === "card" ? styles.card : styles.text,
className ?? "",
]
.filter(Boolean)
.join(" ");
const optionId = (i: number) => `${baseId}-opt-${i}`;
return (
<div className={rootClassName} style={vars}>
<div
ref={viewportRef}
className={styles.viewport}
role="listbox"
tabIndex={n ? 0 : -1}
aria-label={ariaLabel}
aria-activedescendant={n ? optionId(current) : undefined}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={(e) => endPointer(e, false)}
onPointerCancel={(e) => endPointer(e, true)}
onKeyDown={onKeyDown}
>
{highlight && <div className={styles.band} aria-hidden="true" />}
{items.map((raw, i) => {
const item = normalize(raw);
const selected = i === current;
const state: WheelCarouselRenderState = { index: i, total: n, selected, variant };
// Constant first-paint pose (see initialPos): the drum is already
// curved in server HTML and in the scaled-down gallery card.
const d0 = rowOffset(i, initialPos, n, looping);
const off0 = Math.abs(d0) >= range;
const rowStyle: CSSVars = {
"--d": (off0 ? Math.sign(d0) * range : d0).toFixed(4),
"--a": Math.min(Math.abs(d0), 1).toFixed(3),
};
const label = item.alt ?? item.title ?? `${i + 1}`;
return (
<div
key={item.id ?? i}
ref={(node) => {
rowRefs.current[i] = node;
}}
id={optionId(i)}
role="option"
aria-selected={selected}
aria-label={renderItem ? label : item.alt}
data-wc-index={i}
data-off={off0 ? "" : undefined}
className={`${styles.row}${selected ? ` ${styles.selected}` : ""}`}
style={rowStyle}
>
{renderItem ? (
renderItem(item, state)
) : variant === "card" ? (
<div className={styles.cardRow}>
<span
className={styles.thumb}
aria-hidden="true"
style={{
backgroundImage: item.image
? thumbBackground(item.image)
: placeholderArt(i),
}}
/>
<span className={styles.cardText}>
<span className={styles.title}>{item.title}</span>
{item.subtitle ? (
<span className={styles.subtitle}>{item.subtitle}</span>
) : null}
</span>
</div>
) : (
<span className={styles.label}>{item.title ?? i + 1}</span>
)}
</div>
);
})}
</div>
<span className={styles.srOnly} aria-live="polite">
{announce}
</span>
</div>
);
}
export default WheelCarousel;
```
#### `components/wheel-carousel/WheelCarousel.module.css`
```css
/* ==========================================================================
Wheel carousel — a vertical 3D drum, like the iOS picker wheel. Rows sit
on the outside of a horizontal-axis cylinder; the selected one rests in
a glass band at the front, the rest curve away and fade out.
Geometry is pure CSS: the component only writes --d (signed row distance
from the centre) and --a (|d| capped at 1) per row. The angular step and
radius are derived here from the row height and visible row count, so
the front row's pitch is exactly --wc-row-h.
========================================================================== */
.root {
/* The component only sets these inline for non-default values, so the
default render is exactly this stylesheet. */
--wc-row-h: 40px;
--wc-visible: 5;
--wc-perspective: 500px;
--wc-text: #f5f5f7;
--wc-text-dim: rgba(245, 245, 247, 0.92);
--wc-band: rgba(255, 255, 255, 0.07);
--wc-hairline: rgba(255, 255, 255, 0.16);
--wc-accent: #a78bfa;
--wc-band-radius: 10px;
/* One row per (180° / (visible + 2)): the outermost visible rows sit just
short of edge-on, so the drum reads as a full half-cylinder. */
--wc-step: calc(180deg / (var(--wc-visible) + 2));
/* Chord of one step at the front equals the row height. */
--wc-radius: calc(var(--wc-row-h) / (2 * tan(var(--wc-step) / 2)));
/* Projected height of the visible arc: 2r·sin((visible + 1) / 2 · step). */
--wc-height: calc(
2 * var(--wc-radius) * sin(var(--wc-step) * (var(--wc-visible) + 1) / 2)
);
position: relative;
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: var(--wc-text);
}
.card {
--wc-row-h: 64px;
--wc-band-radius: 16px;
}
.viewport {
position: relative;
height: var(--wc-height);
overflow: hidden;
/* Perspective lives here and the rows are direct children, so each row is
projected around the band. Keep transform-style flat on this element:
mask-image and overflow would flatten preserve-3d anyway. */
perspective: var(--wc-perspective);
perspective-origin: 50% 50%;
/* Rows dim toward the top and bottom. Unprefixed only — Lightning CSS
adds -webkit-mask-image itself, and writing both makes it drop the
standard one. */
mask-image: linear-gradient(
to bottom,
transparent 0%,
rgba(0, 0, 0, 0.6) 20%,
#000 40%,
#000 60%,
rgba(0, 0, 0, 0.6) 80%,
transparent 100%
);
/* The wheel consumes vertical drags; horizontal page panning stays free. */
touch-action: pan-x;
user-select: none;
cursor: grab;
outline: none;
border-radius: var(--wc-band-radius);
}
.viewport:active {
cursor: grabbing;
}
/* The mask would fade a ring drawn on the viewport itself, so the focus
ring is drawn on the band (or the root when there is no band). */
.root:has(.viewport:focus-visible) .band {
box-shadow:
inset 0 1px 0 var(--wc-hairline),
inset 0 -1px 0 var(--wc-hairline),
0 0 0 2px var(--wc-accent);
}
.root:not(:has(.band)):has(.viewport:focus-visible) {
outline: 2px solid var(--wc-accent);
outline-offset: 2px;
border-radius: var(--wc-band-radius);
}
/* Glass band at the front face, behind the rows (painted first). */
.band {
position: absolute;
inset-inline: 4px;
top: 50%;
height: var(--wc-row-h);
margin-top: calc(var(--wc-row-h) / -2);
border-radius: var(--wc-band-radius);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.015)),
var(--wc-band);
box-shadow:
inset 0 1px 0 var(--wc-hairline),
inset 0 -1px 0 var(--wc-hairline);
pointer-events: none;
}
/* Faint accent bloom inside the band, so it reads as lit glass on black. */
.band::after {
content: "";
position: absolute;
inset: 0;
border-radius: inherit;
background: radial-gradient(
60% 140% at 50% 50%,
color-mix(in srgb, var(--wc-accent) 16%, transparent),
transparent 70%
);
}
.row {
position: absolute;
inset-inline: 0;
top: 50%;
height: var(--wc-row-h);
margin-top: calc(var(--wc-row-h) / -2);
display: flex;
align-items: center;
justify-content: center;
/* Swing out from the drum's axis (r behind the band) and back to the
surface. Rows above the centre (d < 0) tilt their top away. */
transform: translateZ(calc(var(--wc-radius) * -1))
rotateX(calc(var(--d, 0) * var(--wc-step) * -1))
translateZ(var(--wc-radius));
/* Off-centre rows dim: full strength in the band, 60% one row away
(the mask takes care of the rest toward the edges). */
opacity: calc(1 - var(--a, 0) * 0.4);
backface-visibility: hidden;
will-change: transform;
cursor: pointer;
}
/* Rows past ±90° are parked at the edge, invisible but still in the
accessibility tree (the listbox keeps every option). */
.row[data-off] {
opacity: 0;
pointer-events: none;
}
/* ---- text variant ------------------------------------------------------ */
.label {
font-size: calc(var(--wc-row-h) * 0.5);
font-weight: 500;
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
padding-inline: 10px;
color: var(--wc-text-dim);
transition: color 0.2s ease;
}
.selected .label {
color: var(--wc-text);
font-weight: 600;
}
/* ---- card variant ------------------------------------------------------ */
.cardRow {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
height: 100%;
padding-inline: 14px;
box-sizing: border-box;
}
.thumb {
flex: none;
position: relative;
width: calc(var(--wc-row-h) - 16px);
height: calc(var(--wc-row-h) - 16px);
border-radius: calc((var(--wc-row-h) - 16px) * 0.22);
background-size: cover;
background-position: center;
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.14),
0 6px 14px -6px rgba(0, 0, 0, 0.7);
overflow: hidden;
}
/* Film grain over the art, so flat gradients read as printed artwork. */
.thumb::after {
content: "";
position: absolute;
inset: 0;
opacity: 0.28;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='80' height='80'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/></filter><rect width='100%' height='100%' filter='url(%23n)'/></svg>");
}
.cardText {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.title {
font-size: 14px;
font-weight: 600;
letter-spacing: -0.005em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--wc-text);
}
.subtitle {
font-size: 12px;
color: rgba(245, 245, 247, 0.55);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.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) {
.label {
transition: none;
}
}
```
代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/wheel-carousel.json
```
### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { WheelCarousel } from "@/components/wheel-carousel";
// TODO: 選択値を受け取るには onIndexChange={(i) => …} を渡します。外から値を決めるなら index={…} も渡して制御モードにします(変更するとホイールがその行まで回ります)
// TODO: 日時ピッカーにするなら、月 / 日 / 時刻 などのホイールを横に並べ、それぞれ別の aria-label を付けます(インスタンスは互いに独立)
// TODO: 行の中身を自作するなら renderItem={(item, { index, selected }) => …} を渡します。3D の配置と減光はそのまま使われます
<WheelCarousel
variant="text"
rowHeight={40}
perspective={500}
highlight={true}
loop={true}
friction={0.94}
visibleRows={5}
items={["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"]}
aria-label="月"
/>
```
### 4. 組み込み手順
1. ホイールを置くコンポーネントを `"use client"` にして `<WheelCarousel items={["1月", "2月", …]} aria-label="月" onIndexChange={setMonth} />` を描画する。`onIndexChange` / `renderItem` は関数なので Server Component からは渡せない。
2. `items` は文字列か `{ id?, title?, subtitle?, image?, alt? }` の配列。`variant="card"` ではサムネイル(`image` は画像 URL か `linear-gradient(…)`。URL は `background-image` として表示されるので `next/image` の最適化は効かない)とタイトル・サブタイトルが出る。
3. 値を外から決めるなら `index` + `onIndexChange` の制御モード、初期値だけなら `defaultIndex`。`index` を変えるとホイールはその行まで**回って**移動する(日付ピッカーで月を変えたら日を `Math.min(day, 日数 - 1)` に丸めて渡す、など)。
4. 横幅は親に従う(`width: 100%`)。複数並べるなら親を `display: grid` / `flex` にして列幅を決める。各インスタンスは独立しているので、月 / 日 / 時刻 のように並べて `aria-label` をそれぞれ付ける。帯の高さを揃えるため、並べるホイールの `rowHeight` と `visibleRows` は同じにする。
5. 高さは `rowHeight` と `visibleRows` から自動で決まる(5 行・40px で約 171px)。固定高さの枠に入れる場合はこの値を基準にする。
6. `loop` は行数が `visibleRows + 2` 以上のときだけ有効(それ未満だと同じ行を 2 回描く必要があるため、端で止まる通常モードになる)。
7. 行数は数十件までを想定(全行を DOM に置く)。数百件以上なら絞り込み UI を併用する。
8. `npm run build` が通ることを確認し、「ドラッグして離すと慣性で回って行に吸着する」「ホイール 1 ノッチで 1 行」「トラックパッドで指に追従して止まると吸着」「↑↓ / PageUp / PageDown / Home / End」「行クリックでその行へ回る」「スマホで横方向のスクロールは妨げない」を確認する。
#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| ホイールを回すとページまでスクロールする | React の `onWheel` は passive 登録で `preventDefault()` が効かない | `addEventListener("wheel", …, { passive: false })` をネイティブで付ける。ループなしで端に達したときだけ素通しにしてページに返す |
| ドラムが平らに見える(ただのリストが上下でフェード) | `mask-image` / `overflow` / `filter` を付けた要素は `preserve-3d` が平坦化される | `perspective` とマスクはビューポートに付け、行はその**直下の子**に置いて各行の transform に `translateZ(-r) rotateX(…) translateZ(r)` をまとめて書く |
| Chrome / Safari でフェードが効かない | `mask-image` と `-webkit-mask-image` を併記すると Next.js(Lightning CSS)が unprefixed を削る | **unprefixed だけ書く**。プレフィックスはビルド時に自動付与される |
| 回している途中で行が一瞬元の位置に飛ぶ | 行の位置を React の state から描画していて、選択変更の再描画が rAF の書き込みを上書きする | 行の `--d` は初回描画の値(一定)だけを JSX に書き、以降は rAF が `style.setProperty` で書く。1 フレームごとに setState しない |
| ループ時に裏側の行が前面に回り込んで重なる | 角度ステップ固定の円筒は 360° で一周するので、行数が多いと背面の行が前に来る | `|d|` が `(visibleRows + 2) / 2`(= 90°)以上の行は `data-off` で透明 + `pointer-events: none` にして端に寄せる。`backface-visibility` だけに頼らない |
| スクリーンリーダーで一部の選択肢しか読めない | 見えない行を `visibility: hidden` / `aria-hidden` にしている | listbox の option はすべてアクセシビリティツリーに残す。隠すのは opacity だけ |
| 回転後に値が二重に読み上げられる | キー操作で `aria-activedescendant` と live region の両方が読む | live region はポインター / ホイールでの変更時だけ更新する |
### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる
## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- Next.js(App Router)、React 19、TypeScript、CSS Modules。依存なし(純粋な CSS 3D + requestAnimationFrame)
- ファイル: `components/wheel-carousel/WheelCarousel.tsx`(`"use client"`)+ `WheelCarousel.module.css` + `index.ts`
- props: `items`(文字列 or `{ id?, title?, subtitle?, image?, alt? }` の配列)、`index?` / `defaultIndex = 0` / `onIndexChange?(i)`、`variant = "text" | "card"`、`rowHeight`(既定 text 40px / card 64px)、`visibleRows = 5`(奇数に切り上げ)、`loop = false`、`perspective = 500`、`highlight = true`、`friction = 0.94`、`renderItem?(item, { index, total, selected, variant })`、`className`、`aria-label`
- 状態: 連続値の位置 `pos`(行単位の小数、ループ時は無制限)と目標行 `target` を ref に持つ。確定した選択 index だけが React state
### 見た目
- CSS 変数(ルートが既定値を持ち、既定と違う値だけインライン): `--wc-row-h: 40px`(card は 64px)、`--wc-visible: 5`、`--wc-perspective: 500px`、`--wc-text`、`--wc-band`、`--wc-hairline`、`--wc-accent: #a78bfa`
- ジオメトリは CSS で導出: `--wc-step = 180deg / (visible + 2)`、半径 `--wc-radius = rowH / (2·tan(step/2))`(正面での 1 行の弦 = 行の高さ)、ビューポート高さ `2r·sin(step·(visible+1)/2)`
- 行: ビューポート中央に絶対配置し `transform: translateZ(-r) rotateX(-d·step) translateZ(r)`。上の行(d < 0)は上端が奥へ倒れる。`opacity: 1 − min(|d|,1)·0.4`
- ビューポート: `perspective`、`overflow: hidden`、上下を `mask-image: linear-gradient(transparent 0%, 黒 60% 20%, #000 40%, #000 60%, 黒 60% 80%, transparent 100%)` でフェード
- 選択帯(`highlight`): 中央に行の高さぶん、角丸 10px(card 16px)、白 7% + 上から薄いグラデーション、上下に白 16% のヘアライン(inset シャドウ)、内側にアクセント色の淡い放射グロー。行より先に描いて背後に置く
- text 行: 文字サイズ = 行の高さ × 0.5、500(選択行は 600・白)、`tabular-nums`、中央揃え
- card 行: サムネイル(行の高さ − 16px の角丸正方形、`image` か黄金角の色相グラデーション、フィルムグレイン overlay)+ タイトル 14px / 600 + サブタイトル 12px / 白 55%
### モーション
- ドラッグ: `pos -= dy / rowHeight`。5px 動くまではタップ扱い。ループなしで端を越えたら移動量 × 0.35(ラバーバンド)。速度は行/ms を指数平滑
- 離したとき: 摩擦だけで止まる位置 `pos + v / ω`(`ω = −ln(friction) / 16.667ms`)を求めて最寄りの行に丸め、その行へ**臨界減衰ばね**で移動(初速 = フリック速度、解析解なのでフレームレート非依存・行き過ぎなし。ω の下限 0.011/ms)。90ms 以上止まってから離した場合は初速 0
- ホイール: 1 イベントの |deltaY| ≥ 50px はマウスのノッチとして目標を 1 行ずつ進める。それ未満(トラックパッド)は 1:1 で追従し、140ms 途切れたら最寄りの行へ吸着
- キー・クリック・制御 index の変更も同じばねで移動。移動中に再指定しても現在の速度を引き継ぐ
- rAF は移動中だけ回し、止まったら停止(アイドル時は 0 コスト)。1 フレームで書くのは各行の `--d` / `--a` と `data-off` だけ
- `prefers-reduced-motion: reduce`: ばね移動は即時ジャンプ。ドラッグ中の追従はそのまま(直接操作なので)
### アクセシビリティ
- **listbox を採用**(spinbutton ではなく): 行はテキスト以外(サムネイル付きカード)も取り、有限の選択肢から 1 つを選ぶ UI なので option の集合として公開するのが正確。spinbutton は数値の増減向けで、カード行の内容や総数を伝えられない
- ビューポートが `role="listbox"`・`tabIndex={0}`・`aria-label`・`aria-activedescendant`(確定行の option id)。フォーカスはホイール 1 か所のまま(タブ停止は 1 つ)
- 各行は `role="option"`・`aria-selected`・`useId` ベースの id(複数インスタンスで衝突しない)。見えない行も opacity 0 で残し、ツリーから外さない
- キー: ↑↓ で 1 行、PageUp / PageDown で visibleRows 行、Home / End で先頭 / 末尾
- フォーカスリングは選択帯にアクセント色 2px(ビューポートのマスクで消えないように)
- ポインター / ホイールで値が変わったときだけ、視覚的に隠した `aria-live="polite"` で新しい値を読む。マウント時にフォーカスを奪わない
### 受け入れ条件
- 静止状態で上下の行が奥へ倒れて小さく暗くなり、円筒の手前に見える。選択行はガラス帯の中で一番明るい
- はじくと慣性で回り、止まると必ず行にぴったり吸着する。ループ時は端なく回る
- マウスホイール 1 ノッチで 1 行、トラックパッドは指に追従、キーボードとクリックでも同じばねで回る
- 横に並べた複数のホイールが互いに干渉しない。制御 index を変えると回って移動する
- `onIndexChange` は操作ごとに 1 回だけ呼ばれる(フレームごとではない)インストール
npx shadcn@latest add https://anima-js.vercel.app/r/wheel-carousel.json生成コード
import { WheelCarousel } from "@/components/wheel-carousel";
// TODO: 選択値を受け取るには onIndexChange={(i) => …} を渡します。外から値を決めるなら index={…} も渡して制御モードにします(変更するとホイールがその行まで回ります)
// TODO: 日時ピッカーにするなら、月 / 日 / 時刻 などのホイールを横に並べ、それぞれ別の aria-label を付けます(インスタンスは互いに独立)
// TODO: 行の中身を自作するなら renderItem={(item, { index, selected }) => …} を渡します。3D の配置と減光はそのまま使われます
<WheelCarousel
variant="text"
rowHeight={40}
perspective={500}
highlight={true}
loop={true}
friction={0.94}
visibleRows={5}
items={["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"]}
aria-label="月"
/>