BLK-42Blocks
深度視差ワードウォール(5層・掴んで送れる無限流し)
取り組み領域のキーワードを、速度の違う5層に分けて無限に流す帯。canvasもWebGLも使わず素のDOM要素を translate3d で動かすので、流れている語はテキストのまま選択・フォーカスでき、手前2層はボタンとしてキーボードで辿れる。無限ループは要素の生成破棄ではなく「レーン長の剰余」で作り、余った隙間は要素数で割って自動的に均す。ドラッグと横ホイールで送り量と進行方向を掴み替えられ(縦ホイールはページへ通す)、語を選ぶと流れが止まって説明が出る。prefers-reduced-motion では流さず、同じ語をhairlineの一覧として静止表示する。依存ゼロ。
- 追加日:
- 2026-08-26
- 依存:
- なし
- tags
- #block #parallax #depth #infinite-loop #marquee #drag #pointer #keyboard #motion #hairline #no-image #no-dependency
プレビュー
手を動かしている範囲
ひとつの案件で
触っているものは、これだけあります
奥へ行くほどゆっくり流れます。横に払うと送りの向きが変わり、語を選ぶとその場で止まります。
設計Structure
誰に何をどの順で伝えるかを決めてから、画面を描きはじめます。
BLK-42 / Scope
※ 掲載の語と説明はサンプルです。語を押すと選択したまま止められます。
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Montserrat } from "next/font/google";
const montserrat = Montserrat({ weight: ["600", "700"], subsets: ["latin"], display: "swap" });
/**
* 深度視差ワードウォール(BLK-42)
*
* 差別化: ギャラリー既存の「奥行きのある巡回ギャラリー」はいずれも canvas/WebGL の描画物で、
* 文字は画素になり本文は下のDOMリストに二重持ちしていた。本ブロックは同じ多層視差を
* **素のDOM要素の translate3d だけ**で組むので、流れている語そのものが選択・フォーカス・
* コピーできるテキストのまま残る(手前2層はbutton、奥3層は装飾)。既存の無限マーキーが
* 単速・CSSアニメ・hover停止のみなのに対し、こちらは5層それぞれ速度が違い、ドラッグと
* 横ホイールで送り量と進行方向そのものを掴み替えられる。飽和パターンの角丸・英字キッカー・
* 末尾の「→」リンクは意図的に外している。
*
* 汎用技法メモ(web-design-playbook 還流用):
* 1. **無限ループはスプライトの生成破棄ではなく「レーン長の剰余」で作る**。要素数は固定のまま、
* レーン長 L を「全要素幅の合計」と「表示幅+最大要素幅」の大きい方に取り、
* x = ((base - offset) % L + L) % L とするだけで両方向に途切れず回る。DOMが増減しないので
* フォーカス中の要素が消えず、キーボード操作と共存できる(canvasの流儀をDOMへ持ち込む要点)。
* 2. **隙間は「余りをnで割る」で自動的に均す**。gap = (L - Σ幅) / n にすると、要素が少ない層でも
* レーンが均等に埋まり、幅の実測値が変わっても手で調整する定数が要らない。
* 3. 層ごとの速度比 = 視差。奥ほど遅くするだけで遠近が出るので、scale や blur は要らない。
* ドラッグ量も同じ比率で各層に配ると、掴んで動かしている間も遠近が保たれる。
* 4. **速度は state ではなく ref に置き、DOM へは transform で書き込む**。hover/focus で
* 再レンダリングが走っても流れが飛ばない。倍率は target へ指数補間(cur += (t-cur)*k*dt)で
* 寄せると、停止・再開が段差なくつながる。
* 5. 慣性の向きは「速度の符号」で決める。ドラッグ速度を毎フレーム減衰させ、閾値を超えた向きを
* 進行方向として保持すると、投げた方向へ流れが入れ替わる操作になる。
* 6. **ホイールは縦を奪わない**。|deltaX| > |deltaY| のときだけ preventDefault して横送りに使う。
* 縦ホイールはページに素通りさせるので、ページ内に置いても読み進みを妨げない
* (touch-action: pan-y も同じ役割をタッチで果たす)。
* 7. フォーカスした語が画面外にいるときは、その層の offset を差分だけずらして引き寄せる。
* 要素を動かすのではなくレーンをずらすので、他の語との間隔が崩れない。
* 8. 停止条件は3つ(prefers-reduced-motion / 画面外 / タブ非表示)。モーション低減時は
* rAF を回さず、同じ語を hairline の一覧として静止レイアウトで出す。
*/
type Word = { ja: string; en: string; note: string };
const WORDS: Word[] = [
{ ja: "設計", en: "Structure", note: "誰に何をどの順で伝えるかを決めてから、画面を描きはじめます。" },
{ ja: "導線", en: "Flow", note: "問い合わせまでの道を一本にして、迷う場所を残しません。" },
{ ja: "速度", en: "Speed", note: "画像と依存を減らし、回線の細い場所でも待たせない重さに収めます。" },
{ ja: "検索", en: "Search", note: "見出しと構造化データを整え、探している人の側から見つかる形にします。" },
{ ja: "文章", en: "Writing", note: "事業の言葉で書きます。どこにでもある美文は置きません。" },
{ ja: "実装", en: "Build", note: "手で直せるコードを納めます。開けられない箱は残しません。" },
{ ja: "更新", en: "Update", note: "公開後に自社で直せるところまでを、納品物の範囲に含めます。" },
{ ja: "保守", en: "Care", note: "障害と改修の窓口を一つにして、記録ごと引き継げるようにします。" },
{ ja: "解析", en: "Data", note: "見る数字は三つに絞り、次に直す一箇所だけを毎月決めます。" },
{ ja: "自動化", en: "Automation", note: "毎回手で繰り返している手順から順に、記録を取って手放します。" },
{ ja: "撮影", en: "Photo", note: "現場で撮ります。用意された写真で埋めた「らしさ」は作りません。" },
{ ja: "相談", en: "Talk", note: "作らない方がよいと判断したときは、そのまま率直にお伝えします。" },
];
/** 手前(0)→奥(4)。speed=px/秒、interactive な層だけが tab 対象 */
const LAYERS = [
{
count: 4,
speed: 46,
interactive: true,
gap: 96,
band: [0.02, 0.34] as const, // 縦の担当帯(手前2層は重ならせない)
className:
"text-[26px] font-bold tracking-[0.06em] text-gray-900 @2xl:text-[40px]",
},
{
count: 5,
speed: 34,
interactive: true,
gap: 80,
band: [0.56, 0.96] as const, // 縦の担当帯(手前2層は重ならせない)
className:
"text-[19px] font-semibold tracking-[0.08em] text-gray-700 @2xl:text-[28px]",
},
{
count: 6,
speed: 25,
interactive: false,
gap: 64,
band: [0.38, 0.5] as const, // 縦の担当帯(手前2層は重ならせない)
className: "text-[15px] font-medium tracking-[0.1em] text-gray-400 @2xl:text-[21px]",
},
{
count: 7,
speed: 18,
interactive: false,
gap: 52,
band: [0, 1] as const, // 縦の担当帯(手前2層は重ならせない)
className: "text-[12px] tracking-[0.12em] text-gray-300 @2xl:text-[16px]",
},
{
count: 8,
speed: 12,
interactive: false,
gap: 44,
band: [0, 1] as const, // 縦の担当帯(手前2層は重ならせない)
className: "text-[10px] tracking-[0.14em] text-gray-200 @2xl:text-[13px]",
},
] as const;
const PAD = 10;
/** 決定論的な擬似乱数(SSRとクライアントで同じ配置になるよう固定シード) */
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
type Item = { layer: number; index: number; word: number; yFrac: number; seed: number };
export default function DepthParallaxKeywordWall() {
const wallRef = useRef<HTMLDivElement | null>(null);
const elsRef = useRef<(HTMLElement | null)[][]>(LAYERS.map(() => []));
const laneRef = useRef<{ lane: number; base: number[]; w: number[]; h: number[] }[]>(
LAYERS.map(() => ({ lane: 1, base: [], w: [], h: [] })),
);
const offsetRef = useRef<number[]>(LAYERS.map(() => 0));
const sizeRef = useRef({ w: 0, h: 0 });
const dirRef = useRef(1); // 1 = 左へ流れる
const velRef = useRef(0); // ドラッグ由来の速度(px/秒)
const factorRef = useRef(1); // 現在の倍率(0=停止)
const hoverRef = useRef(false);
const holdRef = useRef(false); // フォーカス/ピン留めで停止中
const dragRef = useRef<{ id: number; x: number; t: number } | null>(null);
const movedRef = useRef(0); // ドラッグ移動量(押し込み後のクリック誤爆を防ぐ)
const rafRef = useRef<number | null>(null);
const [active, setActive] = useState(0);
const [pinned, setPinned] = useState<number | null>(null);
const [reduced, setReduced] = useState(false);
const items = useMemo<Item[]>(() => {
const rand = mulberry32(20260826);
const out: Item[] = [];
LAYERS.forEach((layer, l) => {
for (let i = 0; i < layer.count; i++) {
out.push({
layer: l,
index: i,
word: (i * 5 + l * 3) % WORDS.length,
yFrac: rand(),
seed: rand() * 100,
});
}
});
return out;
}, []);
const byLayer = useMemo(
() => LAYERS.map((_, l) => items.filter((it) => it.layer === l)),
[items],
);
/** 幅を実測してレーン長・基準位置・縦位置を組み直す */
const layout = useCallback(() => {
const wall = wallRef.current;
if (!wall) return;
const W = wall.clientWidth;
const H = wall.clientHeight;
sizeRef.current = { w: W, h: H };
LAYERS.forEach((layer, l) => {
const els = elsRef.current[l];
const w = els.map((el) => el?.offsetWidth ?? 0);
const h = els.map((el) => el?.offsetHeight ?? 0);
const sum = w.reduce((a, b) => a + b, 0);
const widest = Math.max(1, ...w);
// レーンは「全要素+最小間隔」と「表示幅+最大要素幅」の大きい方。余りは均等に配る
const lane = Math.max(sum + layer.count * layer.gap, W + widest + layer.gap);
const gap = (lane - sum) / layer.count;
const base: number[] = [];
let x = 0;
for (let i = 0; i < layer.count; i++) {
base.push(x);
x += w[i] + gap;
}
laneRef.current[l] = { lane, base, w, h };
// 縦位置は要素高さを引いた範囲へ決定論的に散らす
els.forEach((el, i) => {
if (!el) return;
const span = Math.max(0, H - h[i] - PAD * 2);
const [b0, b1] = layer.band;
el.dataset.y = String(PAD + (b0 + byLayer[l][i].yFrac * (b1 - b0)) * span);
});
});
}, [byLayer]);
/** 現在の offset に従って全要素へ transform を書く */
const paint = useCallback((time: number, moving: boolean) => {
LAYERS.forEach((_, l) => {
const lane = laneRef.current[l];
const els = elsRef.current[l];
els.forEach((el, i) => {
if (!el) return;
const raw = lane.base[i] - offsetRef.current[l];
const x = ((raw % lane.lane) + lane.lane) % lane.lane;
const y = Number(el.dataset.y ?? 0);
const bob = moving ? Math.sin(time * 0.0009 + byLayer[l][i].seed) * 4 : 0;
el.style.transform = `translate3d(${x.toFixed(2)}px, ${(y + bob).toFixed(2)}px, 0)`;
});
});
}, [byLayer]);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const apply = () => setReduced(mq.matches);
apply();
mq.addEventListener("change", apply);
return () => mq.removeEventListener("change", apply);
}, []);
// レイアウト(マウント・リサイズ・フォント確定時)
useEffect(() => {
if (reduced) return;
layout();
paint(0, false);
const wall = wallRef.current;
const ro = new ResizeObserver(() => {
layout();
paint(performance.now(), false);
});
if (wall) ro.observe(wall);
document.fonts?.ready.then(() => {
layout();
paint(performance.now(), false);
});
return () => ro.disconnect();
}, [layout, paint, reduced]);
// 走行ループ(画面外・非表示・モーション低減では回さない)
useEffect(() => {
if (reduced) return;
const wall = wallRef.current;
if (!wall) return;
let last = performance.now();
let visible = true;
const frame = (now: number) => {
rafRef.current = requestAnimationFrame(frame);
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
const target = holdRef.current ? 0 : hoverRef.current ? 0.18 : 1;
factorRef.current += (target - factorRef.current) * Math.min(1, dt * 6);
// ドラッグ由来の速度は毎フレーム減衰し、閾値を超えた向きが進行方向になる
velRef.current *= Math.pow(0.06, dt);
if (Math.abs(velRef.current) > 60) dirRef.current = Math.sign(velRef.current);
const ratioBase = LAYERS[0].speed;
LAYERS.forEach((layer, l) => {
const ratio = layer.speed / ratioBase;
const drift = layer.speed * dirRef.current * factorRef.current;
offsetRef.current[l] += (drift + velRef.current * ratio) * dt;
});
paint(now, factorRef.current > 0.01);
};
const start = () => {
if (rafRef.current == null) {
last = performance.now();
rafRef.current = requestAnimationFrame(frame);
}
};
const stop = () => {
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
rafRef.current = null;
};
const sync = () => (visible && !document.hidden ? start() : stop());
const io = new IntersectionObserver(
([entry]) => {
visible = entry.isIntersecting;
sync();
},
{ threshold: 0.01 },
);
io.observe(wall);
document.addEventListener("visibilitychange", sync);
return () => {
io.disconnect();
document.removeEventListener("visibilitychange", sync);
stop();
};
}, [paint, reduced]);
// 横ホイールのときだけ横送りに使う(縦はページへ通す=passive:false が必要)
useEffect(() => {
const wall = wallRef.current;
if (!wall || reduced) return;
const onWheel = (e: WheelEvent) => {
if (Math.abs(e.deltaX) <= Math.abs(e.deltaY)) return;
e.preventDefault();
const ratioBase = LAYERS[0].speed;
LAYERS.forEach((layer, l) => {
offsetRef.current[l] += e.deltaX * (layer.speed / ratioBase);
});
velRef.current = e.deltaX * 6;
};
wall.addEventListener("wheel", onWheel, { passive: false });
return () => wall.removeEventListener("wheel", onWheel);
}, [reduced]);
const nudge = (l: number, i: number) => {
const lane = laneRef.current[l];
const W = sizeRef.current.w;
if (!lane.base.length || !W) return;
const raw = lane.base[i] - offsetRef.current[l];
const x = ((raw % lane.lane) + lane.lane) % lane.lane;
const width = lane.w[i];
if (x >= 8 && x + width <= W - 8) return;
const goal = Math.max(16, W * 0.5 - width / 2);
offsetRef.current[l] += x - goal;
paint(0, false); // 停止状態で描き直すので時刻(ゆらぎ)は使わない
};
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (reduced || e.button !== 0) return;
dragRef.current = { id: e.pointerId, x: e.clientX, t: performance.now() };
movedRef.current = 0;
e.currentTarget.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
const drag = dragRef.current;
if (!drag || drag.id !== e.pointerId) return;
const now = performance.now();
const dx = e.clientX - drag.x;
const dt = Math.max(8, now - drag.t) / 1000;
movedRef.current += Math.abs(dx);
const ratioBase = LAYERS[0].speed;
LAYERS.forEach((layer, l) => {
offsetRef.current[l] -= dx * (layer.speed / ratioBase);
});
velRef.current = -dx / dt;
dragRef.current = { id: drag.id, x: e.clientX, t: now };
};
const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
if (dragRef.current?.id !== e.pointerId) return;
dragRef.current = null;
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
};
const word = WORDS[active];
const wallHeight = "h-[300px] @2xl:h-[400px]";
return (
<section className="@container w-full bg-white text-gray-900">
<div className="mx-auto max-w-5xl px-5 py-14 @2xl:px-10 @2xl:py-20">
<div className="flex flex-col gap-4 border-b border-gray-200 pb-8 @2xl:flex-row @2xl:items-end @2xl:justify-between">
<div>
<p className="flex items-center gap-2.5 text-[12px] leading-none font-bold text-amber-600">
<span aria-hidden="true" className="h-2.5 w-2.5 bg-amber-600" />
手を動かしている範囲
</p>
<h2 className="mt-5 text-[26px] leading-[1.35] font-bold tracking-[0.02em] text-gray-900 @2xl:text-[34px]">
ひとつの案件で
<br className="@2xl:hidden" />
触っているものは、これだけあります
</h2>
</div>
<p className="max-w-sm text-[13px] leading-[1.9] text-gray-500">
{reduced
? "12の語を並べています。選ぶと、その語について書いた1文が下に出ます。"
: "奥へ行くほどゆっくり流れます。横に払うと送りの向きが変わり、語を選ぶとその場で止まります。"}
</p>
</div>
{reduced ? (
<ul className="mt-10 grid grid-cols-2 gap-px border border-gray-200 bg-gray-200 @2xl:grid-cols-4">
{WORDS.map((w, i) => (
<li key={w.ja} className="bg-white">
<button
type="button"
onFocus={() => setActive(i)}
onMouseEnter={() => setActive(i)}
onClick={() => setActive(i)}
aria-pressed={active === i}
className={`flex w-full flex-col items-start gap-1.5 px-4 py-5 text-left transition-colors duration-200 hover:bg-gray-50 focus-visible:bg-gray-50 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-amber-600 ${
active === i ? "text-amber-700" : "text-gray-900"
}`}
>
<span className="text-[19px] leading-none font-bold tracking-[0.06em]">
{w.ja}
</span>
<span
className={`${montserrat.className} text-[10px] leading-none font-semibold tracking-[0.16em] text-gray-400`}
>
{w.en}
</span>
</button>
</li>
))}
</ul>
) : (
<div
ref={wallRef}
role="group"
aria-label="取り組み領域のキーワード"
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onMouseEnter={() => {
hoverRef.current = true;
}}
onMouseLeave={() => {
hoverRef.current = false;
}}
className={`relative mt-10 w-full ${wallHeight} touch-pan-y cursor-grab overflow-hidden border-y border-gray-200 select-none active:cursor-grabbing`}
>
{LAYERS.map((layer, l) => (
<div key={l} aria-hidden={layer.interactive ? undefined : true}>
{byLayer[l].map((item, i) => {
const w = WORDS[item.word];
const common = `absolute top-0 left-0 whitespace-nowrap will-change-transform ${layer.className}`;
if (!layer.interactive) {
return (
<span
key={`${l}-${i}`}
ref={(el) => {
elsRef.current[l][i] = el;
}}
className={common}
>
{w.ja}
</span>
);
}
const on = pinned === item.word || active === item.word;
return (
<button
key={`${l}-${i}`}
type="button"
ref={(el) => {
elsRef.current[l][i] = el;
}}
onFocus={() => {
holdRef.current = true;
setActive(item.word);
nudge(l, i);
}}
onBlur={() => {
holdRef.current = pinned !== null;
}}
onMouseEnter={() => setActive(item.word)}
onClick={() => {
if (movedRef.current > 6) return; // 送った直後のクリックは無効
const next = pinned === item.word ? null : item.word;
setPinned(next);
setActive(item.word);
holdRef.current = next !== null;
}}
aria-pressed={pinned === item.word}
className={`${common} border-b-2 pb-1 transition-colors duration-200 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 ${
on
? "border-amber-600 text-amber-700"
: "border-transparent hover:border-gray-900"
}`}
>
{w.ja}
<span
className={`${montserrat.className} ml-2 align-middle text-[9px] font-semibold tracking-[0.16em] text-gray-400 @2xl:text-[10px]`}
>
{w.en}
</span>
</button>
);
})}
</div>
))}
</div>
)}
<div
aria-live="polite"
className="grid grid-cols-1 gap-6 border-b border-gray-200 py-7 @2xl:grid-cols-[168px_1fr] @2xl:gap-10"
>
<p className="flex items-baseline gap-3">
<span className="text-[21px] leading-none font-bold tracking-[0.08em] text-gray-900">
{word.ja}
</span>
<span
className={`${montserrat.className} text-[10px] leading-none font-semibold tracking-[0.18em] text-amber-600`}
>
{word.en}
</span>
</p>
<p className="text-[14px] leading-[1.95] text-gray-700 @2xl:text-[15px]">{word.note}</p>
</div>
<div className="mt-6 flex flex-col gap-2 @xl:flex-row @xl:items-center @xl:justify-between @xl:gap-6">
<span
className={`${montserrat.className} text-[10px] leading-none font-semibold tracking-[0.18em] whitespace-nowrap text-gray-300`}
>
BLK-42 / Scope
</span>
<p className="text-[12px] leading-[1.8] text-gray-400">
※ 掲載の語と説明はサンプルです。
{reduced
? ""
: pinned !== null
? "選択中の語をもう一度押すと流れが戻ります。"
: "語を押すと選択したまま止められます。"}
</p>
</div>
</div>
</section>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/depth-parallax-keyword-wall.json