BLK-32Blocks
実績カルーセル(横スクロール・層視差)
アニメーションライブラリを使わず、ブラウザ標準のスクロールスナップだけで組んだ実績・事例カルーセル。トラックは overflow-x-auto + snap-x snap-mandatory なので、ドラッグ・スワイプ・ホイール・キーボードのフォーカス送りは実装せずに手に入る。JSが担うのは「今どこにいるか読む」ことと scrollTo で送ることだけで、送りの緩急はCSSのscroll-behaviorに委ねているため prefers-reduced-motion では自動的に瞬間移動へ降格する。カード内は、スクロール位置から求めた1つのCSS変数(--p)を地紋・アンバー図形・ゴースト番号・種別ロックアップの4層が別々の係数で読み、層ごとの移動量の差で視差の奥行きを作る(Reactのstateは経由せず再レンダーはゼロ)。自動送りは末尾で巻き戻さず進行方向を反転し、停止ボタン・画面外・ポインタ滞在・タブ非表示・reduced-motionの5条件で止まる。画像素材とライブラリ依存はゼロ、リンクはスライドにつき1本のstretched linkで<a>の入れ子を作らない。
- 追加日:
- 2026-08-20
- 依存:
- なし
- tags
- #block #works #case-study #carousel #scroll-snap #parallax #autoplay #container-query #stretched-link #no-image #no-dependency
プレビュー
BLK-32 — Works / Carousel
現場で撮った、その日の一枚。
完成した画面より、決まった瞬間のほうが仕事を説明します。横に送りながら、6件の現場を順に見てください。
表示中の事例: 01/06
Drag / Swipe
実績をすべて見る"use client";
import { useCallback, useEffect, useRef, useState } from "react";
/**
* 横スクロール・ギャラリーカルーセル(BLK-32)
*
* 汎用技法メモ(web-design-playbook へ還流する要点)
*
* 1. カルーセルの「進行」はライブラリではなくスクロール位置そのものに持たせる
* アニメーションライブラリでトラックを translateX するとスクロール量が仮想値になり、
* ドラッグ・ホイール・キーボード・スクリーンリーダーの読み上げ順が全部ハンドラ実装になる。
* `overflow-x-auto` + `snap-x snap-mandatory` にすると、この4つは全部ブラウザが持つ。
* JS が担うのは「今どこにいるか読む」ことと `scrollTo` で送ることの2つだけになる。
*
* 2. 送りは scrollTo({left}) だけ書き、緩急は CSS の scroll-behavior に委ねる
* behavior を省いた scrollTo は要素の計算済み scroll-behavior に従う。トラック側を
* `scroll-smooth motion-reduce:scroll-auto` にしておけば、reduced-motion のときだけ
* 瞬間移動になる。JS 側に分岐を書かずにモーションの降格が済む。
*
* 3. カード間の視差は「1フレーム1回、CSS変数を1つ書く」だけで作れる
* 各スライドの中心とトラックの中心の差をトラック幅で正規化した値 --p(≒ -1〜1)を書き、
* 地紋 → ゴースト番号 → 前景ラベルの3層が `calc(var(--p) * ±Npx)` を別々の係数で読む。
* 層ごとの移動量の差=視差で、React の state は一切経由しない(再レンダーはゼロ)。
* plate 側の `overflow-hidden` が層のはみ出しを切ることで初めて奥行きとして読める。
*
* 4. スクロールイベントは rAF で1フレームに畳む・読み取りはまとめて行う
* scroll は1フレームに複数回飛ぶ。`if (!raf.current)` で予約済みなら何もしないことで
* 書き込み回数を描画回数に一致させ、offsetLeft/scrollLeft の読み取りも1関数内に集約して
* レイアウトの読み書きが交互になる(強制同期レイアウト)のを避ける。
*
* 5. 自動送りは「巻き戻さない」— 端で向きを反転する
* 末尾から先頭へ戻す実装はトラック全長を一気に流れて視線が飛ぶ。到達端を
* `scrollLeft >= scrollWidth - clientWidth - 2` で検出して進行方向を反転させると、
* 複数枚が同時に見えるレイアウト(最後のスライドが左端に来られない)でも詰まらない。
*
* 6. 自動送りを止める条件は4つ持つ
* reduced-motion / 画面外(IntersectionObserver の ratio)/ ポインタ・フォーカス滞在 /
* タブ非表示。加えて停止ボタンを必ず置き、停止中だけ現在位置を aria-live で読ませる
* (自動送り中に読み上げると通知が鳴り続けるため live は off にする)。
*
* 着想: 「横スクロールのギャラリーカルーセルと、カード内の層ごとの視差」という着想。
* 実装・文言・配色・構成はすべて First CH のオリジナル(外部コード・素材の取り込みは無し)。
*/
type Moment = {
no: string;
kind: string;
industry: string;
title: string;
note: string;
scene: string;
date: string;
shape: 0 | 1 | 2 | 3;
};
const moments: Moment[] = [
{
no: "01",
kind: "SITE",
industry: "建築金物",
title: "見積依頼を、写真1枚から始められるように",
note: "「品番が分からない」で止まっていた問い合わせを、写真を添えて送るだけの1ステップに置き換えました。",
scene: "公開の翌朝、最初の1件は現場から届いた。",
date: "2026.05",
shape: 0,
},
{
no: "02",
kind: "LP",
industry: "歯科",
title: "はじめての人にだけ、道順を長く見せる",
note: "初診と再診で入口を分け、迷いやすい人にだけ地図と持ち物の案内を厚く出す構成にしました。",
scene: "撮影日、待合室の光がいちばん柔らかい時間を待った。",
date: "2026.03",
shape: 1,
},
{
no: "03",
kind: "SHOP",
industry: "焙煎所",
title: "焙煎の周期に、売り場のほうを合わせる",
note: "入荷日を入れておけば棚が自動で入れ替わる仕組みに。毎月の差し替え作業がなくなりました。",
scene: "試し焼きの香りの中で、豆の並び順を決めた。",
date: "2026.01",
shape: 2,
},
{
no: "04",
kind: "FORM",
industry: "社会保険労務士",
title: "相談前の不安を、3つの質問に畳む",
note: "長い問い合わせフォームを3問に分け、送信の前に相談内容の要約を見せてから確定する形へ。",
scene: "初稿を見せた日、担当者が最初に触ったのは戻るボタンだった。",
date: "2025.11",
shape: 3,
},
{
no: "05",
kind: "SITE",
industry: "造園",
title: "四季を、更新作業ではなく設定にする",
note: "季節ごとの主役写真を日付で切り替え、現場が触る画面を1つだけに絞りました。",
scene: "剪定の音が止んだ夕方に、庭の写真の順番が決まった。",
date: "2025.09",
shape: 1,
},
{
no: "06",
kind: "EC",
industry: "器・工芸",
title: "一点ものを、待たせずに見せ切る",
note: "再入荷を待つ人の受け口を商品ページの中に置き、次の窯出しの予定をその場で伝える形にしました。",
scene: "窯出しの日、割れた1枚も含めて記録に残した。",
date: "2025.07",
shape: 0,
},
];
// 地紋(斜めのヘアライン)。plate ごとに角度を変えず、視差の移動量だけで差を出す。
const grain =
"repeating-linear-gradient(45deg, rgba(17,24,39,0.05) 0 1px, transparent 1px 10px)";
// aria-hidden の装飾図形。data 側で固定し、並び順やリビールで形が飛ばないようにする。
const shapes = [
"-top-10 -left-10 size-36 rounded-full border-[10px] border-amber-500/25",
"-right-8 -bottom-10 size-32 rotate-12 bg-amber-500/15",
"top-1/2 -left-3 h-28 w-4 -translate-y-1/2 bg-amber-500/25",
"-top-12 right-8 size-28 -rotate-12 border-y-[10px] border-amber-500/25",
];
const AUTOPLAY_MS = 4600;
export default function MomentsGalleryCarousel() {
const rootRef = useRef<HTMLElement>(null);
const trackRef = useRef<HTMLUListElement>(null);
const raf = useRef(0);
const parallax = useRef(false); // reduced-motion のときは CSS 変数を書かない
const direction = useRef(1); // 自動送りの進行方向(端で反転する)
const [index, setIndex] = useState(0);
const [bounds, setBounds] = useState({ start: true, end: false });
const [playing, setPlaying] = useState(true);
const [hovering, setHovering] = useState(false);
const [inView, setInView] = useState(false);
const [revealed, setRevealed] = useState(false);
// 1フレームに1回だけ、スクロール位置を読んで CSS 変数と現在位置へ反映する
const measure = useCallback(() => {
raf.current = 0;
const track = trackRef.current;
const root = rootRef.current;
if (!track || !root) return;
const max = track.scrollWidth - track.clientWidth;
root.style.setProperty("--progress", max > 0 ? (track.scrollLeft / max).toFixed(4) : "1");
const viewport = track.clientWidth || 1;
const center = track.scrollLeft + viewport / 2;
const base = (track.children[0] as HTMLElement | undefined)?.offsetLeft ?? 0;
let nearest = 0;
let min = Infinity;
for (let i = 0; i < track.children.length; i++) {
const el = track.children[i] as HTMLElement;
// 先頭に来ているスライド=現在位置(snap-start なので左端との距離で決まる)
const gap = Math.abs(el.offsetLeft - base - track.scrollLeft);
if (gap < min) {
min = gap;
nearest = i;
}
if (parallax.current) {
// トラックの中心から見た位置を -1〜1 に正規化して層へ配る
const p = ((el.offsetLeft + el.offsetWidth / 2 - center) / viewport) * 2;
el.style.setProperty("--p", Math.max(-1.4, Math.min(1.4, p)).toFixed(3));
}
}
setIndex((prev) => (prev === nearest ? prev : nearest));
const atStart = track.scrollLeft <= 2;
const atEnd = track.scrollLeft >= max - 2;
setBounds((prev) => (prev.start === atStart && prev.end === atEnd ? prev : { start: atStart, end: atEnd }));
}, []);
useEffect(() => {
parallax.current = !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const track = trackRef.current;
if (!track) return;
const schedule = () => {
if (!raf.current) raf.current = requestAnimationFrame(measure);
};
track.addEventListener("scroll", schedule, { passive: true });
window.addEventListener("resize", schedule);
measure();
return () => {
track.removeEventListener("scroll", schedule);
window.removeEventListener("resize", schedule);
cancelAnimationFrame(raf.current);
};
}, [measure]);
// リビールと「画面内か」を1つの Observer で見る
useEffect(() => {
const root = rootRef.current;
if (!root) return;
if (typeof IntersectionObserver === "undefined") {
// 非対応環境では観測せずに表示だけ確定させる(effect 内の同期 setState は避ける)
const id = requestAnimationFrame(() => {
setRevealed(true);
setInView(true);
});
return () => cancelAnimationFrame(id);
}
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) setRevealed(true);
setInView(entry.intersectionRatio >= 0.3);
}
},
{ threshold: [0.05, 0.3, 0.6] },
);
io.observe(root);
return () => io.disconnect();
}, []);
const goTo = useCallback((i: number) => {
const track = trackRef.current;
if (!track) return;
const clamped = Math.max(0, Math.min(track.children.length - 1, i));
const el = track.children[clamped] as HTMLElement | undefined;
const first = track.children[0] as HTMLElement | undefined;
if (!el || !first) return;
// offsetLeft はトラックの padding を含むので先頭スライド基準の相対位置に直す。
// behavior は書かない=トラックの scroll-behavior(reduced-motion で auto)に従う
track.scrollTo({ left: el.offsetLeft - first.offsetLeft });
}, []);
// 自動送り: 停止条件は reduced-motion / 画面外 / 滞在中 / タブ非表示 の4つ
useEffect(() => {
if (!playing || !inView || hovering) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const id = window.setInterval(() => {
const track = trackRef.current;
if (!track || document.hidden) return;
const max = track.scrollWidth - track.clientWidth;
if (track.scrollLeft >= max - 2) direction.current = -1;
if (track.scrollLeft <= 2) direction.current = 1;
goTo(index + direction.current);
}, AUTOPLAY_MS);
return () => window.clearInterval(id);
}, [playing, inView, hovering, index, goTo]);
// リビール: transform/opacity のみ。reduced-motion では初期状態=最終状態にする
const reveal =
"transition-[opacity,transform] duration-700 ease-out motion-reduce:transition-none " +
(revealed
? "translate-y-0 opacity-100"
: "translate-y-6 opacity-0 motion-reduce:translate-y-0 motion-reduce:opacity-100");
const delay = (ms: number) => ({ transitionDelay: `${ms}ms` });
const total = moments.length;
return (
<section
ref={rootRef}
aria-labelledby="moments-heading"
aria-roledescription="カルーセル"
className="@container w-full max-w-5xl bg-white"
>
{/* 見出し+操作 */}
<div className="flex flex-col gap-6 px-1 @2xl:flex-row @2xl:items-end @2xl:justify-between">
<div className="max-w-xl">
<p
style={delay(0)}
className={`font-display text-xs font-bold tracking-[0.25em] text-amber-600 uppercase ${reveal}`}
>
BLK-32 — Works / Carousel
</p>
<h2
id="moments-heading"
style={delay(80)}
className={`mt-4 text-[clamp(1.7rem,3.8vw,2.4rem)] leading-[1.3] font-bold tracking-tight text-gray-900 ${reveal}`}
>
現場で撮った、
{/* 句点だけが折り返して次行に落ちないよう、下線付きの語と一体で扱う */}
<span className="whitespace-nowrap">
<span className="relative inline-block">
<span className="relative z-10">その日の一枚</span>
<span
aria-hidden="true"
className="absolute -bottom-1 left-0 h-[3px] w-full bg-amber-500/70"
/>
</span>
。
</span>
</h2>
<p
style={delay(160)}
className={`mt-5 text-[15px] leading-[1.9] font-medium text-gray-600 ${reveal}`}
>
完成した画面より、決まった瞬間のほうが仕事を説明します。横に送りながら、6件の現場を順に見てください。
</p>
</div>
{/* 進行制御 */}
<div style={delay(240)} className={`flex items-center gap-3 ${reveal}`}>
<p
className="font-display text-[12px] font-semibold tracking-[0.12em] text-gray-400 tabular-nums"
aria-live={playing ? "off" : "polite"}
>
<span className="sr-only">表示中の事例: </span>
<span className="text-gray-900">{String(index + 1).padStart(2, "0")}</span>
<span className="mx-1 text-gray-300">/</span>
{String(total).padStart(2, "0")}
</p>
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => goTo(index - 1)}
disabled={bounds.start}
aria-label="前の事例へ"
className="grid size-9 place-items-center rounded-full border border-gray-300 text-[13px] text-gray-700 transition-colors hover:border-gray-900 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 disabled:border-gray-200 disabled:text-gray-300 motion-reduce:transition-none"
>
<span aria-hidden="true">←</span>
</button>
<button
type="button"
onClick={() => goTo(index + 1)}
disabled={bounds.end}
aria-label="次の事例へ"
className="grid size-9 place-items-center rounded-full border border-gray-300 text-[13px] text-gray-700 transition-colors hover:border-gray-900 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 disabled:border-gray-200 disabled:text-gray-300 motion-reduce:transition-none"
>
<span aria-hidden="true">→</span>
</button>
<button
type="button"
onClick={() => setPlaying((v) => !v)}
aria-pressed={!playing}
aria-label={playing ? "自動送りを停止" : "自動送りを再開"}
className="ml-1 grid size-9 place-items-center rounded-full border border-gray-300 text-[11px] text-gray-700 transition-colors hover:border-gray-900 hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
>
<span aria-hidden="true">{playing ? "❙❙" : "▶"}</span>
</button>
</div>
</div>
</div>
{/* トラック。スナップ・ドラッグ・キーボード送りはすべてブラウザ側の機能 */}
<div
style={delay(320)}
className={`mt-9 border-y border-gray-200 py-8 ${reveal}`}
onPointerEnter={() => setHovering(true)}
onPointerLeave={() => setHovering(false)}
onFocus={() => setHovering(true)}
onBlur={() => setHovering(false)}
>
<ul
ref={trackRef}
className="relative flex snap-x snap-mandatory gap-5 overflow-x-auto scroll-pl-1 scroll-smooth px-1 pb-1 [-ms-overflow-style:none] [scrollbar-width:none] motion-reduce:scroll-auto [&::-webkit-scrollbar]:hidden"
>
{moments.map((m, i) => (
<li
key={m.no}
aria-label={`事例 ${i + 1} / ${total}`}
data-active={String(i === index)}
className="group relative flex w-[80%] shrink-0 snap-start flex-col @md:w-[47%] @2xl:w-[31.5%]"
>
{/* plate=画像の代わりの面。overflow-hidden が視差の層を切る */}
<div className="relative aspect-[4/5] overflow-hidden border border-gray-200 bg-gray-50 transition-colors duration-500 group-hover:border-gray-300 group-data-[active=true]:border-gray-300 motion-reduce:transition-none">
{/* 最奥: 地紋(進行方向と同じ向きにゆっくり流す) */}
<span
aria-hidden="true"
className="absolute inset-y-0 -right-10 -left-10 [transform:translate3d(calc(var(--p,0)*26px),0,0)]"
style={{ backgroundImage: grain }}
/>
{/* 中景: アンバーの図形(逆向き=視差がいちばん強い層) */}
<span
aria-hidden="true"
className={`absolute [transform:translate3d(calc(var(--p,0)*-42px),0,0)] ${shapes[m.shape]}`}
/>
{/* 中景: ゴースト番号 */}
<span
aria-hidden="true"
className="font-display absolute -bottom-6 left-3 text-[5.5rem] leading-none font-extrabold text-gray-900/[0.06] tabular-nums [transform:translate3d(calc(var(--p,0)*-18px),0,0)]"
>
{m.no}
</span>
{/* 前景: 種別ロックアップ(いちばん動かない=手前に見える) */}
<span
aria-hidden="true"
className="absolute inset-0 flex flex-col items-center justify-center [transform:translate3d(calc(var(--p,0)*8px),0,0)]"
>
<span className="font-display text-[24px] font-extrabold tracking-[0.2em] text-gray-900/85 @2xl:text-[28px]">
{m.kind}
</span>
<span className="font-display mt-1.5 text-[10px] font-semibold tracking-[0.3em] text-gray-400 tabular-nums">
{m.date.replace(".", " / ")}
</span>
</span>
{/* 前景: 通し番号チップ */}
<span className="font-display absolute top-3 left-3 rounded-full bg-white/90 px-2.5 py-0.5 text-[10px] font-bold tracking-[0.18em] text-gray-500 tabular-nums">
MOMENT {m.no}
</span>
{/* 現在位置の合図。plate 下端に引かれるアンバーの罫線 */}
<span
aria-hidden="true"
className="absolute bottom-0 left-0 h-[3px] w-full origin-left scale-x-0 bg-amber-600 transition-transform duration-500 ease-out group-data-[active=true]:scale-x-100 motion-reduce:transition-none"
/>
</div>
{/* 情報。カードで囲わず hairline と余白だけでまとめる */}
<div className="mt-4 border-t border-gray-200 pt-4">
<p className="flex items-center gap-2 text-[11px] font-semibold text-gray-500">
{m.industry}
<span aria-hidden="true" className="text-gray-300">
/
</span>
<span className="font-display text-gray-400 tabular-nums">{m.date}</span>
</p>
<h3 className="mt-2 text-[15px] leading-[1.6] font-bold text-gray-900 @2xl:text-base">
{/* セル全面をクリック領域にする stretched link(<a> はスライドに1本だけ) */}
<a
href="#"
className="after:absolute after:inset-0 after:content-[''] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
>
{m.title}
</a>
</h3>
<p className="mt-3 text-[13px] leading-[1.85] text-gray-500">{m.note}</p>
<p className="mt-4 flex gap-2 border-l-2 border-amber-500/60 pl-3 text-[12px] leading-[1.8] text-gray-600">
{m.scene}
</p>
</div>
</li>
))}
</ul>
</div>
{/* 進捗レール+締めのリンク */}
<div
style={delay(420)}
className={`mt-6 flex flex-wrap items-center justify-between gap-5 px-1 ${reveal}`}
>
<div className="flex min-w-[180px] flex-1 items-center gap-3">
<span aria-hidden="true" className="relative h-px w-full max-w-xs bg-gray-200">
{/* 進捗は state ではなく --progress(scrollLeft/最大値)で描く */}
<span className="absolute inset-0 h-px origin-left bg-amber-600 [transform:scaleX(var(--progress,0))]" />
</span>
<span className="font-display shrink-0 text-[10px] font-bold tracking-[0.22em] text-gray-400 uppercase">
Drag / Swipe
</span>
</div>
<a
href="#"
className="group relative inline-flex items-center gap-2 text-sm font-semibold text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
>
<span className="relative">
実績をすべて見る
<span
aria-hidden="true"
className="absolute -bottom-1 left-0 h-px w-full origin-left scale-x-100 bg-gray-900 transition-transform duration-300 ease-out group-hover:scale-x-0 motion-reduce:transition-none"
/>
<span
aria-hidden="true"
className="absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-amber-600 transition-transform delay-150 duration-300 ease-out group-hover:origin-left group-hover:scale-x-100 motion-reduce:transition-none"
/>
</span>
<span
aria-hidden="true"
className="transition-transform duration-300 ease-out group-hover:translate-x-1 motion-reduce:transition-none"
>
→
</span>
</a>
</div>
</section>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/moments-gallery-carousel.json