Lattice Light Trails (Section Boundary Backdrop)
A decorative band for the boundary between two sections. The light is modeled as walkers on the lattice graph rather than free-floating particles, so every stroke lands exactly on a hairline rule and matches the surrounding grid pitch. At each intersection a walker goes straight or turns, depositing heat on the edge it just crossed; that heat decays with a ~1s time constant to form the trail. On the boundary row the straight-ahead bias is raised so the section rule itself appears to glow. Trails live in per-edge Float32Arrays instead of polylines, so overlapping paths never increase draw cost: heat is quantised into four levels and stroked in one pass per level, while the in-progress edge is drawn as a gradient from tail to head. No controls, no foreground motion. Under prefers-reduced-motion a single deterministic long-exposure frame is drawn instead, and the loop also stops off-screen and on hidden tabs. Zero dependencies, zero image assets.
- Added:
- 2026-09-24
- Dependencies:
- None
- tags
- #block #background #decoration #divider #canvas #grid #trail #lattice #motion #no-image #no-dependency
Preview
区切りの罫線に、続きの合図を持たせる
節と節のあいだの1本は、読み手が息を継ぐ場所です。First CH はその線を消さず、線の上だけに動きを置きます。格子に沿って光が渡り、通った辺が一拍だけ残る——スクロールを止めずに「まだ続きがある」ことが伝わります。
次の節へ、線を伝って渡る
公開したあとの運用まで、同じ線でつなぐ
- 更新体制
- 担当と締切を決めた月次の更新。原稿の受け渡しは1つの窓口にまとめます
- 計測
- 問い合わせまでの導線を数字で確認し、落ちている節を特定します
- 改善
- 節単位で差し替えられる構成にしておき、作り直さずに直します
"use client";
import { useEffect, useRef, useState } from "react";
/** 格子を走る光・節の境界バックドロップ(BLK-76) */
const RULE = "#e5e7eb"; // gray-200(静的な格子)
const EDGE = "#d1d5db"; // gray-300(節の境界罫線)
const GLOW = "217, 119, 6"; // amber-600
const HEAD = "#f59e0b"; // amber-500(先端)
const PITCH = 56; // 格子ピッチの目安(px)。幅から列数を丸めて逆算する
const PITCH_NARROW = 40; // 狭幅のピッチ。同じ56pxだと格子が3〜4マスしかできず、走る絵にならない
const SPEED = 2.6; // 1秒あたり何セル進むか
const TAU = 900; // 残光の時定数(ms)
const TAU_STILL = 2800; // 静止画(長時間露光)用の時定数
const STRAIGHT = 0.62; // 交差点で直進する確率
const STRAIGHT_ON_RULE = 0.88; // 境界罫線に乗っているときの直進確率
const LEVELS = 4; // 熱を丸める段数
type Runner = {
c: number;
r: number;
dir: number; // 0:右 1:下 2:左 3:上
t: number; // 現在の辺の進捗 0..1
life: number; // 残り本数(0 で再投入)
age: number; // 投入からの本数(フェードイン用)
};
const DX = [1, 0, -1, 0];
const DY = [0, 1, 0, -1];
/** 固定シードの乱数。静止画を毎回同じ絵にするために使う */
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let x = Math.imul(a ^ (a >>> 15), 1 | a);
x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
};
}
export default function GridTraceBackdrop() {
const [still, setStill] = useState(false);
const stageRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const stage = stageRef.current;
const canvas = canvasRef.current;
if (!stage || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let w = 1;
let h = 1;
let cols = 1;
let rows = 1;
let stepX = PITCH;
let stepY = PITCH;
let ruleRow = 1; // 節の境界にあたる行線
let heatH = new Float32Array(1); // 横辺の熱: r*(cols) + c
let heatV = new Float32Array(1); // 縦辺の熱: r*(cols+1) + c
let nodes = new Float32Array(1); // ノードの点灯: r*(cols+1) + c
let runners: Runner[] = [];
let ready = false;
let raf = 0;
let last = 0;
let rand = Math.random;
let tauLive = TAU;
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
let onScreen = true;
let visible = document.visibilityState !== "hidden";
const px = (c: number) => Math.round(c * stepX) + 0.5;
const py = (r: number) => Math.round(r * stepY) + 0.5;
/** 端のノードから内側へ向かって投入する(画面の外から入ってきたように見せる) */
const spawn = (runner: Runner) => {
const side = Math.floor(rand() * 4);
if (side === 0) {
runner.c = 0;
runner.r = 1 + Math.floor(rand() * Math.max(1, rows - 1));
runner.dir = 0;
} else if (side === 2) {
runner.c = cols;
runner.r = 1 + Math.floor(rand() * Math.max(1, rows - 1));
runner.dir = 2;
} else if (side === 1) {
runner.c = Math.floor(rand() * (cols + 1));
runner.r = 0;
runner.dir = 1;
} else {
runner.c = Math.floor(rand() * (cols + 1));
runner.r = rows;
runner.dir = 3;
}
runner.t = 0;
runner.life = 14 + Math.floor(rand() * 20);
runner.age = 0;
};
/** 交差点での行き先。来た道へは戻らない=軌跡が同じ辺を往復して滞留しない */
const turn = (runner: Runner) => {
const straight = runner.r === ruleRow ? STRAIGHT_ON_RULE : STRAIGHT;
let dir = runner.dir;
if (rand() > straight) dir = (runner.dir + (rand() < 0.5 ? 1 : 3)) % 4;
// 盤の外へ出る向きは1度だけ選び直す(外れたままなら寿命を終えて再投入される)
const nc = runner.c + DX[dir];
const nr = runner.r + DY[dir];
if (nc < 0 || nc > cols || nr < 0 || nr > rows) dir = (dir + 2) % 4;
runner.dir = dir;
};
const alloc = () => {
const narrow = w < 480;
const pitch = narrow ? PITCH_NARROW : PITCH;
cols = Math.max(4, Math.round(w / pitch));
stepX = w / cols;
rows = Math.max(2, Math.round(h / stepX));
stepY = h / rows;
ruleRow = Math.max(1, Math.round(rows / 2));
heatH = new Float32Array((rows + 1) * cols);
heatV = new Float32Array(rows * (cols + 1));
nodes = new Float32Array((rows + 1) * (cols + 1));
// 狭幅は1体が帯を横断するまでが短く、残光が育つ前に端へ抜ける。時定数を伸ばして補う
tauLive = narrow ? TAU * 1.5 : TAU;
// 体数は列数ではなくセル数で決める。狭幅は行数も減るので、列数基準だと帯が空いて見える
const count = Math.min(7, Math.max(5, Math.round((cols * rows) / 6)));
runners = [];
for (let i = 0; i < count; i++) {
const runner: Runner = { c: 0, r: 0, dir: 0, t: 0, life: 0, age: 0 };
spawn(runner);
// 投入直後に全員が同じ位置から出ないよう、進捗と寿命をばらす
runner.t = rand();
runner.life = 6 + Math.floor(rand() * 26);
runners.push(runner);
}
ready = true;
};
/** 1ステップ進める。辺をまたぐたびに熱とノードを灯す */
const advance = (dt: number, tau: number) => {
const decay = Math.exp(-dt / tau);
for (let i = 0; i < heatH.length; i++) heatH[i] *= decay;
for (let i = 0; i < heatV.length; i++) heatV[i] *= decay;
for (let i = 0; i < nodes.length; i++) nodes[i] *= decay * decay;
const move = (SPEED * dt) / 1000;
for (const runner of runners) {
runner.t += move;
while (runner.t >= 1) {
runner.t -= 1;
// 渡り終えた辺に熱を置く(進行中の辺は書かない=光が先へ飛ばない)
const dir = runner.dir;
if (dir === 0) heatH[runner.r * cols + runner.c] = 1;
else if (dir === 2) heatH[runner.r * cols + runner.c - 1] = 1;
else if (dir === 1) heatV[runner.r * (cols + 1) + runner.c] = 1;
else heatV[(runner.r - 1) * (cols + 1) + runner.c] = 1;
runner.c += DX[dir];
runner.r += DY[dir];
runner.age++;
runner.life--;
nodes[runner.r * (cols + 1) + runner.c] = 1;
if (runner.life <= 0 || runner.c < 0 || runner.c > cols || runner.r < 0 || runner.r > rows) {
spawn(runner);
break;
}
turn(runner);
}
}
};
const draw = () => {
ctx.clearRect(0, 0, w, h);
ctx.lineCap = "butt";
// 1) 静的な格子。節の境界にあたる行だけ一段濃くして「区切り罫線」に見せる
ctx.lineWidth = 1;
ctx.strokeStyle = RULE;
ctx.beginPath();
for (let c = 0; c <= cols; c++) {
ctx.moveTo(px(c), 0);
ctx.lineTo(px(c), h);
}
for (let r = 0; r <= rows; r++) {
if (r === ruleRow) continue;
ctx.moveTo(0, py(r));
ctx.lineTo(w, py(r));
}
ctx.stroke();
ctx.strokeStyle = EDGE;
ctx.beginPath();
ctx.moveTo(0, py(ruleRow));
ctx.lineTo(w, py(ruleRow));
ctx.stroke();
// 2) 残光。濃度を LEVELS 段に丸め、段ごとに1パスで引く
for (let level = 1; level <= LEVELS; level++) {
const lo = (level - 1) / LEVELS;
const hi = level / LEVELS;
ctx.beginPath();
let any = false;
for (let r = 0; r <= rows; r++) {
for (let c = 0; c < cols; c++) {
const v = heatH[r * cols + c];
if (v <= lo || v > hi) continue;
ctx.moveTo(px(c), py(r));
ctx.lineTo(px(c + 1), py(r));
any = true;
}
}
for (let r = 0; r < rows; r++) {
for (let c = 0; c <= cols; c++) {
const v = heatV[r * (cols + 1) + c];
if (v <= lo || v > hi) continue;
ctx.moveTo(px(c), py(r));
ctx.lineTo(px(c), py(r + 1));
any = true;
}
}
if (!any) continue;
// 濃度をそのまま α にすると薄い残光まで濃く出て回路図に見える。指数で寝かせる
ctx.strokeStyle = `rgba(${GLOW}, ${(Math.pow(hi, 1.35) * 0.8).toFixed(3)})`;
ctx.lineWidth = level >= LEVELS ? 1.6 : 1;
ctx.stroke();
}
// 3) 通過したノード。交差点が一拍遅れて消えると「通った跡」が読める
ctx.lineWidth = 1;
for (let r = 0; r <= rows; r++) {
for (let c = 0; c <= cols; c++) {
const v = nodes[r * (cols + 1) + c];
if (v < 0.06) continue;
ctx.fillStyle = `rgba(${GLOW}, ${(v * 0.75).toFixed(3)})`;
const size = 2 + v * 2.4;
ctx.fillRect(px(c) - size / 2, py(r) - size / 2, size, size);
}
}
// 4) 先端。進行中の辺だけ、根元→頭のグラデーションで描く
for (const runner of runners) {
const x0 = px(runner.c);
const y0 = py(runner.r);
const x1 = px(runner.c + DX[runner.dir]);
const y1 = py(runner.r + DY[runner.dir]);
// 進捗0の位置に頭を置くとグラデーションの始点と終点が重なり、何も描かれない
const t = Math.max(0.04, runner.t);
const hx = x0 + (x1 - x0) * t;
const hy = y0 + (y1 - y0) * t;
const fade = Math.min(1, runner.age / 2 + 0.25) * Math.min(1, runner.life / 3);
if (fade <= 0.02) continue;
const grad = ctx.createLinearGradient(x0, y0, hx, hy);
grad.addColorStop(0, `rgba(${GLOW}, ${(0.12 * fade).toFixed(3)})`);
grad.addColorStop(1, `rgba(${GLOW}, ${(0.95 * fade).toFixed(3)})`);
ctx.strokeStyle = grad;
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(hx, hy);
ctx.stroke();
// 頭は白い芯+アンバーの傘。shadowBlur は重いので2枚重ねで代用する
ctx.fillStyle = `rgba(${GLOW}, ${(0.28 * fade).toFixed(3)})`;
ctx.fillRect(hx - 3.5, hy - 3.5, 7, 7);
ctx.fillStyle = HEAD;
ctx.globalAlpha = fade;
ctx.fillRect(hx - 1.5, hy - 1.5, 3, 3);
ctx.globalAlpha = 1;
}
};
/** 長時間露光の1枚。固定シードで同じ歩行を回すのでリサイズしても同じ絵になる */
const drawStill = () => {
if (!ready) return;
heatH.fill(0);
heatV.fill(0);
nodes.fill(0);
rand = mulberry32(0x5eed01 + cols * 131 + rows);
for (const runner of runners) spawn(runner);
for (let i = 0; i < 420; i++) advance(16, TAU_STILL);
rand = Math.random;
draw();
};
const frame = (time: number) => {
if (!last) last = time;
const dt = Math.min(48, time - last);
last = time;
advance(dt, tauLive);
draw();
raf = requestAnimationFrame(frame);
};
const stop = () => {
if (raf) cancelAnimationFrame(raf);
raf = 0;
};
const start = () => {
if (raf || mql.matches || !onScreen || !visible || !ready) return;
last = 0;
raf = requestAnimationFrame(frame);
};
const ro = new ResizeObserver((entries) => {
// contentRect はレイアウト寸法(一覧カードの scale の影響を受けない)
const box = entries[0].contentRect;
const nw = Math.max(1, Math.round(box.width));
const nh = Math.max(1, Math.round(box.height));
if (nw === w && nh === h) return;
w = nw;
h = nh;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
alloc();
if (mql.matches) drawStill();
else {
draw();
start();
}
});
ro.observe(stage);
const io = new IntersectionObserver(
(entries) => {
onScreen = entries[0].isIntersecting;
if (onScreen) start();
else stop();
},
{ rootMargin: "120px" },
);
io.observe(stage);
const onVisibility = () => {
visible = document.visibilityState !== "hidden";
if (visible) start();
else stop();
};
const onMotionChange = () => {
setStill(mql.matches);
if (mql.matches) {
stop();
drawStill();
} else start();
};
document.addEventListener("visibilitychange", onVisibility);
mql.addEventListener("change", onMotionChange);
setStill(mql.matches);
return () => {
stop();
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
mql.removeEventListener("change", onMotionChange);
};
}, []);
return (
<div className="w-full max-w-4xl overflow-hidden rounded-2xl border border-gray-200 bg-white">
{/* 上の節(締め)。前景は動かさない=背面の格子だけが動く装飾として使う */}
<div className="px-5 pt-9 pb-8 sm:px-10 sm:pt-12 sm:pb-10">
<div className="flex items-center gap-3">
<span aria-hidden="true" className="h-px w-8 bg-amber-600" />
<span className="text-[11px] font-bold tracking-[0.1em] text-amber-600">設計の話</span>
</div>
<h3 className="mt-4 max-w-xl text-[clamp(1.3rem,4.4vw,1.95rem)] leading-[1.5] font-bold tracking-[0.01em] text-gray-900">
区切りの罫線に、続きの合図を持たせる
</h3>
<p className="mt-4 max-w-xl text-[12.5px] leading-[1.95] text-gray-600 sm:text-[13.5px]">
節と節のあいだの1本は、読み手が息を継ぐ場所です。First CH はその線を消さず、線の上だけに動きを置きます。格子に沿って光が渡り、通った辺が一拍だけ残る——スクロールを止めずに「まだ続きがある」ことが伝わります。
</p>
</div>
{/* 境界の帯。ここだけが動く。上下の版面の罫線とピッチを合わせてある */}
<div
ref={stageRef}
className="relative h-[184px] border-y border-gray-200 bg-white sm:h-[232px]"
>
<canvas ref={canvasRef} aria-hidden="true" className="absolute inset-0 block h-full w-full" />
<div className="pointer-events-none absolute inset-0 flex items-center justify-center px-5">
<p className="bg-white/90 px-4 py-2 text-[11px] leading-[1.9] tracking-[0.08em] text-gray-500 sm:text-[12px]">
{still ? "モーション低減:残光を1枚だけ描画" : "次の節へ、線を伝って渡る"}
</p>
</div>
</div>
{/* 下の節(入り)。カード羅列を避け、hairline罫線の台帳で情報密度を作る */}
<div className="px-5 pt-8 pb-10 sm:px-10 sm:pt-10 sm:pb-12">
<h3 className="text-[clamp(1.15rem,3.8vw,1.55rem)] leading-[1.5] font-bold text-gray-900">
公開したあとの運用まで、同じ線でつなぐ
</h3>
<dl className="mt-6 border-t border-gray-200">
{[
{ term: "更新体制", desc: "担当と締切を決めた月次の更新。原稿の受け渡しは1つの窓口にまとめます" },
{ term: "計測", desc: "問い合わせまでの導線を数字で確認し、落ちている節を特定します" },
{ term: "改善", desc: "節単位で差し替えられる構成にしておき、作り直さずに直します" },
].map((row) => (
<div
key={row.term}
className="grid gap-1 border-b border-gray-200 py-4 sm:grid-cols-[132px_1fr] sm:gap-6"
>
<dt className="text-[12px] font-bold tracking-[0.08em] text-gray-900">{row.term}</dt>
<dd className="text-[12.5px] leading-[1.9] text-gray-600 sm:text-[13px]">{row.desc}</dd>
</div>
))}
</dl>
<a
href="#"
className="mt-6 inline-block border-b border-amber-600 pb-1 text-[12.5px] font-bold text-gray-900 transition-colors hover:text-amber-700 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 motion-reduce:transition-none sm:text-[13px]"
>
運用の進め方を見る
</a>
</div>
</div>
);
}
Add to your project via shadcn CLI
npx shadcn@latest add https://designs.first-ch.com/r/grid-trace-backdrop.json