First CH Designs
BLK-74Blocks

砂が積もって崩れるバックドロップ(粒子セルオートマトン)

重力で落ちた粒が消えずに積もり、傾きが限界を超えると崩れるセクション背面。粒はオブジェクトではなく格子セルのバイト配列(Uint8Array)で持ち、規則は「下が空なら落ちる/詰まっていれば斜め下へ滑る」の2つだけ。斜めへ滑る確率がそのまま安息角になり、確率を上げると雪崩が起きる。落下中の粒はアンバー(#d97706)、積もった粒は砂色4段で描き分けるので、一枚の絵の中で「いま落ちている筋」と「もう積もった層」が読み分けられる。注ぎ口はポインタに追従し、無操作ならゆっくり往復する。上部の操作卓は流量3段・崩す・空にするの3操作だけを持ち、下部の計測帯には堆積率・粒数・落下中・堆積面の傾きを等幅で並べた。傾きは飾りの数字ではなく、描画の走査ついでに列ごとの最上段を拾って隣接列の高低差から実測しているので、崩している間は数値が下がる。負荷はセル数の上限(22,000セル目安)と充填率62%の粒数上限で機械的に頭打ちになる。prefers-reduced-motion では計算を回さず、安息角から三角錐の断面を解析的に求めた静止した山を1枚だけ描く(流量ボタンは山の再計算として生き続ける)。停止条件はモーション低減・画面外・タブ非表示の3つ。

追加日:
2026-09-22
依存:
なし
tags
#block #background #decoration #canvas #cellular-automaton #granular #gravity #physics #interactive #motion #no-image #no-dependency

プレビュー

標準の流量

積層のふるまいを、そのまま背景に

落ちた粒は、消えずに形になる

一粒ずつの規則は「下が空なら落ちる、詰まっていれば横へ滑る」の2つだけ。 それでも山ができ、傾きが限界を超えれば崩れます。First CH は、小さな決まりごとの積み重ねで 画面のふるまいを設計します。

表現の相談をする画面をなぞると注ぎ口が動きます
堆積
0%
粒数
0
落下中
0
堆積面の傾き
0°
"use client";

import { useEffect, useRef, useState } from "react";

/**
 * 砂の落下・堆積バックドロップ(BLK-74)
 *
 * 差別化メモ(非公開・実装判断の記録):
 * 既存のキャンバス系(方眼+バネで戻る粒子、カーソル軌跡のロープ)はどちらも
 * 「乱れても元へ戻る/時間で消える」一過性の演出だった。本ブロックは重力で落ちた粒が
 * 格子に残り続け、積もった形そのものが状態(堆積率)になる不可逆型にした。
 * 操作もパラメータのスライダーではなく「注ぐ・崩す・空にする」という堆積への介入に寄せた。
 *
 * 汎用技法メモ(web-design-playbook へ還流する要点):
 * 1. 粒は座標を持たせず**格子セルのバイト配列**(Uint8Array)で持つ。数千粒でもオブジェクトを
 *    作らないので GC が走らず、上限は「セル数×充填率」で機械的に決まる(負荷が青天井にならない)。
 * 2. 更新は下段→上段の走査で「下が空なら落ちる/詰まっていれば斜め下へ滑る」の2規則だけ。
 *    斜めへ滑る確率が安息角になり、確率を下げると急峻な山、1.0 に上げると雪崩になる。
 *    左右の走査方向を1ステップごとに反転しないと山が片側へ寄る(対称性はここでしか作れない)。
 * 3. 走査順の副作用を消すため、移動した先のセルには当該ステップの済みフラグを立てる。
 *    立てないと1粒が1ステップで何マスも落ち、落下速度が走査方向に依存する。
 * 4. 描画は**色ごとに1パス**。セルごとに fillRect を呼ばず、同じ色のセルを rect() でパスへ溜めて
 *    最後に fill() する。状態変更が色数(4回)で済むので数千セルでも 60fps を割らない。
 * 5. 落下中の粒と静止した粒を色で分ける。移動フラグの立った粒だけアンバーで描くと、
 *    「いま落ちている筋」と「もう積もった層」が一枚の絵の中で読み分けられる。
 * 6. reduced-motion では計算を回さず、安息角から三角錐の断面を解析的に求めて1枚描く
 *    (体積 V と傾き s から頂高 = √(V·s))。流量ボタンは山の高さの再計算として生き続ける。
 * 7. 停止条件は prefers-reduced-motion / 画面外(IntersectionObserver)/ タブ非表示の3つ。
 *    毎フレーム変わる堆積率は state にせず ref から transform を直接書き、
 *    数値表示だけ 1% 変化したときに setState する(再レンダリングを演出から切り離す)。
 */

type Flow = 0 | 1 | 2;

const FLOWS: { label: string; note: string; grains: number; spread: number; fill: number }[] = [
  { label: "細", note: "1本の筋で落とす", grains: 2, spread: 1, fill: 0.3 },
  { label: "並", note: "標準の流量", grains: 6, spread: 2, fill: 0.52 },
  { label: "太", note: "面で浴びせる", grains: 11, spread: 5, fill: 0.78 },
];

/** 静止した砂の色(濃淡4段。手前ほど暗い層になるよう深さで選ぶ) */
const TONES = ["#e3d2b8", "#cdb693", "#ad9370", "#7b6a55"];
/** 落下中の粒(アンバー) */
const LIVE = "#d97706";

const CELL_TARGET = 22000; // セル数の上限目安(これ以上は1セルを大きくする)
const REPOSE = 0.56; // 斜めへ滑る確率=安息角。低いほど急峻に積もる
const SLIDE = 1; // 雪崩中の滑り確率
const SUBSTEPS = 2; // 1フレームあたりの物理ステップ
const SPAN = 4; // 傾きを測る列間隔(隣接列だと粒1個の凹凸を拾う)
const BED = 0.07; // 初期の下地(画面が空のまま始まらないように敷く)

export default function SandDepositBackdrop() {
  const [flow, setFlow] = useState<Flow>(1);
  const [stats, setStats] = useState({ fill: 0, grains: 0, falling: 0, angle: 0 });
  const [still, setStill] = useState(false);
  const [inView, setInView] = useState(false);

  const stageRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const gaugeRef = useRef<HTMLSpanElement>(null);
  const flowRef = useRef<Flow>(1);
  const apiRef = useRef<{ collapse: () => void; clear: () => void; refresh: () => void } | null>(null);

  useEffect(() => {
    const id = requestAnimationFrame(() => setInView(true));
    return () => cancelAnimationFrame(id);
  }, []);

  // 流量はシミュレーション側が毎フレーム読むので ref へ。静止時は山を計算し直す
  useEffect(() => {
    flowRef.current = flow;
    apiRef.current?.refresh();
  }, [flow]);

  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 cell = 5;
    let cols = 1;
    let rows = 1;
    let grid = new Uint8Array(1); // 0=空 / 1..4=砂の濃淡
    let moved = new Uint8Array(1); // 当該ステップで動いた印(描画では落下中の色に使う)
    let grains = 0;
    let cap = 1;
    let avalanche = 0; // 残りの雪崩ステップ数
    let flip = false; // 走査方向(毎ステップ反転させないと山が片寄る)
    let clock = 0;
    let raf = 0;
    let last = 0;
    let reported = -1;
    let reportedAt = -Infinity;
    let angleSeen = -1;
    let fallingSeen = -1;
    let ready = false; // 格子が組まれたか(ResizeObserver の初回で立つ)

    const pour = { x: 0.5, live: false, at: -Infinity };

    const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
    let onScreen = true;
    let visible = document.visibilityState !== "hidden";

    const idx = (c: number, r: number) => r * cols + c;
    const rand = () => Math.random();

    const alloc = () => {
      cell = Math.max(4, Math.round(Math.sqrt((w * h) / CELL_TARGET)));
      cols = Math.max(8, Math.floor(w / cell));
      rows = Math.max(8, Math.floor(h / cell));
      grid = new Uint8Array(cols * rows);
      moved = new Uint8Array(cols * rows);
      grains = 0;
      cap = Math.floor(cols * rows * 0.62);
      ready = true;
    };

    /** 下地を敷く。空の枠から始めると「何も起きていない画面」に見える時間が長い */
    const bed = () => {
      const depth = Math.max(2, Math.round(rows * BED));
      for (let r = rows - depth; r < rows; r++) {
        const edge = r === rows - depth; // 最上段だけ欠けさせ、定規のような直線にしない
        for (let c = 0; c < cols; c++) {
          if (edge && Math.random() < 0.45) continue;
          const t = Math.random();
          grid[idx(c, r)] = t < 0.14 ? 4 : t < 0.42 ? 3 : t < 0.78 ? 2 : 1;
          grains++;
        }
      }
    };

    /** 指定列に粒を置く。既に埋まっていれば置かない(=上限に自然に収束する) */
    const drop = (c: number, tone: number) => {
      if (c < 0 || c >= cols || grains >= cap) return;
      const i = idx(c, 0);
      if (grid[i]) return;
      grid[i] = tone;
      moved[i] = 1;
      grains++;
    };

    const emit = () => {
      const f = FLOWS[flowRef.current];
      // 無操作なら注ぎ口がゆっくり往復する(触られていなくても絵が育つ)
      const auto = 0.5 + 0.3 * Math.sin(clock * 0.00092) + 0.08 * Math.sin(clock * 0.0031);
      const idle = !pour.live || clock - pour.at > 1800;
      const center = (idle ? auto : pour.x) * cols;
      for (let k = 0; k < f.grains; k++) {
        const off = Math.round((rand() - 0.5) * 2 * f.spread);
        // 濃淡は深さではなく乱数で振る(層が混ざり、縞に見えない)
        const tone = 1 + (rand() < 0.16 ? 3 : rand() < 0.45 ? 2 : rand() < 0.8 ? 1 : 0);
        drop(Math.round(center) + off, tone);
      }
    };

    const step = () => {
      moved.fill(0);
      const slip = avalanche > 0 ? SLIDE : REPOSE;
      flip = !flip;
      for (let r = rows - 2; r >= 0; r--) {
        for (let n = 0; n < cols; n++) {
          const c = flip ? n : cols - 1 - n;
          const i = idx(c, r);
          const v = grid[i];
          if (!v || moved[i]) continue;

          const below = i + cols;
          if (!grid[below]) {
            grid[below] = v;
            grid[i] = 0;
            moved[below] = 1;
            continue;
          }
          // 真下が詰まっていれば斜め下へ滑る。滑る確率がそのまま安息角になる
          if (rand() > slip) continue;
          const first = rand() < 0.5 ? -1 : 1;
          for (const s of [first, -first]) {
            const nc = c + s;
            if (nc < 0 || nc >= cols) continue;
            const dest = idx(nc, r + 1);
            if (grid[dest]) continue;
            grid[dest] = v;
            grid[i] = 0;
            moved[dest] = 1;
            break;
          }
        }
      }
      if (avalanche > 0) avalanche--;
    };

    const draw = () => {
      ctx.clearRect(0, 0, w, h);
      const size = cell + 0.6; // わずかに重ねて格子の隙間(白線)を消す
      // 色ごとに1パス。セルごとの fillRect は状態変更が多すぎて重い
      const buckets: number[][] = [[], [], [], [], []];
      // 走査ついでに列ごとの「静止した最上段」を拾い、堆積面の傾きを実測する。
      // 落下中の粒を混ぜると、宙にある筋の高さが山の高さとして数えられて角度が跳ね上がる
      const top = new Int32Array(cols).fill(rows);
      for (let i = 0; i < grid.length; i++) {
        const v = grid[i];
        if (!v) continue;
        const live = moved[i] === 1;
        buckets[live ? 4 : v - 1].push(i);
        if (live) continue;
        const c = i % cols;
        if (top[c] === rows) top[c] = (i - c) / cols;
      }
      for (let b = 0; b < 5; b++) {
        const list = buckets[b];
        if (!list.length) continue;
        ctx.fillStyle = b === 4 ? LIVE : TONES[b];
        ctx.beginPath();
        for (const i of list) {
          const c = i % cols;
          const r = (i - c) / cols;
          ctx.rect(c * cell, r * cell, size, size);
        }
        ctx.fill();
      }

      // 隣接列どうしの差は粒1個ぶんの凹凸を拾ってしまうので、SPAN 列ぶんの高低差で均す
      let sum = 0;
      let n = 0;
      for (let c = 0; c + SPAN < cols; c++) {
        if (top[c] === rows || top[c + SPAN] === rows) continue;
        sum += Math.abs(top[c] - top[c + SPAN]) / SPAN;
        n++;
      }
      const angle = n ? Math.round((Math.atan(sum / n) * 180) / Math.PI) : 0;
      return { falling: buckets[4].length, angle };
    };

    /**
     * force: フレーム以外(リサイズ・静止画・操作)からの呼び出し。
     * 間引きの基準がシミュレーション時計なので、rAF が止まっている間は時計も止まる。
     * force を通さないと停止中の更新がすべて握りつぶされ、数値が固まる。
     */
    const report = (m: { falling: number; angle: number }, force = false) => {
      const ratio = grains / cap;
      if (gaugeRef.current) gaugeRef.current.style.transform = `scaleY(${Math.min(1, ratio)})`;
      // 数値表示は演出から切り離す。毎フレーム setState せず 180ms 以上あいたときだけ更新する
      if (!force && clock - reportedAt < 180) return;
      const pct = Math.round(ratio * 100);
      if (pct === reported && m.angle === angleSeen && m.falling === fallingSeen) return;
      reportedAt = clock;
      reported = pct;
      angleSeen = m.angle;
      fallingSeen = m.falling;
      setStats({ fill: pct, grains, falling: m.falling, angle: m.angle });
    };

    const frame = (time: number) => {
      if (!ready) {
        // 格子が組まれるのは ResizeObserver の初回。それまでは空回しする
        raf = requestAnimationFrame(frame);
        return;
      }
      if (!last) last = time;
      const dt = Math.min(48, time - last);
      last = time;
      clock += dt;
      for (let s = 0; s < SUBSTEPS; s++) {
        emit();
        step();
      }
      report(draw());
      raf = requestAnimationFrame(frame);
    };

    const stop = () => {
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
    };

    const start = () => {
      if (raf || mql.matches || !onScreen || !visible) return;
      last = 0;
      raf = requestAnimationFrame(frame);
    };

    /**
     * reduced-motion 用の静止画。安息角から三角錐の断面を解析的に求める。
     * 体積 V の山は傾き s のとき頂高 = √(V·s)(断面積 = 頂高²/s より)。
     */
    const drawStatic = () => {
      grid.fill(0);
      moved.fill(0);
      grains = 0;
      const f = FLOWS[flowRef.current];
      const volume = cols * rows * f.fill * 0.55;
      const slope = 0.86; // 1列あたり何セル下がるか=安息角
      const peak = Math.min(rows * 0.92, Math.sqrt(volume * slope));
      const cx = cols * 0.46;
      // 素の三角形は定規で引いた斜面に見える。粗さ(細かい凹凸)とうねり(低い周波数)を
      // 決定的なハッシュから足して、同じ絵のまま砂の稜線にする
      const noise = (c: number, seed: number) => {
        const n = ((c * 374761393) ^ (seed * 668265263)) >>> 0;
        return ((n % 1000) / 1000 - 0.5) * 2;
      };
      for (let c = 0; c < cols; c++) {
        const swell = noise(c >> 3, 7) * peak * 0.09 + noise(c >> 1, 13) * peak * 0.03;
        const hgt = Math.round(peak - Math.abs(c - cx) * slope + swell + noise(c, 3) * 1.4);
        for (let k = 0; k < hgt; k++) {
          const r = rows - 1 - k;
          if (r < 0) break;
          // 決定的なハッシュで濃淡を散らす(描き直しても同じ絵になる)
          const n = ((c * 73856093) ^ (r * 19349663)) >>> 0;
          const t = (n % 100) / 100;
          grid[idx(c, r)] = t < 0.16 ? 4 : t < 0.45 ? 3 : t < 0.8 ? 2 : 1;
          grains++;
        }
      }
      report(draw(), true);
    };

    apiRef.current = {
      collapse: () => {
        if (mql.matches) {
          // 静止時は「崩れた後」の低く広い山へ置き換える(操作が死なないように)
          drawStatic();
          return;
        }
        avalanche = 90;
      },
      clear: () => {
        grid.fill(0);
        moved.fill(0);
        grains = 0;
        avalanche = 0;
        if (mql.matches) report(draw(), true);
      },
      refresh: () => {
        if (mql.matches) drawStatic();
      },
    };

    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) drawStatic();
      else {
        bed();
        report(draw(), true);
      }
    });
    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 toLocal = (clientX: number) => {
      const rect = stage.getBoundingClientRect();
      if (!rect.width) return;
      // 変形後の矩形→0..1 の比率へ(縮小プレビューの中でも注ぎ口が合う)
      pour.x = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
      pour.live = true;
      pour.at = clock;
    };
    const onMove = (e: PointerEvent) => toLocal(e.clientX);
    const onLeave = () => {
      pour.live = false;
    };
    const onMotionChange = () => {
      setStill(mql.matches);
      if (mql.matches) {
        stop();
        drawStatic();
      } else {
        start();
      }
    };

    stage.addEventListener("pointermove", onMove, { passive: true });
    stage.addEventListener("pointerdown", onMove, { passive: true });
    stage.addEventListener("pointerleave", onLeave, { passive: true });
    document.addEventListener("visibilitychange", onVisibility);
    mql.addEventListener("change", onMotionChange);

    setStill(mql.matches);
    if (mql.matches) {
      if (ready) drawStatic();
    } else start();

    return () => {
      stop();
      apiRef.current = null;
      ro.disconnect();
      io.disconnect();
      stage.removeEventListener("pointermove", onMove);
      stage.removeEventListener("pointerdown", onMove);
      stage.removeEventListener("pointerleave", onLeave);
      document.removeEventListener("visibilitychange", onVisibility);
      mql.removeEventListener("change", onMotionChange);
    };
  }, []);

  const revealClass = `transition-[opacity,transform] duration-700 ease-out motion-reduce:transition-none ${
    inView
      ? "translate-y-0 opacity-100"
      : "translate-y-3 opacity-0 motion-reduce:translate-y-0 motion-reduce:opacity-100"
  }`;

  const readouts = [
    { label: "堆積", value: `${stats.fill}`, unit: "%" },
    { label: "粒数", value: stats.grains.toLocaleString("en-US"), unit: "" },
    { label: "落下中", value: `${stats.falling}`, unit: "" },
    { label: "堆積面の傾き", value: `${stats.angle}`, unit: "°" },
  ];

  return (
    <div className="w-full max-w-4xl overflow-hidden rounded-2xl border border-gray-200 bg-white">
      {/* 操作卓は上。堆積そのものが状態なので「注ぐ・崩す・空にする」だけを持たせる */}
      <div className="flex flex-wrap items-center gap-x-6 gap-y-3 border-b border-gray-200 px-4 py-3 sm:px-7 sm:py-4">
        <div className="flex items-baseline gap-3">
          <span className="font-mono text-[11px] font-bold tracking-[0.12em] text-amber-600">
            {FLOWS[flow].label}流
          </span>
          <span className="text-[11px] tracking-[0.04em] text-gray-500">{FLOWS[flow].note}</span>
        </div>
        <div className="flex flex-wrap items-center gap-x-4 gap-y-2 sm:ml-auto">
          <div className="flex border border-gray-200">
            {FLOWS.map((f, i) => (
              <button
                key={f.label}
                type="button"
                aria-pressed={flow === i}
                onClick={() => setFlow(i as Flow)}
                className={`border-l border-gray-200 px-3.5 py-1.5 text-[12px] font-bold transition-colors first:border-l-0 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-amber-600 motion-reduce:transition-none ${
                  flow === i ? "bg-gray-900 text-white" : "text-gray-500 hover:text-gray-900"
                }`}
              >
                {f.label}
              </button>
            ))}
          </div>
          <button
            type="button"
            onClick={() => apiRef.current?.collapse()}
            className="bg-amber-600 px-4 py-1.5 text-[12px] font-bold text-white transition-colors hover:bg-amber-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600 motion-reduce:transition-none"
          >
            崩す
          </button>
          <button
            type="button"
            onClick={() => apiRef.current?.clear()}
            className="border border-gray-300 px-4 py-1.5 text-[12px] font-bold text-gray-600 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"
          >
            空にする
          </button>
        </div>
      </div>

      <div ref={stageRef} className="relative min-h-[380px] touch-pan-y overflow-hidden sm:min-h-[460px]">
        <canvas ref={canvasRef} aria-hidden="true" className="absolute inset-0 block h-full w-full" />

        {/* 右端の水位ゲージ。毎フレーム変わる値は state を経由せず transform で直接書く */}
        <div
          aria-hidden="true"
          className="absolute top-6 right-3 bottom-6 w-6 sm:right-5 sm:w-8"
        >
          <span className="absolute inset-y-0 right-0 w-px bg-gray-200" />
          <span
            ref={gaugeRef}
            className="absolute inset-y-0 right-0 w-px origin-bottom scale-y-0 bg-amber-600"
          />
          {[0.25, 0.5, 0.75].map((t) => (
            <span
              key={t}
              style={{ bottom: `${t * 100}%` }}
              className="absolute right-0 h-px w-2.5 bg-gray-200"
            />
          ))}
        </div>

        {/* 前景の紙面。砂は背面に積もるので、どれだけ埋まっても本文の可読性は動かない */}
        <div className={`${revealClass} relative max-w-md px-5 pt-8 pb-28 sm:px-10 sm:py-12`}>
          <div className="bg-white/95 px-5 py-6 shadow-[0_22px_56px_-30px_rgba(15,23,42,0.6)] backdrop-blur-[1px] sm:px-8 sm:py-8">
            <p className="text-[12px] leading-[1.9] font-bold tracking-[0.08em] text-amber-600">
              積層のふるまいを、そのまま背景に
            </p>
            <p className="mt-3 text-[clamp(1.35rem,4.6vw,2.05rem)] leading-[1.45] font-bold tracking-[0.01em] text-gray-900">
              落ちた粒は、消えずに形になる
            </p>
            <p className="mt-4 text-[12.5px] leading-[1.95] text-gray-600 sm:text-[13.5px]">
              一粒ずつの規則は「下が空なら落ちる、詰まっていれば横へ滑る」の2つだけ。
              それでも山ができ、傾きが限界を超えれば崩れます。First CH は、小さな決まりごとの積み重ねで
              画面のふるまいを設計します。
            </p>
            <div className="mt-6 flex flex-col items-start gap-2 border-t border-gray-200 pt-4 sm:flex-row sm:items-center sm:gap-4">
              <a
                href="#"
                className="border-b border-gray-900 pb-0.5 text-[13px] font-bold text-gray-900 transition-colors hover:border-amber-600 hover:text-amber-600 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600 motion-reduce:transition-none"
              >
                表現の相談をする
              </a>
              <span className="text-[11px] tracking-[0.04em] text-gray-400">
                {still ? "モーション低減中は静止した山を表示" : "画面をなぞると注ぎ口が動きます"}
              </span>
            </div>
          </div>
        </div>
      </div>

      {/* 計測帯。カード羅列を避け、hairline 罫線で区切った等幅の数値だけを並べる */}
      <dl className="grid grid-cols-2 border-t border-gray-200 sm:grid-cols-4">
        {readouts.map((r, i) => (
          <div
            key={r.label}
            className={`flex items-baseline justify-between gap-2 px-4 py-3 sm:px-7 sm:py-4 ${
              i % 2 === 1 ? "border-l border-gray-200" : ""
            } ${i >= 2 ? "border-t border-gray-200 sm:border-t-0" : ""} ${
              i === 2 ? "sm:border-l sm:border-gray-200" : ""
            }`}
          >
            <dt className="text-[11px] tracking-[0.08em] text-gray-500">{r.label}</dt>
            <dd className="font-mono text-[13px] font-bold text-gray-900 tabular-nums">
              {r.value}
              <span className="ml-0.5 text-[10px] font-medium text-gray-400">{r.unit}</span>
            </dd>
          </div>
        ))}
      </dl>
    </div>
  );
}

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

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