Depth-Parallax Keyword Wall
A band of keywords drifting endlessly across five layers, each at a different speed. No canvas or WebGL: plain DOM nodes moved with translate3d, so the words stay selectable text and the two front layers are keyboard-reachable buttons. The infinite loop comes from a modulo over lane length rather than spawning and disposing sprites, and leftover space is divided evenly across the items. Drag or horizontal wheel to change the throw and the direction (vertical wheel passes through to the page); selecting a word halts the drift and reveals its note. Under prefers-reduced-motion the same words render as a static hairline grid. Zero dependencies.
- Added:
- 2026-08-26
- Dependencies:
- None
- tags
- #block #parallax #depth #infinite-loop #marquee #drag #pointer #keyboard #motion #hairline #no-image #no-dependency
Preview
手を動かしている範囲
ひとつの案件で
触っているものは、これだけあります
奥へ行くほどゆっくり流れます。横に払うと送りの向きが変わり、語を選ぶとその場で止まります。
設計Structure
誰に何をどの順で伝えるかを決めてから、画面を描きはじめます。
※ 掲載の語と説明はサンプルです。語を押すと選択したまま止められます。
"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>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/depth-parallax-keyword-wall.json