First CH Designs
BLK-76Blocks

格子を走る光(節の境界バックドロップ)

節と節のあいだに挟む境界帯の背景装飾。光は自由座標の粒ではなく格子のグラフを歩く歩行者として持つので、動きは必ず罫線の上に乗り、既存のhairline罫線とピッチを合わせたまま差し込める。交差点ごとに直進・左折・右折を選び、渡り終えた辺に熱を置いて残光にする(時定数約1秒で消える)。区切りにあたる行線の上では直進バイアスを上げるので、節の区切り罫線そのものが光って見える。軌跡はポリラインではなく辺ごとの熱を持つFloat32Arrayなので、何本重なっても描画量は一定。残光は濃度を4段に丸めて段ごとに1パスで引き、進行中の辺だけは熱に書かず根元から先端へのグラデーションで描く(熱に書くと光が1セル先へ飛んで見える)。操作は一切持たず、前景の版面も動かさない。prefers-reduced-motionでは固定シードの歩行を420ステップ回した長時間露光を1枚だけ描き、画面外・タブ非表示でもrAFを止める。依存ライブラリゼロ・画像素材ゼロ。

追加日:
2026-09-24
依存:
なし
tags
#block #background #decoration #divider #canvas #grid #trail #lattice #motion #no-image #no-dependency

プレビュー

設計の話

区切りの罫線に、続きの合図を持たせる

節と節のあいだの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>
  );
}

shadcn CLI でプロジェクトに追加

npx shadcn@latest add https://designs.first-ch.com/r/grid-trace-backdrop.json