Aero ワイヤレスイヤホン
★ 4.8¥24,800
ネイティブ CSS scroll-snap の商品・コンテンツカルーセル — コンテナ幅で 1.15 / 2.2 / 3.2 枚、マウスドラッグ、ホバーで出る矢印、プログレスバー / ドット、端のフェード。慣性スクロールとアクセシビリティはブラウザのまま。
# SnapCarousel をこのプロジェクトに追加してください
このプロンプトは自己完結しています。コンポーネントのソースコード一式が下に含まれているので、外部サイトへのアクセスや shadcn CLI は不要です。手順どおりにファイルを作成し、依存パッケージを追加し、使用例を配置して、そのまま動作する状態にしてください。
## コンポーネントについて
- 名前: SnapCarousel
- 説明: ネイティブ CSS scroll-snap の商品・コンテンツカルーセル — コンテナ幅で 1.15 / 2.2 / 3.2 枚、マウスドラッグ、ホバーで出る矢印、プログレスバー / ドット、端のフェード。慣性スクロールとアクセシビリティはブラウザのまま。
- 実行時に必要な npm パッケージ: lucide-react
- 構成ファイル: `components/snap-carousel/index.tsx`, `components/snap-carousel/SnapCarousel.module.css`
## 前提と注意
- React 19 / TypeScript / Next.js(App Router)のプロジェクトを想定しています。Next.js 以外の React プロジェクト(Vite など)でもそのまま動きます。
- スタイルは同梱の CSS Modules(ある場合)で自己完結しており、Tailwind CSS の有無や設定には依存しません。Tailwind の設定変更やグローバル CSS の追加は、この依頼文に明記された分だけにしてください。
- ブラウザ API やインタラクションを使うクライアントコンポーネントです。ファイル先頭の "use client" を維持してください。
- コンポーネント自体の背景は透過です。配置先ページの背景がそのまま透けて見える前提で作られているので、白背景や単色の `div` などで囲わず、コンポーネントだけをそのまま配置してください。
- ソースコードの内容は変更しないでください。例外は、`@/` パスエイリアスが無いプロジェクトで import パスを相対パスに直す場合だけです。
## 手順
### 1. 依存パッケージを追加する
pnpm / yarn / bun を使っているプロジェクトでは、そのパッケージマネージャーのコマンドに置き換えてください。
```bash
npm install lucide-react
```
### 2. ファイルを作成する
`@/components` が指すディレクトリ(多くは `src/components/` か `components/`。shadcn の `components.json` があれば `aliases.components` の指す場所)に、以下の 2 ファイルを**一字一句そのまま**作成してください。
#### `components/snap-carousel/index.tsx`
```tsx
"use client";
import {
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
useSyncExternalStore,
type CSSProperties,
type FocusEvent,
type KeyboardEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from "react";
import { ChevronLeft, ChevronRight, Pause, Play } from "lucide-react";
import styles from "./SnapCarousel.module.css";
/** Lets CSS custom properties pass the CSSProperties type check. */
type CSSVars = CSSProperties & Record<`--${string}`, string | number>;
/**
* One slide. `image` is an image URL or any CSS background value
* (gradients included); without it the default slide paints gradient art.
*/
export type SnapCarouselItem = {
id?: string;
title?: string;
subtitle?: string;
/** Small tag on the title row — a rating, date, badge… */
meta?: string;
image?: string;
alt?: string;
};
/** What `renderItem` gets besides the item. */
export type SnapCarouselSlideState = {
/** Slide index in `items`. */
index: number;
total: number;
/** The slide the current snap stop belongs to. */
active: boolean;
/** At least partly inside the viewport. */
visible: boolean;
};
/**
* Slides per view by container width: sm < 480px ≤ md < 640px ≤ lg.
* A plain number is the lg value; sm / md are capped at 1 / 2 from it.
*/
export type SnapCarouselSlidesPerView =
| number
| { sm?: number; md?: number; lg?: number };
export type SnapCarouselProps<T extends SnapCarouselItem = SnapCarouselItem> = {
items: T[];
slidesPerView?: SnapCarouselSlidesPerView;
/** Space between slides in px. */
gap?: number;
/** Add a sliver of the next slide (1.15 / 2.2 / 3.2) to hint at more. */
peek?: boolean;
/** Round prev / next buttons, shown on hover or keyboard focus. */
showArrows?: boolean;
indicator?: "progress" | "dots" | "none";
/** Fade the edges that have more content beyond them. */
fade?: boolean;
/** Buttons and arrow keys wrap around: next at the end goes to the start. */
loop?: boolean;
/**
* Advance every `interval` ms, rewinding at the end. Pauses on hover,
* focus, offscreen or in a hidden tab; off under reduced motion.
*/
autoplay?: boolean;
interval?: number;
/** Where slides snap inside the viewport. */
align?: "start" | "center";
/** Controlled snap stop. With align "start" it is the first visible slide. */
index?: number;
defaultIndex?: number;
onIndexChange?: (index: number) => void;
/** Custom slide content; the default is a gradient-art card. */
renderItem?: (item: T, state: SnapCarouselSlideState) => ReactNode;
className?: string;
"aria-label"?: string;
};
const DEFAULT_SPV = { sm: 1, md: 2, lg: 3 } as const;
const DEFAULT_GAP = 16;
const DEFAULT_INTERVAL = 4500;
/** Mouse travel (px) before a press becomes a drag and clicks are eaten. */
const DRAG_THRESHOLD = 6;
/** How far (ms of current velocity) a released drag is projected ahead. */
const FLING_MS = 220;
/** Used when an item has no image, so the carousel never paints empty. */
const FALLBACK_ART = [
"radial-gradient(120% 90% at 20% 0%, #6d5bff, #1b1640 70%)",
"radial-gradient(120% 90% at 80% 0%, #ff7a59, #3a1410 70%)",
"radial-gradient(120% 90% at 30% 0%, #2dd4bf, #0b2a2a 70%)",
"radial-gradient(120% 90% at 70% 0%, #f472b6, #33102a 70%)",
"radial-gradient(120% 90% at 50% 0%, #facc15, #2e2408 70%)",
];
/** A CSS background value rather than a URL to load in an <img>. */
const CSS_BACKGROUND = /gradient\(|^url\(|^(#|rgb|hsl|oklch|oklab|lab|lch|color-mix)/i;
function resolveSlidesPerView(spv: SnapCarouselSlidesPerView | undefined) {
if (spv === undefined) return DEFAULT_SPV;
if (typeof spv === "number") {
const lg = Math.max(1, spv);
return { sm: Math.min(lg, 1), md: Math.min(lg, 2), lg };
}
return {
sm: Math.max(1, spv.sm ?? DEFAULT_SPV.sm),
md: Math.max(1, spv.md ?? DEFAULT_SPV.md),
lg: Math.max(1, spv.lg ?? DEFAULT_SPV.lg),
};
}
function subscribeReducedMotion(onChange: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
/** false on the server; follows the OS setting on the client. */
function useReducedMotion() {
return useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
function nearestIndex(positions: number[], left: number) {
let best = 0;
for (let i = 1; i < positions.length; i++) {
if (Math.abs(positions[i] - left) < Math.abs(positions[best] - left)) best = i;
}
return best;
}
/** Default slide: gradient (or photo) art with a title row underneath. */
function DefaultSlide({ item, index }: { item: SnapCarouselItem; index: number }) {
const image = item.image?.trim();
const isPhoto = !!image && !CSS_BACKGROUND.test(image);
return (
<article className={styles.card}>
<div className={styles.art}>
{isPhoto ? (
// Plain <img>: registry components stay framework-agnostic.
// eslint-disable-next-line @next/next/no-img-element
<img
className={styles.artFill}
src={image}
alt={item.alt ?? ""}
loading="lazy"
decoding="async"
draggable={false}
/>
) : (
<div
className={styles.artFill}
style={{ background: image || FALLBACK_ART[index % FALLBACK_ART.length] }}
role={item.alt ? "img" : undefined}
aria-label={item.alt || undefined}
aria-hidden={item.alt ? undefined : true}
/>
)}
<span className={styles.sheen} aria-hidden />
</div>
{item.title || item.subtitle || item.meta ? (
<div className={styles.meta}>
{item.title ? <p className={styles.title}>{item.title}</p> : null}
{item.meta ? <span className={styles.tag}>{item.meta}</span> : null}
{item.subtitle ? <p className={styles.subtitle}>{item.subtitle}</p> : null}
</div>
) : null}
</article>
);
}
type DragState = {
id: number;
startX: number;
startScroll: number;
lastX: number;
lastT: number;
/** Pointer velocity in px/ms, smoothed. */
velocity: number;
active: boolean;
};
/**
* Product / content carousel on native CSS scroll-snap (Embla / Airbnb
* style). The browser does the scrolling — touch momentum, trackpads and
* assistive tech keep working — and JS only adds mouse drag, buttons,
* keyboard steps and an indicator synced from the scroll position.
*/
export function SnapCarousel<T extends SnapCarouselItem = SnapCarouselItem>({
items,
slidesPerView,
gap = DEFAULT_GAP,
peek = true,
showArrows = true,
indicator = "progress",
fade = true,
loop = false,
autoplay = false,
interval = DEFAULT_INTERVAL,
align = "start",
index,
defaultIndex,
onIndexChange,
renderItem,
className,
"aria-label": ariaLabel = "カルーセル",
}: SnapCarouselProps<T>) {
const trackId = useId();
const hintId = useId();
const rootRef = useRef<HTMLElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
// Snap stops in scroll px, one per reachable position. Slides that all
// clamp to the scroll end share one stop, so there can be fewer stops
// than slides; `stopSlides` maps a stop back to its slide.
const positionsRef = useRef<number[]>([]);
const initialIndex = index ?? defaultIndex ?? 0;
const currentRef = useRef(initialIndex);
/** Stop a button / key press is heading to, so rapid presses chain. */
const targetRef = useRef<number | null>(null);
const dragRef = useRef<DragState | null>(null);
/** Where a released drag is gliding to; snap stays off until it lands. */
const glideRef = useRef<{ target: number; timer: number } | null>(null);
const alignRef = useRef(align);
const onIndexChangeRef = useRef(onIndexChange);
const reduced = useReducedMotion();
const reducedRef = useRef(reduced);
// Latest props for the scroll / pointer callbacks, which are created
// once. Declared first so it runs before the measuring effects below.
useLayoutEffect(() => {
alignRef.current = align;
onIndexChangeRef.current = onIndexChange;
reducedRef.current = reduced;
});
const [current, setCurrent] = useState(initialIndex);
/** First slide of each stop; its length is the number of stops. */
const [stopSlides, setStopSlides] = useState<number[]>(() => items.map((_, i) => i));
const total = stopSlides.length;
const [atStart, setAtStart] = useState(true);
const [atEnd, setAtEnd] = useState(items.length <= 1);
const [visible, setVisible] = useState<boolean[]>([]);
const [announcement, setAnnouncement] = useState("");
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const [onscreen, setOnscreen] = useState(true);
const [pageVisible, setPageVisible] = useState(true);
const [userPaused, setUserPaused] = useState(false);
/** Ends a drag glide: snap comes back on (cancel = a new drag took over). */
const settleGlide = useCallback(() => {
const glide = glideRef.current;
if (!glide) return;
window.clearTimeout(glide.timer);
glideRef.current = null;
if (!dragRef.current?.active) delete trackRef.current?.dataset.dragging;
}, []);
/** Reads scroll state once; called at most once per frame. */
const sync = useCallback(() => {
const track = trackRef.current;
const root = rootRef.current;
const positions = positionsRef.current;
if (!track || !root) return;
const left = track.scrollLeft;
const max = track.scrollWidth - track.clientWidth;
// Settled from the position itself rather than `scrollend`, which
// isn't everywhere and may fire for the drag's own scroll.
if (glideRef.current && !dragRef.current && Math.abs(left - glideRef.current.target) < 1) {
settleGlide();
}
// Written straight to CSS variables: the bar moves every frame without
// a React render.
root.style.setProperty("--sc-progress", (max > 0 ? left / max : 0).toFixed(4));
root.style.setProperty(
"--sc-thumb",
Math.min(1, track.clientWidth / Math.max(1, track.scrollWidth)).toFixed(4),
);
setAtStart(left <= 1);
setAtEnd(left >= max - 1);
if (!positions.length) return;
const next = left >= max - 1 ? positions.length - 1 : nearestIndex(positions, left);
if (targetRef.current === next) targetRef.current = null;
if (next !== currentRef.current) {
currentRef.current = next;
setCurrent(next);
onIndexChangeRef.current?.(next);
}
}, [settleGlide]);
const measure = useCallback(() => {
const track = trackRef.current;
if (!track) return;
const max = Math.max(0, track.scrollWidth - track.clientWidth);
const inset = parseFloat(getComputedStyle(track).paddingLeft) || 0;
const width = track.clientWidth;
const positions: number[] = [];
const stopSlide: number[] = [];
Array.from(track.children).forEach((child, i) => {
const slide = child as HTMLElement;
const raw =
alignRef.current === "center"
? slide.offsetLeft + slide.offsetWidth / 2 - width / 2
: slide.offsetLeft - inset;
const pos = Math.min(max, Math.max(0, raw));
if (positions.length && pos - positions[positions.length - 1] < 2) return;
positions.push(pos);
stopSlide.push(i);
});
positionsRef.current = positions;
setStopSlides((prev) =>
prev.length === stopSlide.length && prev.every((v, i) => v === stopSlide[i])
? prev
: stopSlide,
);
}, []);
const goTo = useCallback((target: number, announce = false) => {
const track = trackRef.current;
const positions = positionsRef.current;
if (!track || !positions.length) return;
const i = Math.min(positions.length - 1, Math.max(0, target));
targetRef.current = i;
track.scrollTo({ left: positions[i], behavior: reducedRef.current ? "auto" : "smooth" });
if (announce) setAnnouncement(`${i + 1} / ${positions.length}`);
}, []);
/** One stop back / forward, wrapping when `loop` is on. */
const step = useCallback(
(dir: -1 | 1) => {
const count = positionsRef.current.length;
if (count < 2) return;
let next = (targetRef.current ?? currentRef.current) + dir;
if (next < 0 || next >= count) {
if (!loop) return;
next = next < 0 ? count - 1 : 0;
}
goTo(next, true);
},
[goTo, loop],
);
// First measure and the initial stop, before paint, so a non-zero
// defaultIndex never flashes slide 1.
useLayoutEffect(() => {
measure();
const track = trackRef.current;
const positions = positionsRef.current;
if (track && positions.length && currentRef.current > 0) {
track.scrollLeft = positions[Math.min(positions.length - 1, currentRef.current)];
}
sync();
// Mount only: later index changes go through the controlled effect.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Re-measure when the layout inputs change; the container width itself
// is covered by the ResizeObserver below. Keyed by the resolved counts
// so an inline `{ sm, md, lg }` literal doesn't re-measure every render.
const spv = resolveSlidesPerView(slidesPerView);
const spvKey = `${spv.sm}/${spv.md}/${spv.lg}`;
useLayoutEffect(() => {
measure();
sync();
}, [items.length, align, gap, peek, spvKey, measure, sync]);
// Scroll → rAF-throttled sync. Native listener so it can be passive.
useEffect(() => {
const track = trackRef.current;
if (!track) return;
let frame = 0;
const onScroll = () => {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
sync();
});
};
track.addEventListener("scroll", onScroll, { passive: true });
const ro = new ResizeObserver(() => {
measure();
sync();
});
ro.observe(track);
return () => {
track.removeEventListener("scroll", onScroll);
ro.disconnect();
cancelAnimationFrame(frame);
};
}, [measure, sync]);
// Slides wholly outside the viewport go inert, so Tab and screen readers
// skip them; partly visible (peeking) slides stay usable.
useEffect(() => {
const track = trackRef.current;
if (!track || typeof IntersectionObserver === "undefined") return;
const slides = Array.from(track.children);
const io = new IntersectionObserver(
(entries) => {
setVisible((prev) => {
const next = slides.map((_, i) => prev[i] ?? true);
let changed = next.length !== prev.length;
for (const entry of entries) {
const i = slides.indexOf(entry.target);
if (i >= 0 && next[i] !== entry.isIntersecting) {
next[i] = entry.isIntersecting;
changed = true;
}
}
return changed ? next : prev;
});
},
{ root: track, threshold: 0 },
);
slides.forEach((slide) => io.observe(slide));
return () => io.disconnect();
}, [items]);
// Controlled index: scroll when the prop moves to a stop we aren't at.
// Values we reported ourselves via onIndexChange already match.
const prevIndexRef = useRef(index);
useEffect(() => {
if (index === undefined || index === prevIndexRef.current) {
prevIndexRef.current = index;
return;
}
prevIndexRef.current = index;
if (index !== currentRef.current) goTo(index);
}, [index, goTo]);
// Autoplay pauses offscreen and in background tabs.
useEffect(() => {
if (!autoplay) return;
const root = rootRef.current;
const onVisibility = () => setPageVisible(document.visibilityState === "visible");
document.addEventListener("visibilitychange", onVisibility);
let io: IntersectionObserver | undefined;
if (root && typeof IntersectionObserver !== "undefined") {
io = new IntersectionObserver(([entry]) => setOnscreen(entry.isIntersecting));
io.observe(root);
}
return () => {
document.removeEventListener("visibilitychange", onVisibility);
io?.disconnect();
};
}, [autoplay]);
const playing = autoplay && !reduced && !userPaused;
const running = playing && !hovered && !focused && onscreen && pageVisible;
// One timeout per stop: `current` restarts it, so a manual move gives
// the new slide a full interval. Autoplay always rewinds at the end —
// with loop off it would otherwise stall on the last stop.
useEffect(() => {
if (!running) return;
const id = window.setTimeout(() => {
const count = positionsRef.current.length;
if (count < 2) return;
goTo(currentRef.current + 1 >= count ? 0 : currentRef.current + 1);
}, Math.max(1000, interval));
return () => window.clearTimeout(id);
}, [running, interval, current, goTo]);
/* --- Mouse drag-to-scroll. Touch and pen keep native scrolling. --- */
const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType !== "mouse" || event.button !== 0) return;
targetRef.current = null;
dragRef.current = {
id: event.pointerId,
startX: event.clientX,
startScroll: event.currentTarget.scrollLeft,
lastX: event.clientX,
lastT: event.timeStamp,
velocity: 0,
active: false,
};
};
const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || event.pointerId !== drag.id) return;
const track = event.currentTarget;
const dx = event.clientX - drag.startX;
if (!drag.active) {
if (Math.abs(dx) < DRAG_THRESHOLD) return;
drag.active = true;
// A fast second flick: the previous glide must not turn snap back on
// under this drag.
settleGlide();
track.setPointerCapture(event.pointerId);
// Snap off while dragging, or every scrollLeft write would snap.
track.dataset.dragging = "";
}
track.scrollLeft = drag.startScroll - dx;
const dt = event.timeStamp - drag.lastT;
if (dt > 0) {
drag.velocity = 0.8 * ((event.clientX - drag.lastX) / dt) + 0.2 * drag.velocity;
}
drag.lastX = event.clientX;
drag.lastT = event.timeStamp;
};
const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || event.pointerId !== drag.id) return;
dragRef.current = null;
if (!drag.active) return;
const track = event.currentTarget;
if (track.hasPointerCapture(event.pointerId)) {
track.releasePointerCapture(event.pointerId);
}
// The click that follows a drag must not open the slide under the
// pointer. It fires in the same task as pointerup, so a 0ms timeout
// removes the guard if no click comes.
const swallow = (e: Event) => {
e.preventDefault();
e.stopPropagation();
};
track.addEventListener("click", swallow, { capture: true, once: true });
window.setTimeout(() => track.removeEventListener("click", swallow, { capture: true }), 0);
// Fling: project the release velocity (ignored if the pointer rested
// before letting go), then glide to the nearest stop. Snap returns
// only once the glide has landed, so it never yanks mid-animation.
const velocity = event.timeStamp - drag.lastT > 80 ? 0 : drag.velocity;
const positions = positionsRef.current;
if (!positions.length) {
delete track.dataset.dragging;
return;
}
const target = positions[nearestIndex(positions, track.scrollLeft - velocity * FLING_MS)];
// sync() settles on arrival; the timeout covers an interrupted glide.
glideRef.current = { target, timer: window.setTimeout(settleGlide, 900) };
if (Math.abs(track.scrollLeft - target) < 1) settleGlide();
else track.scrollTo({ left: target, behavior: reducedRef.current ? "auto" : "smooth" });
};
useEffect(() => () => window.clearTimeout(glideRef.current?.timer), []);
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
// Only the track itself: keys inside slide content (inputs, links with
// their own handlers) are left alone.
if (event.target !== event.currentTarget) return;
const last = positionsRef.current.length - 1;
if (event.key === "ArrowRight") step(1);
else if (event.key === "ArrowLeft") step(-1);
else if (event.key === "Home") goTo(0, true);
else if (event.key === "End") goTo(last, true);
else return;
event.preventDefault();
};
const onFocus = () => setFocused(true);
const onBlur = (event: FocusEvent<HTMLElement>) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setFocused(false);
};
// The CSS module carries the defaults; custom properties are only set
// for non-default values so the default render matches the stylesheet.
const vars: CSSVars = {};
if (spv.sm !== DEFAULT_SPV.sm) vars["--sc-spv-sm"] = spv.sm;
if (spv.md !== DEFAULT_SPV.md) vars["--sc-spv-md"] = spv.md;
if (spv.lg !== DEFAULT_SPV.lg) vars["--sc-spv-lg"] = spv.lg;
if (gap !== DEFAULT_GAP) vars["--sc-gap"] = `${Math.max(0, gap)}px`;
const canPrev = loop ? total > 1 : !atStart;
const canNext = loop ? total > 1 : !atEnd;
const activeSlide = stopSlides[current] ?? current;
const showPause = autoplay && !reduced;
return (
<section
ref={rootRef}
className={className ? `${styles.root} ${className}` : styles.root}
aria-roledescription="カルーセル"
aria-label={ariaLabel}
data-snap-carousel
data-align={align}
data-peek={peek ? undefined : "off"}
data-fade={fade ? "on" : undefined}
style={vars}
onPointerEnter={(e) => e.pointerType === "mouse" && setHovered(true)}
onPointerLeave={() => setHovered(false)}
onFocus={onFocus}
onBlur={onBlur}
>
<div
className={styles.viewport}
data-at-start={atStart || undefined}
data-at-end={atEnd || undefined}
>
<div
ref={trackRef}
id={trackId}
className={styles.track}
role="group"
aria-label="スライド一覧"
aria-describedby={hintId}
tabIndex={0}
onKeyDown={onKeyDown}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
// Stops the browser's native image / link drag ghost on mouse drag.
onDragStart={(e) => e.preventDefault()}
>
{items.map((item, i) => {
const isVisible = visible[i] ?? true;
const state: SnapCarouselSlideState = {
index: i,
total: items.length,
active: i === activeSlide,
visible: isVisible,
};
return (
<div
key={item.id ?? i}
className={styles.slide}
role="group"
aria-roledescription="スライド"
aria-label={`${i + 1} / ${items.length}`}
data-active={state.active || undefined}
inert={!isVisible}
>
{renderItem ? renderItem(item, state) : <DefaultSlide item={item} index={i} />}
</div>
);
})}
</div>
{showArrows ? (
<>
{/* aria-disabled (not disabled) keeps focus on the button when
it hits an end, instead of dropping it to <body>. */}
<button
type="button"
className={`${styles.arrow} ${styles.prev}`}
aria-label="前へ"
aria-controls={trackId}
aria-disabled={!canPrev || undefined}
onClick={() => canPrev && step(-1)}
>
<ChevronLeft size={18} strokeWidth={2.2} aria-hidden />
</button>
<button
type="button"
className={`${styles.arrow} ${styles.next}`}
aria-label="次へ"
aria-controls={trackId}
aria-disabled={!canNext || undefined}
onClick={() => canNext && step(1)}
>
<ChevronRight size={18} strokeWidth={2.2} aria-hidden />
</button>
</>
) : null}
</div>
{indicator !== "none" || showPause ? (
<div className={styles.footer}>
{indicator === "progress" ? (
// Decorative: each slide already announces "n / total".
<div className={styles.progress} aria-hidden>
<span className={styles.thumb} />
</div>
) : null}
{indicator === "dots" && total > 1 ? (
<div className={styles.dots} role="group" aria-label="表示位置">
{Array.from({ length: total }, (_, i) => (
<button
key={i}
type="button"
className={styles.dot}
aria-label={`${i + 1} 番目へ`}
aria-current={i === current ? "true" : undefined}
aria-controls={trackId}
onClick={() => goTo(i, true)}
/>
))}
</div>
) : null}
{showPause ? (
<button
type="button"
className={styles.pause}
aria-label={userPaused ? "自動再生を開始" : "自動再生を停止"}
onClick={() => setUserPaused((p) => !p)}
>
{userPaused ? (
<Play size={12} strokeWidth={2.4} aria-hidden />
) : (
<Pause size={12} strokeWidth={2.4} aria-hidden />
)}
</button>
) : null}
</div>
) : null}
<span id={hintId} className={styles.srOnly}>
左右の矢印キーで移動できます
</span>
{/* Silent while autoplay runs, so it doesn't talk over the user. */}
<span className={styles.srOnly} aria-live={running ? "off" : "polite"} aria-atomic="true">
{announcement}
</span>
</section>
);
}
export default SnapCarousel;
```
#### `components/snap-carousel/SnapCarousel.module.css`
```css
/* ==========================================================================
Snap carousel — native CSS scroll-snap, Embla / Airbnb style
The track is a real horizontal scroller: momentum, trackpads, touch and
assistive tech come from the browser. Slide widths follow the carousel's
own width through container queries (never the viewport), so the same
markup shows 1.15 / 2.2 / 3.2 slides in a phone column, a card or a page.
========================================================================== */
/* Registered so the edge fades can transition (a plain custom property
inside mask-image would jump). Browsers without @property just snap. */
@property --sc-fade-start {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
@property --sc-fade-end {
syntax: "<length>";
inherits: false;
initial-value: 0px;
}
.root {
/* Defaults. The component only sets these inline for non-default values,
so the default render is exactly this stylesheet. */
--sc-gap: 16px;
--sc-spv-sm: 1;
--sc-spv-md: 2;
--sc-spv-lg: 3;
--sc-peek-sm: 0.15;
--sc-peek-md: 0.2;
--sc-peek-lg: 0.2;
--sc-fade-size: 56px;
--sc-radius: 18px;
--sc-aspect: 1 / 1;
/* Arrows sit on the art, not on the middle of art + caption. */
--sc-arrow-offset: 26px;
/* Written per frame by the component while scrolling. */
--sc-progress: 0;
--sc-thumb: 0.32;
position: relative;
container-type: inline-size;
width: 100%;
min-width: 0;
font-family:
-apple-system, BlinkMacSystemFont, "Hiragino Sans",
"Hiragino Kaku Gothic ProN", "Noto Sans JP", "Segoe UI", Roboto,
sans-serif;
color: #f4f4f5;
}
.root[data-peek="off"] {
--sc-peek-sm: 0;
--sc-peek-md: 0;
--sc-peek-lg: 0;
}
/* --sc-spv lives on the viewport so the track and the arrows share it. */
.viewport {
--sc-spv: calc(var(--sc-spv-sm) + var(--sc-peek-sm));
position: relative;
}
@container (min-width: 480px) {
.viewport {
--sc-spv: calc(var(--sc-spv-md) + var(--sc-peek-md));
}
}
@container (min-width: 640px) {
.viewport {
--sc-spv: calc(var(--sc-spv-lg) + var(--sc-peek-lg));
}
}
/* --- Track: the scroller ------------------------------------------------ */
.track {
position: relative; /* slides' offsetLeft is measured against it */
display: flex;
gap: var(--sc-gap);
overflow-x: auto;
overflow-y: hidden;
/* No browser back-swipe when a horizontal fling hits the end. */
overscroll-behavior-x: contain;
scroll-snap-type: x mandatory;
/* JS passes an explicit behavior; "smooth" here would also smooth every
scrollLeft write during a mouse drag. */
scroll-behavior: auto;
scrollbar-width: none;
padding-block: 6px;
outline: none;
}
.track::-webkit-scrollbar {
display: none;
}
.track[data-dragging] {
scroll-snap-type: none;
cursor: grabbing;
user-select: none;
}
/* Mouse: text selection would start on mousedown, before the drag
threshold is crossed, and highlight titles across cards. */
@media (hover: hover) and (pointer: fine) {
.track {
cursor: grab;
user-select: none;
}
}
/* Edge fades only where more content waits beyond the edge. Unprefixed
only: the build adds -webkit-mask-image, and writing both can make
Lightning CSS drop the standard one. */
.root[data-fade="on"] .track {
mask-image: linear-gradient(
to right,
transparent,
#000 var(--sc-fade-start),
#000 calc(100% - var(--sc-fade-end)),
transparent
);
transition:
--sc-fade-start 0.3s ease,
--sc-fade-end 0.3s ease;
}
.root[data-fade="on"] .viewport:not([data-at-start]) .track {
--sc-fade-start: var(--sc-fade-size);
}
.root[data-fade="on"] .viewport:not([data-at-end]) .track {
--sc-fade-end: var(--sc-fade-size);
}
/* The mask would clip an outline on the track, so the focus ring is drawn
by the (unmasked) viewport instead. */
.viewport:has(> .track:focus-visible)::after {
content: "";
position: absolute;
inset: 0 -4px;
border-radius: calc(var(--sc-radius) + 6px);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.85);
pointer-events: none;
}
/* (100% - gaps) / slides-per-view. A fractional count (3.2) leaves the
next slide peeking in at the edge. */
.slide {
flex: 0 0 calc((100% - (var(--sc-spv) - 1) * var(--sc-gap)) / var(--sc-spv));
min-width: 0;
scroll-snap-align: start;
}
.root[data-align="center"] .slide {
scroll-snap-align: center;
}
/* --- Default slide: product / content card ------------------------------ */
.card {
display: flex;
flex-direction: column;
gap: 12px;
}
.art {
position: relative;
aspect-ratio: var(--sc-aspect);
overflow: hidden;
isolation: isolate;
border-radius: var(--sc-radius);
background: #141417;
}
.artFill {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.6s cubic-bezier(0.2, 0.7, 0.2, 1);
}
/* Film grain over the art: keeps flat gradients from banding and looking
like clip-art. Pure SVG noise, no network request. */
.art::after {
content: "";
position: absolute;
inset: 0;
z-index: 2;
pointer-events: none;
opacity: 0.16;
mix-blend-mode: overlay;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
/* Top sheen + hairline rim, drawn above the image so photos get it too. */
.sheen {
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
pointer-events: none;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.1), transparent 38%);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
@media (hover: hover) {
.slide:hover .artFill {
transform: scale(1.04);
}
}
.meta {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: baseline;
gap: 3px 10px;
padding: 0 2px;
}
.title {
margin: 0;
overflow: hidden;
font-size: 14px;
font-weight: 500;
line-height: 1.35;
letter-spacing: -0.005em;
white-space: nowrap;
text-overflow: ellipsis;
}
.tag {
font-size: 12px;
color: rgba(255, 255, 255, 0.62);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.subtitle {
grid-column: 1 / -1;
margin: 0;
font-size: 13px;
line-height: 1.4;
color: rgba(255, 255, 255, 0.6);
font-variant-numeric: tabular-nums;
}
/* --- Prev / next: dark glass lenses, revealed on hover / focus ---------- */
.arrow {
position: absolute;
top: calc(50% - var(--sc-arrow-offset));
z-index: 3;
display: grid;
place-items: center;
width: 40px;
height: 40px;
padding: 0;
border: 0;
border-radius: 999px;
color: #fff;
cursor: pointer;
background: rgba(18, 18, 22, 0.62);
/* Unprefixed only (see the mask note above). */
backdrop-filter: blur(14px) saturate(160%);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.18),
inset 0 0 0 1px rgba(255, 255, 255, 0.12),
0 8px 24px rgba(0, 0, 0, 0.45);
opacity: 0;
pointer-events: none; /* invisible buttons must not eat taps */
transform: translateY(-50%) scale(0.88);
transition:
opacity 0.22s ease,
transform 0.22s ease,
background-color 0.2s ease;
-webkit-tap-highlight-color: transparent;
}
.prev {
left: 10px;
}
.next {
right: 10px;
}
.root:hover .arrow,
.root:focus-within .arrow {
opacity: 1;
pointer-events: auto;
transform: translateY(-50%) scale(1);
}
.arrow:hover {
background: rgba(36, 36, 42, 0.78);
}
.root .arrow:active {
transform: translateY(-50%) scale(0.94);
}
/* At an end the arrow steps aside, but stays faintly visible while it has
keyboard focus so the focus never seems to vanish. */
.root .arrow[aria-disabled="true"] {
opacity: 0;
pointer-events: none;
cursor: default;
}
.root .arrow[aria-disabled="true"]:focus-visible {
opacity: 0.4;
}
.arrow:focus-visible,
.dot:focus-visible,
.pause:focus-visible {
outline: 2px solid #fff;
outline-offset: 2px;
}
/* --- Footer: progress bar / dots + autoplay toggle ----------------------- */
.footer {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
min-height: 24px;
margin-top: 14px;
}
.progress {
position: relative;
width: min(240px, 46%);
height: 3px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
}
/* Thumb width = visible share of the track; it travels the remaining
width. translateX % is relative to the thumb itself, hence the ratio. */
.thumb {
position: absolute;
inset-block: 0;
left: 0;
width: calc(var(--sc-thumb) * 100%);
border-radius: inherit;
background: linear-gradient(90deg, rgba(255, 255, 255, 0.65), #fff);
box-shadow: 0 0 10px rgba(255, 255, 255, 0.35);
transform: translateX(
calc(var(--sc-progress) * (1 - var(--sc-thumb)) / var(--sc-thumb) * 100%)
);
will-change: transform;
}
.dots {
display: flex;
align-items: center;
gap: 6px;
}
.dot {
position: relative;
width: 6px;
height: 6px;
padding: 0;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.28);
cursor: pointer;
transition:
width 0.3s cubic-bezier(0.2, 0.7, 0.2, 1),
background-color 0.3s ease;
}
/* 6px dots, comfortable hit area. */
.dot::before {
content: "";
position: absolute;
inset: -9px -3px;
}
.dot[aria-current="true"] {
width: 20px;
background: #fff;
}
.pause {
display: grid;
place-items: center;
width: 26px;
height: 26px;
padding: 0;
border: 0;
border-radius: 999px;
color: rgba(255, 255, 255, 0.85);
cursor: pointer;
background: rgba(255, 255, 255, 0.08);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.14);
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.root[data-fade="on"] .track,
.artFill,
.arrow,
.dot {
transition: none;
}
.slide:hover .artFill {
transform: none;
}
}
```
代替手段: `components.json` がありシェルを実行できる環境なら、shadcn CLI でも同じファイルと依存パッケージが入ります。
```bash
npx shadcn@latest add https://anima-js.vercel.app/r/snap-carousel.json
```
### 3. 使用例を配置する
以下は配信元のプレイグラウンドで設定されていた値をそのまま反映した使用例です。これを基に、適切なページ・レイアウトへ配置してください。コード中の TODO コメントは、対応するか、何をすべきかを説明してください。
```tsx
import { SnapCarousel } from "@/components/snap-carousel";
// TODO: items を自分のデータに置き換えてください。image には画像 URL か CSS の background 値(グラデーション可)を渡せます
// TODO: カードの中身を自由に描くなら renderItem={(item, state) => …} を渡してください(state.active / state.visible 付き)
<SnapCarousel
slidesPerView={3}
gap={16}
peek={true}
align="start"
showArrows={true}
indicator="progress"
fade={true}
loop={false}
autoplay={false}
interval={4500}
aria-label="おすすめ商品"
items={[
{ title: "Aero ワイヤレスイヤホン", subtitle: "¥24,800", meta: "★ 4.8", image: "radial-gradient(34% 22% at 50% 52%, #f5f5f7, #9a9aa3 92%, transparent 95%), radial-gradient(30% 5% at 50% 80%, rgba(0,0,0,.55), transparent), radial-gradient(120% 90% at 50% 0%, #3b3f58, #0b0b0e 75%)" },
{ title: "Halo スマートリング", subtitle: "¥39,600", meta: "★ 4.6", image: "radial-gradient(circle at 50% 50%, transparent 15%, #ffd9a8 16%, #a8672f 26%, transparent 26.6%), radial-gradient(30% 5% at 50% 80%, rgba(0,0,0,.55), transparent), radial-gradient(120% 90% at 50% 0%, #4a2c1c, #0b0b0e 75%)" },
{ title: "Orbit スピーカー", subtitle: "¥18,700", meta: "★ 4.7", image: "radial-gradient(circle at 44% 40%, #fff 0, #ff8a5c 9%, #7a2414 27%, transparent 27.6%), radial-gradient(30% 5% at 50% 80%, rgba(0,0,0,.55), transparent), radial-gradient(120% 90% at 50% 0%, #5a2418, #0b0b0e 75%)" },
{ title: "Pebble モバイルバッテリー", subtitle: "¥6,980", meta: "★ 4.5", image: "radial-gradient(34% 22% at 50% 52%, #b8f3d8, #2f8a64 92%, transparent 95%), radial-gradient(30% 5% at 50% 80%, rgba(0,0,0,.55), transparent), radial-gradient(120% 90% at 50% 0%, #173d33, #0b0b0e 75%)" },
{ title: "Lumen デスクライト", subtitle: "¥12,100", meta: "★ 4.9", image: "radial-gradient(circle at 44% 40%, #fff 0, #ffe27a 9%, #a8740c 27%, transparent 27.6%), radial-gradient(30% 5% at 50% 80%, rgba(0,0,0,.55), transparent), radial-gradient(120% 90% at 50% 0%, #4a3a10, #0b0b0e 75%)" },
{ title: "Loop ヘッドバンド", subtitle: "¥8,800", meta: "★ 4.4", image: "radial-gradient(circle at 50% 50%, transparent 15%, #c7b8ff 16%, #5b47c9 26%, transparent 26.6%), radial-gradient(30% 5% at 50% 80%, rgba(0,0,0,.55), transparent), radial-gradient(120% 90% at 50% 0%, #2a2160, #0b0b0e 75%)" },
{ title: "Drift アロマディフューザー", subtitle: "¥9,900", meta: "★ 4.6", image: "radial-gradient(circle at 44% 40%, #fff 0, #9fd8ff 9%, #1f5f9a 27%, transparent 27.6%), radial-gradient(30% 5% at 50% 80%, rgba(0,0,0,.55), transparent), radial-gradient(120% 90% at 50% 0%, #163050, #0b0b0e 75%)" },
{ title: "Nook ワイヤレス充電器", subtitle: "¥5,480", meta: "★ 4.3", image: "radial-gradient(34% 22% at 50% 52%, #ffb3cf, #b03a6c 92%, transparent 95%), radial-gradient(30% 5% at 50% 80%, rgba(0,0,0,.55), transparent), radial-gradient(120% 90% at 50% 0%, #4a1a30, #0b0b0e 75%)" },
{ title: "Crest スマートウォッチ", subtitle: "¥52,800", meta: "★ 4.8", image: "radial-gradient(circle at 50% 50%, transparent 15%, #e9e9ee 16%, #6b6b78 26%, transparent 26.6%), radial-gradient(30% 5% at 50% 80%, rgba(0,0,0,.55), transparent), radial-gradient(120% 90% at 50% 0%, #2e3038, #0b0b0e 75%)" },
{ title: "Glow ナイトランプ", subtitle: "¥4,620", meta: "★ 4.7", image: "radial-gradient(circle at 44% 40%, #fff 0, #ffc2a1 9%, #b25a2c 27%, transparent 27.6%), radial-gradient(30% 5% at 50% 80%, rgba(0,0,0,.55), transparent), radial-gradient(120% 90% at 50% 0%, #3d2216, #0b0b0e 75%)" },
]}
/>
```
### 4. 組み込み手順
1. 表示したい場所(Server Component でも可。コンポーネント自体が `"use client"`)に `<SnapCarousel items={…} aria-label="おすすめ商品" />` を置く。`aria-label` はページ内で何のカルーセルかが分かる名前にする。
2. `items` は `{ id?, title?, subtitle?, meta?, image?, alt? }[]`。`image` は画像 URL か CSS の background 値(グラデーション可)。URL は `<img loading="lazy">` で描画されるので、意味のある画像には `alt` を付ける。並べ替えや追加がある場合は `id` を渡す(React の key になる)。
3. 幅は**親コンテナ**で決まる(コンテナクエリ)。親が `display: flex` の場合は `min-width: 0` を付けないとトラックが縮まずページが横にはみ出す。
4. 枚数は `slidesPerView`。数値なら「640px 以上での枚数」で、480–639px は最大 2 枚、479px 以下は 1 枚。個別に決めるなら `{ sm, md, lg }`。`peek`(既定 true)で +0.15 / +0.2 枚のぞく。
5. カードの中身を自作する場合は `renderItem={(item, state) => …}`。スライド内の `<a>` や `<button>` はそのまま使える(マウスドラッグ後のクリックは自動で無効化される)。
6. 現在位置を外で使うなら `onIndexChange`、外から動かすなら `index`(制御)/ `defaultIndex`(初期位置)。index は「スナップ位置」の番号で、`align="start"` では先頭に見えているスライド番号と同じ。末尾付近のスライドは最後の位置にまとまるため、位置の数はスライド数より少なくなることがある。
7. `autoplay` はホバー・フォーカス中・画面外・タブ非表示で止まり、OS の「視差効果を減らす」では無効。停止 / 再開ボタンが自動で出る(WCAG 2.2.2)。
8. `npm run build` が通ることを確認し、実機で「縦スクロールがカルーセル上でも効く」「横スワイプの慣性」「マウスドラッグ後にリンクが開かない」を確認する。
#### 触ってはいけないところ
| 症状 | 原因 | 対処 |
| --- | --- | --- |
| マウスドラッグがカクつく / 指を離す前にスナップする | ドラッグ中も `scroll-snap-type` が有効、または CSS の `scroll-behavior: smooth` が scrollLeft の代入を補間している | ドラッグ中は `data-dragging` でスナップを外す仕組みと、トラックの `scroll-behavior: auto` を残す。スムーズさは JS の `scrollTo({ behavior })` 側で付ける |
| ドラッグして離すとカードのリンクが開く | pointerup 直後の click が届いている | 同梱のキャプチャ段階の click 抑止(`once` + 0ms で解除)を外さない |
| Chrome で端のフェードが効かない | `mask-image` と `-webkit-mask-image` を併記すると Next.js(Lightning CSS)が標準側を削ることがある | **unprefixed だけ書く**。`backdrop-filter` も同様 |
| フォーカスリングが見えない | トラックにマスクが掛かっていて outline ごと消える | リングはマスクの無い `.viewport` の `::after` に描く(`:has(> .track:focus-visible)`) |
| スマホでカルーセル上の縦スクロールが効かない | `touch-action: pan-x` や pointer の `preventDefault` を追加した | タッチはネイティブスクロールに任せる。ドラッグ処理は `pointerType === "mouse"` のときだけ |
| 枚数がビューポート幅で変わってしまう | `vw` やメディアクエリで幅を決めた | ルートの `container-type: inline-size` と `@container` を使う。サイドバー内やカード内でも正しい枚数になる |
### 5. 完了条件
- 型チェックとビルド(`npm run build` 相当)が通る
- 使用例を置いたページでコンポーネントが表示され、操作に反応する
- "use client" が維持され、不透明な背景のラッパーが追加されていない
- 使用例の TODO コメントが解消されている(または対応方法が説明されている)
- 「組み込み手順」にある作業がすべて済んでいる
## 付録: 見た目と挙動の仕様(レビュー用)
正となるのは上のソースコードです。以下は、実装後に見た目と挙動がギャラリーと一致しているかを確認するための仕様です。ソースを使えない事情がある場合は、この仕様を満たすように同じコンポーネントを実装してください。
### 前提
- Next.js(App Router)、React 19、TypeScript、CSS Modules、`lucide-react`(矢印・再生アイコン)。アニメーションライブラリは不要
- ファイル: `components/snap-carousel/SnapCarousel.tsx`(`"use client"`)+ `SnapCarousel.module.css` + `index.ts`
- props: `items`、`slidesPerView`(数値 = lg、または `{ sm, md, lg }`、既定 1 / 2 / 3)、`gap`(16)、`peek`(true)、`showArrows`(true)、`indicator`(`"progress" | "dots" | "none"`、既定 progress)、`fade`(true)、`loop`(false)、`autoplay`(false)、`interval`(4500ms)、`align`(`"start" | "center"`)、`index` / `defaultIndex` / `onIndexChange`、`renderItem(item, state)`、`className`、`aria-label`
### 見た目(ダーク前提)
- 構造: `section.root`(`container-type: inline-size`)→ `.viewport`(相対配置、矢印の基準)→ `.track`(横スクローラー)→ `.slide` × n、その下に `.footer`
- 枚数: `.viewport` に `--sc-spv` = sm + peek-sm(1 + .15)、`@container (min-width: 480px)` で md(2 + .2)、640px 以上で lg(3 + .2)。スライド幅 `calc((100% - (var(--sc-spv) - 1) * var(--sc-gap)) / var(--sc-spv))`
- トラック: `display: flex; gap: var(--sc-gap); overflow-x: auto; scroll-snap-type: x mandatory; overscroll-behavior-x: contain; scrollbar-width: none; padding-block: 6px`、スライドは `scroll-snap-align: start`(center 時は center)
- 既定カード: アート(`aspect-ratio: 1 / 1`、角丸 18px、背景 `#141417`)+ 下にタイトル行。アートには上部の反射(白 .10 → 透明 38%)と内側 1px の白 .08 のリム、SVG `feTurbulence` のフィルムグレイン(opacity .16、`mix-blend-mode: overlay`)。ホバーでアートだけ `scale(1.04)`(600ms)
- テキスト: タイトル 14px / 500 / 1 行省略、右端に `meta`(12px、白 .62、tabular-nums。評価など)、2 行目に `subtitle`(13px、白 .60。価格など)
- 端のフェード: `mask-image: linear-gradient(to right, transparent, #000 var(--sc-fade-start), #000 calc(100% - var(--sc-fade-end)), transparent)`。先頭にいないときだけ左 56px、末尾にいないときだけ右 56px。`@property` で登録した長さなので 300ms で滑らかに出入りする
- 矢印: 40px の円、`rgba(18,18,22,.62)` + `backdrop-filter: blur(14px) saturate(160%)`、`inset 0 1px 0 白 .18` / `inset 0 0 0 1px 白 .12` / `0 8px 24px rgba(0,0,0,.45)`。左右 10px、縦はアート中央付近(`50% - 26px`)。通常は opacity 0 + `scale(.88)` + `pointer-events: none`、ルートの `:hover` / `:focus-within` で表示。端では再び消える(フォーカス中は .4)
- プログレスバー: 幅 `min(240px, 46%)`・高さ 3px・白 .12 の溝。つまみの幅 = `clientWidth / scrollWidth`、位置 = `scrollLeft / 最大`。CSS 変数 `--sc-thumb` / `--sc-progress` で `translateX` のみ動かす。白のグラデーション + 淡いグロー
- ドット: 6px、白 .28。現在位置は幅 20px の白いピル(300ms)。ヒット領域は疑似要素で拡張
- フォーカス: トラックはマスクで outline が消えるため、`.viewport:has(> .track:focus-visible)::after` に白 2px のリング。ボタン類は `outline: 2px solid #fff; outline-offset: 2px`
### モーション
- スクロール自体はネイティブ(タッチの慣性、トラックパッド、ホイール)。JS は `scroll` を `requestAnimationFrame` で間引いて位置・端・進捗を読むだけで、毎フレームの React 再レンダーはしない
- スナップ位置: 各スライドの `offsetLeft`(center は中央合わせ)を `[0, 最大スクロール]` にクランプし、2px 以内の重複をまとめたもの。ResizeObserver と props 変更で再計測
- マウスドラッグ: 6px 動いたらドラッグ開始 → pointer capture、`data-dragging` でスナップ解除、`scrollLeft = 開始位置 - dx`。離したら速度(px/ms、平滑化。離す前に 80ms 止まっていたら 0)× 220ms 先を予測し、最寄りのスナップ位置へ `scrollTo({ behavior: "smooth" })`。スクロール位置が目的地に着いたら(保険で 900ms 後に)スナップを戻す。着地前に次のドラッグが始まったら復帰を取り消す(`scrollend` は未対応ブラウザやドラッグ自身のスクロールで発火し得るので使わない)。マウス環境ではトラックに `user-select: none`。直後の click はキャプチャ段階で 1 回だけ破棄
- ボタン / キー: 1 スナップ位置ずつ。移動中の連打は目的地から数える。`loop` なら端で反対側へ巻き戻す(クローンなし)
- 自動再生: 位置が変わるたびに `interval` のタイマーを張り直し、末尾では先頭へ。ホバー・フォーカス・画面外(IntersectionObserver)・タブ非表示で停止
- `prefers-reduced-motion: reduce`: `scrollTo` は `behavior: "auto"`、自動再生なし、フェード・ホバー拡大・ボタンのトランジションなし
### アクセシビリティ
- ルート `<section aria-roledescription="カルーセル" aria-label="…">`。トラックは `role="group"`・`tabIndex=0`・「左右の矢印キーで移動できます」を `aria-describedby`
- キー: ← / → で 1 位置、Home / End で先頭 / 末尾(トラック自身にフォーカスがあるときだけ)
- 各スライド `role="group" aria-roledescription="スライド" aria-label="n / 全数"`。完全に画面外のスライドは `inert`(IntersectionObserver、root = トラック)。のぞいているスライドは操作可能のまま
- 矢印は `aria-label="前へ" / "次へ"`・`aria-controls`。端では `disabled` ではなく `aria-disabled`(フォーカスが body に落ちない)
- ボタン / キー / ドットでの移動は `aria-live="polite"` で「n / 全数」を読み上げ。自動再生中は `aria-live="off"`
- 自動再生時は停止 / 再開ボタン(`aria-label` が切り替わる)
- プログレスバーは装飾(`aria-hidden`)。ドットは `aria-current` 付きのボタン
### 受け入れ条件
- 幅 360px で 1.15 枚、520px で 2.2 枚、720px で 3.2 枚(peek あり・既定)。ビューポートではなく親の幅に追従する
- タッチで横スワイプすると慣性で流れてスナップし、カルーセル上で縦スワイプするとページがスクロールする
- マウスでドラッグして離すと最寄りのカードにスナップし、カード内のリンクは開かない。ドラッグせずにクリックすればリンクは開く
- 先頭では「前へ」と左フェードが無く、末尾では「次へ」と右フェードが無い(loop 時は矢印が常に有効)
- プログレスバーがスクロールに追従し、ドット表示では最後の位置まで到達できる
- トラックに Tab で入り ← / → / Home / End で移動でき、フォーカスリングが見える
- 自動再生はホバー・フォーカスで止まり、「視差効果を減らす」設定では動かないnpx shadcn@latest add https://anima-js.vercel.app/r/snap-carousel.jsonimport { SnapCarousel } from "@/components/snap-carousel";
// TODO: items を自分のデータに置き換えてください。image には画像 URL か CSS の background 値(グラデーション可)を渡せます
// TODO: カードの中身を自由に描くなら renderItem={(item, state) => …} を渡してください(state.active / state.visible 付き)
<SnapCarousel
slidesPerView={3}
gap={16}
peek={true}
align="start"
showArrows={true}
indicator="progress"
fade={true}
loop={false}
autoplay={false}
interval={4500}
aria-label="おすすめ商品"
items={[
{ title: "Aero ワイヤレスイヤホン", subtitle: "¥24,800", meta: "★ 4.8", image: "radial-gradient(34% 22% at 50% 52%, #f5f5f7, #9a9aa3 92%, transparent 95%), radial-gradient(30% 5% at 50% 80%, rgba(0,0,0,.55), transparent), radial-gradient(120% 90% at 50% 0%, #3b3f58, #0b0b0e 75%)" },
{ title: "Halo スマートリング", subtitle: "¥39,600", meta: "★ 4.6", image: "radial-gradient(circle at 50% 50%, transparent 15%, #ffd9a8 16%, #a8672f 26%, transparent 26.6%), radial-gradient(30% 5% at 50% 80%, rgba(0,0,0,.55), transparent), radial-gradient(120% 90% at 50% 0%, #4a2c1c, #0b0b0e 75%)" },
{ title: "Orbit スピーカー", subtitle: "¥18,700", meta: "★ 4.7", image: "radial-gradient(circle at 44% 40%, #fff 0, #ff8a5c 9%, #7a2414 27%, transparent 27.6%), radial-gradient(30% 5% at 50% 80%, rgba(0,0,0,.55), transparent), radial-gradient(120% 90% at 50% 0%, #5a2418, #0b0b0e 75%)" },
{ title: "Pebble モバイルバッテリー", subtitle: "¥6,980", meta: "★ 4.5", image: "radial-gradient(34% 22% at 50% 52%, #b8f3d8, #2f8a64 92%, transparent 95%), radial-gradient(30% 5% at 50% 80%, rgba(0,0,0,.55), transparent), radial-gradient(120% 90% at 50% 0%, #173d33, #0b0b0e 75%)" },
{ title: "Lumen デスクライト", subtitle: "¥12,100", meta: "★ 4.9", image: "radial-gradient(circle at 44% 40%, #fff 0, #ffe27a 9%, #a8740c 27%, transparent 27.6%), radial-gradient(30% 5% at 50% 80%, rgba(0,0,0,.55), transparent), radial-gradient(120% 90% at 50% 0%, #4a3a10, #0b0b0e 75%)" },
{ title: "Loop ヘッドバンド", subtitle: "¥8,800", meta: "★ 4.4", image: "radial-gradient(circle at 50% 50%, transparent 15%, #c7b8ff 16%, #5b47c9 26%, transparent 26.6%), radial-gradient(30% 5% at 50% 80%, rgba(0,0,0,.55), transparent), radial-gradient(120% 90% at 50% 0%, #2a2160, #0b0b0e 75%)" },
{ title: "Drift アロマディフューザー", subtitle: "¥9,900", meta: "★ 4.6", image: "radial-gradient(circle at 44% 40%, #fff 0, #9fd8ff 9%, #1f5f9a 27%, transparent 27.6%), radial-gradient(30% 5% at 50% 80%, rgba(0,0,0,.55), transparent), radial-gradient(120% 90% at 50% 0%, #163050, #0b0b0e 75%)" },
{ title: "Nook ワイヤレス充電器", subtitle: "¥5,480", meta: "★ 4.3", image: "radial-gradient(34% 22% at 50% 52%, #ffb3cf, #b03a6c 92%, transparent 95%), radial-gradient(30% 5% at 50% 80%, rgba(0,0,0,.55), transparent), radial-gradient(120% 90% at 50% 0%, #4a1a30, #0b0b0e 75%)" },
{ title: "Crest スマートウォッチ", subtitle: "¥52,800", meta: "★ 4.8", image: "radial-gradient(circle at 50% 50%, transparent 15%, #e9e9ee 16%, #6b6b78 26%, transparent 26.6%), radial-gradient(30% 5% at 50% 80%, rgba(0,0,0,.55), transparent), radial-gradient(120% 90% at 50% 0%, #2e3038, #0b0b0e 75%)" },
{ title: "Glow ナイトランプ", subtitle: "¥4,620", meta: "★ 4.7", image: "radial-gradient(circle at 44% 40%, #fff 0, #ffc2a1 9%, #b25a2c 27%, transparent 27.6%), radial-gradient(30% 5% at 50% 80%, rgba(0,0,0,.55), transparent), radial-gradient(120% 90% at 50% 0%, #3d2216, #0b0b0e 75%)" },
]}
/>