First CH Designs
BLK-82Blocks

花びらが舞い落ちる季節演出(風向つきバックドロップ)

季節の挨拶ページや和風の案件に添える背景装飾。花びらは点ではなく板として持ち、面内2軸をZ→Y→Xの順に回してから画面へ射影し、その基底ベクトル2本をctx.transformへ直接渡す。面の見え方は行列式そのままなので、真横を向けば線になり、裏返れば裏面の色に切り替わる(角度から幅を縮める近似では出ない挙動)。落下は大きさから決める—大きい花びらは風を受けにくく重く落ちるので、層を手で分けなくても視差になる。横揺れの位相にx座標を混ぜるため、風に流れるほど揺れが進みループ感が消える。状態は積もりのような不可逆なものではなく、風の強さ3段と風向の反転だけ。切り替えは目標値へ指数で寄せる(フレーム時間を指数に入れるのでfpsが落ちても到達時間が変わらない)ため、風向を変えると花びらは減速してから逆流する。奥行きは2枚のcanvasで前後に分け、手前層だけを版面の上に重ねるので本文の前を花びらが横切る。スプライトは表裏×濃淡の8枚をcanvasへ同期で描いて使い回す。prefers-reduced-motionでは固定シードで散った一枚だけを描き、風ボタンは静止画の再描画として生き続ける。画面外・タブ非表示ではrAFを止める。依存ライブラリゼロ・画像素材ゼロ。

追加日:
2026-09-27
依存:
なし
tags
#block #background #decoration #seasonal #canvas #particles #petals #wind #vertical-writing #motion #no-image #no-dependency

プレビュー

そよ標準の風

花のころ、いかがお過ごしですか

季節のたよりを、ひとひらに

季節のごあいさつに添える背景

ひらひらと、
向きを持って落ちる

一枚ずつが面の向きを持っているので、風に乗ると裏が見え、真横を向いた瞬間は線になります。 枚数を増やさなくても密度が出るのは、動きが「落ちる」だけで終わっていないからです。

季節の挨拶ページを相談する風の強さと向きは上のボタンで切り替わります
舞う枚数
0枚
風速
0px/s
面が立つ
0枚
手前層
0枚
"use client";

import { useEffect, useRef, useState } from "react";
import { Zen_Old_Mincho } from "next/font/google";

// preload: false は和文フォント必須(unicode-range で約37分割されるため、preload すると
// 全ページに数十本の <link rel="preload"> がぶら下がる)。
const zenOldMincho = Zen_Old_Mincho({
  weight: ["400"],
  subsets: ["latin"],
  display: "swap",
  preload: false,
});

/** 花びらが舞い落ちるバックドロップ(BLK-82)。 */

type WindLevel = 0 | 1 | 2;

const WINDS: {
  label: string;
  note: string;
  speed: number; // 水平方向の風速(px/s・基準幅900px時)
  fall: number; // 落下の基準速度(px/s)
  turbulence: number; // 縦揺れの強さ
  tumble: number; // 回転の速さ倍率
  density: number; // 枚数の倍率
}[] = [
  { label: "凪", note: "ほとんど風がない", speed: 26, fall: 34, turbulence: 0.35, tumble: 0.5, density: 0.6 },
  { label: "そよ", note: "標準の風", speed: 96, fall: 46, turbulence: 0.7, tumble: 1, density: 1 },
  { label: "花嵐", note: "ひとしきり強く吹く", speed: 226, fall: 62, turbulence: 1.25, tumble: 1.8, density: 1.5 },
];

/** 花びらの表面(淡い順)。白基調に馴染むアンバー寄りの4段 */
const FACE = ["#f9e2c0", "#f3cd99", "#e8ae6b", "#d18e3f"];
/** 裏面は1段暗く。裏返った瞬間に色が変わるので回転が読める */
const BACK = ["#eed2a6", "#e3b87e", "#d29551", "#b0722a"];
/** 輪郭と主脈 */
const EDGE = "rgba(160, 94, 18, 0.55)";

const SPRITE = 96; // スプライト1枚の論理サイズ
const REF_W = 900; // 風速の基準幅(狭い枠では比例して落とす)
const MAX_PETALS = 132;

/** 固定シード乱数(reduced-motion の静止画を毎回同じ絵にする) */
function mulberry32(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** 1枚の花びら(表・裏)を論理サイズ SPRITE の canvas へ描く */
function makeSprite(fill: string, stroke: string): HTMLCanvasElement {
  const dpr = Math.min(typeof window === "undefined" ? 1 : window.devicePixelRatio || 1, 2);
  const c = document.createElement("canvas");
  c.width = SPRITE * dpr;
  c.height = SPRITE * dpr;
  const g = c.getContext("2d");
  if (!g) return c;
  g.setTransform(dpr, 0, 0, dpr, 0, 0);

  const cx = SPRITE / 2;
  // しずく型:先端を細く、付け根を丸く。先端には浅い切れ込みを入れる
  g.beginPath();
  g.moveTo(cx, SPRITE * 0.1);
  g.bezierCurveTo(SPRITE * 0.14, SPRITE * 0.3, SPRITE * 0.16, SPRITE * 0.78, cx, SPRITE * 0.92);
  g.bezierCurveTo(SPRITE * 0.84, SPRITE * 0.78, SPRITE * 0.86, SPRITE * 0.3, cx, SPRITE * 0.1);
  g.closePath();
  g.fillStyle = fill;
  g.fill();
  g.strokeStyle = stroke;
  g.lineWidth = 1.1;
  g.stroke();

  // 主脈。1本だけ入れると回転したとき面の向きが読める
  g.beginPath();
  g.moveTo(cx, SPRITE * 0.16);
  g.lineTo(cx, SPRITE * 0.86);
  g.strokeStyle = stroke;
  g.lineWidth = 0.9;
  g.stroke();

  return c;
}

type Petal = {
  x: number;
  y: number;
  z: number; // 0=奥 / 1=手前
  size: number;
  tone: number; // 濃淡(0..3)
  wind: number; // 風の受けやすさ(小さい花びらほど大きい)
  vx: number;
  vy: number;
  wave: number; // 縦揺れの位相
  ax: number;
  ay: number;
  az: number;
  sx: number; // 前後の傾きの速さ
  sz: number; // 面内の回転の速さ
  free: boolean; // true=水平軸まわりに回り続ける(=裏返る)少数派
  sy: number; // free のときの回転速度
  wob: number; // free でないときの傾きの振れ幅(rad)
  fq: number; // 振れの周期(Hz)
  ph: number; // 振れの位相
};

export default function PetalFallSeasonalBackdrop() {
  const [wind, setWind] = useState<WindLevel>(1);
  const [dir, setDir] = useState<1 | -1>(-1);
  const [still, setStill] = useState(false);
  const [inView, setInView] = useState(false);
  const [stats, setStats] = useState({ petals: 0, speed: 0, edge: 0, near: 0 });

  const stageRef = useRef<HTMLDivElement>(null);
  const backRef = useRef<HTMLCanvasElement>(null);
  const frontRef = useRef<HTMLCanvasElement>(null);
  const windRef = useRef<WindLevel>(1);
  const dirRef = useRef<1 | -1>(-1);
  const apiRef = useRef<{ refresh: () => void } | null>(null);

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

  // 風の設定はシミュレーション側が毎フレーム読むので ref へ。静止時は1枚を描き直す
  useEffect(() => {
    windRef.current = wind;
    dirRef.current = dir;
    apiRef.current?.refresh();
  }, [wind, dir]);

  useEffect(() => {
    const stage = stageRef.current;
    const back = backRef.current;
    const front = frontRef.current;
    if (!stage || !back || !front) return;
    const bctx = back.getContext("2d");
    const fctx = front.getContext("2d");
    if (!bctx || !fctx) return;

    const faces = FACE.map((f) => makeSprite(f, EDGE));
    const backs = BACK.map((f) => makeSprite(f, EDGE));

    let w = 1;
    let h = 1;
    let ready = false;
    let petals: Petal[] = [];
    let raf = 0;
    let last = 0;
    let statAt = 0;
    // 風は目標値へ指数で寄せる(dt を指数に入れるので fps が落ちても到達時間は同じ)
    let speedNow = WINDS[1].speed;
    let fallNow = WINDS[1].fall;
    let turbNow = WINDS[1].turbulence;
    let tumbleNow = WINDS[1].tumble;
    let dirNow = -1; // -1..1 の連続値。符号が反転するまで数百msかかる

    const scale = () => Math.min(1, Math.max(0.45, w / REF_W));

    const spawn = (onScreen: boolean, rnd: () => number): Petal => {
      const s = scale();
      const z = rnd();
      const size = (15 + rnd() * 26) * (0.62 + z * 0.55) * (0.7 + s * 0.3);
      const d = dirRef.current;
      return {
        x: onScreen ? rnd() * w : d === -1 ? w + size + rnd() * w * 0.8 : -size - rnd() * w * 0.8,
        // 半分は上から、半分は風上の側面から入れる。上からだけにすると風下の下半分にだけ
        // 溜まって見え、枠の上半分が空いてしまう
        y: onScreen ? rnd() * h : rnd() < 0.5 ? -size - rnd() * h * 0.4 : rnd() * h * 0.75 - size,
        z,
        size,
        tone: Math.min(3, Math.floor(rnd() * 4)),
        // 大きい(手前の)花びらは風を受けにくく、重く落ちる=勝手に視差になる
        wind: Math.max(0.22, Math.min(1, 1.12 - z * 0.62 - rnd() * 0.22)),
        vx: 0,
        vy: 0,
        wave: rnd() * Math.PI * 2,
        ax: (rnd() - 0.5) * 0.8,
        ay: 0,
        az: rnd() * Math.PI * 2,
        sx: (rnd() - 0.5) * 0.5,
        sz: (rnd() - 0.5) * 0.9,
        // 3枚に1枚だけを回り続ける役にする。全部が回ると常時どれかが真横を向き、
        // 花びらの形が読めない絵になる(裏返りは「たまに起きる」から効く)
        free: rnd() < 0.34,
        sy: (rnd() - 0.5) * 1.8,
        wob: 0.55 + rnd() * 0.6,
        fq: 0.22 + rnd() * 0.35,
        ph: rnd() * Math.PI * 2,
      };
    };

    const targetCount = () =>
      Math.round(
        Math.min(MAX_PETALS, Math.max(16, ((w * h) / 8200) * WINDS[windRef.current].density)),
      );

    /** 1枚を描く。戻り値は面の見え方(0=面が立っている / 1=真正面) */
    const paint = (ctx: CanvasRenderingContext2D, p: Petal) => {
      const cz = Math.cos(p.az);
      const sz = Math.sin(p.az);
      const cy = Math.cos(p.ay);
      const sy = Math.sin(p.ay);
      const cx = Math.cos(p.ax);
      const sxx = Math.sin(p.ax);
      // 面内2軸を Z→Y→X の順で回してから画面へ射影する
      const ux = cz * cy;
      const uy = sz * cx + cz * sy * sxx;
      const vx = -sz * cy;
      const vy = cz * cx - sz * sy * sxx;
      const det = ux * vy - uy * vx;

      ctx.globalAlpha = 0.55 + p.z * 0.42;
      if (Math.abs(det) < 0.05) {
        // 面が真横を向いた瞬間。drawImage では消えるので、板の稜線を1本引いて残す
        const len = p.size * 0.5;
        ctx.beginPath();
        ctx.moveTo(p.x - vx * len, p.y - vy * len);
        ctx.lineTo(p.x + vx * len, p.y + vy * len);
        ctx.strokeStyle = EDGE;
        ctx.lineWidth = Math.max(1, p.size * 0.06);
        ctx.stroke();
        return det;
      }
      const sprite = det < 0 ? backs[p.tone] : faces[p.tone];
      ctx.save();
      ctx.translate(p.x, p.y);
      ctx.transform(ux, uy, vx, vy, 0, 0);
      ctx.drawImage(sprite, -p.size / 2, -p.size / 2, p.size, p.size);
      ctx.restore();
      return det;
    };

    const clear = () => {
      bctx.clearRect(0, 0, w, h);
      fctx.clearRect(0, 0, w, h);
    };

    /** 手前層だけ前面 canvas へ。1本の配列を2パスで振り分けるだけ */
    const drawAll = () => {
      clear();
      let edge = 0;
      let near = 0;
      for (const p of petals) {
        const front = p.z > 0.72;
        const det = paint(front ? fctx : bctx, p);
        if (Math.abs(det) < 0.18) edge += 1;
        if (front) near += 1;
      }
      bctx.globalAlpha = 1;
      fctx.globalAlpha = 1;
      return { edge, near };
    };

    const step = (dt: number) => {
      const cfg = WINDS[windRef.current];
      const s = scale();
      const k = 1 - Math.exp(-dt / 520); // 風の立ち上がり・鎮まり
      speedNow += (cfg.speed * s - speedNow) * k;
      fallNow += (cfg.fall * s - fallNow) * k;
      turbNow += (cfg.turbulence - turbNow) * k;
      tumbleNow += (cfg.tumble - tumbleNow) * k;
      dirNow += (dirRef.current - dirNow) * (1 - Math.exp(-dt / 700));

      // 枚数の増減も1フレーム2枚までにして、段を切り替えた瞬間に湧かせない
      const target = targetCount();
      const rnd = Math.random;
      if (petals.length < target) {
        for (let i = 0; i < Math.min(2, target - petals.length); i += 1) petals.push(spawn(false, rnd));
      } else if (petals.length > target) {
        petals.length = Math.max(target, petals.length - 2);
      }

      const sec = dt / 1000;
      const margin = 140;
      for (const p of petals) {
        const targetVx = speedNow * p.wind * dirNow;
        p.vx += (targetVx - p.vx) * (1 - Math.exp(-dt / 320));
        p.x += p.vx * sec;

        // 落下は「重さ(風を受けにくさ)」で決まる。横揺れの位相に x を混ぜると
        // 風に流れるほど揺れが進み、周期が噛み合わない
        const heavy = 1.5 - p.wind;
        const wave = Math.sin(p.x * 0.012 + p.wave);
        const targetVy = fallNow * heavy + wave * turbNow * 26;
        p.vy += (targetVy - p.vy) * (1 - Math.exp(-dt / 420));
        p.y += p.vy * sec;

        const spin = tumbleNow * sec;
        p.ax += p.sx * spin;
        p.az += p.sz * spin;
        if (p.free) {
          p.ay += (p.sy + p.vx * 0.004) * spin;
        } else {
          // 回り続けずに振れるだけの多数派。面はほぼ正面を向いたままひらひらする
          p.ph += p.fq * tumbleNow * sec * Math.PI * 2;
          p.ay = Math.sin(p.ph) * p.wob;
        }

        const outX = p.x < -margin || p.x > w + margin;
        if (outX || p.y > h + margin) Object.assign(p, spawn(false, rnd));
      }
    };

    /** reduced-motion 用。固定シードで散らした1枚(風の段で密度と傾きが変わる) */
    const drawStatic = () => {
      if (!ready) return;
      const cfg = WINDS[windRef.current];
      const rnd = mulberry32(20260927);
      const count = targetCount();
      petals = [];
      for (let i = 0; i < count; i += 1) {
        const p = spawn(true, rnd);
        p.x = rnd() * (w + 80) - 40;
        p.y = rnd() * (h + 80) - 40;
        // 風が強いほど流れの向きへ傾いた姿勢で止める(静止画でも風向が読める)
        p.az = dirRef.current * (0.25 + cfg.tumble * 0.5) + (rnd() - 0.5) * 0.9;
        // 大半は正面寄り、1割ほどが真横〜裏面。動いている絵の分布に合わせる
        p.ay = rnd() < 0.12 ? (rnd() - 0.5) * 4.4 : (rnd() - 0.5) * 1.7;
        p.ax = (rnd() - 0.5) * 1.1;
        petals.push(p);
      }
      const { edge, near } = drawAll();
      setStats({ petals: count, speed: Math.round(cfg.speed * scale()), edge, near });
    };

    const frame = (t: number) => {
      raf = requestAnimationFrame(frame);
      if (!ready) return;
      const dt = last ? Math.min(64, t - last) : 16;
      last = t;
      step(dt);
      const { edge, near } = drawAll();
      if (t - statAt > 280) {
        statAt = t;
        setStats({ petals: petals.length, speed: Math.round(Math.abs(speedNow * dirNow)), edge, near });
      }
    };

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

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

    const resize = () => {
      // 縮小プレビューの中でも寸法が合うよう、変形後の矩形ではなくレイアウト寸法を採る
      if (!stage.clientWidth || !stage.clientHeight) return;
      w = stage.clientWidth;
      h = stage.clientHeight;
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      for (const c of [back, front]) {
        c.width = Math.round(w * dpr);
        c.height = Math.round(h * dpr);
        const ctx = c.getContext("2d");
        if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      }
      const first = !ready;
      ready = true;
      if (mql.matches) {
        drawStatic();
        return;
      }
      if (first) {
        const rnd = Math.random;
        petals = Array.from({ length: targetCount() }, () => spawn(true, rnd));
      }
      // canvas の width/height 代入は中身を消す。rAF が止まっている(画面外・タブ非表示)
      // 間に寸法が変わると空白のまま残るので、リサイズのたびに必ず1枚描き直す
      const { edge, near } = drawAll();
      setStats({ petals: petals.length, speed: Math.round(Math.abs(speedNow)), edge, near });
    };

    const ro = new ResizeObserver(resize);
    ro.observe(stage);
    resize();

    const io = new IntersectionObserver(
      (entries) => {
        onScreen = entries.some((e) => e.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();
        drawStatic();
      } else start();
    };

    // 画面外で風を変えても値が古びないよう、静止時はここで描き直す
    apiRef.current = {
      refresh: () => {
        if (mql.matches) drawStatic();
      },
    };

    document.addEventListener("visibilitychange", onVisibility);
    mql.addEventListener("change", onMotionChange);
    setStill(mql.matches);
    if (mql.matches) drawStatic();

    return () => {
      stop();
      apiRef.current = null;
      ro.disconnect();
      io.disconnect();
      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"
  }`;

  // 縦組みは行を配列で持ち flex-row-reverse で並べる(折り返しをブラウザに任せない)
  const greeting = ["花のころ、いかがお過ごしですか", "季節のたよりを、ひとひらに"];

  const readouts = [
    { label: "舞う枚数", value: stats.petals.toLocaleString("en-US"), unit: "枚" },
    { label: "風速", value: `${stats.speed}`, unit: "px/s" },
    { label: "面が立つ", value: `${stats.edge}`, unit: "枚" },
    { label: "手前層", value: `${stats.near}`, 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">
            {WINDS[wind].label}
          </span>
          <span className="text-[11px] tracking-[0.04em] text-gray-500">{WINDS[wind].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">
            {WINDS.map((wd, i) => (
              <button
                key={wd.label}
                type="button"
                aria-pressed={wind === i}
                onClick={() => setWind(i as WindLevel)}
                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 ${
                  wind === i ? "bg-gray-900 text-white" : "text-gray-500 hover:text-gray-900"
                }`}
              >
                {wd.label}
              </button>
            ))}
          </div>
          <button
            type="button"
            onClick={() => setDir((d) => (d === -1 ? 1 : -1))}
            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"
          >
            風向を変える({dir === -1 ? "右から左へ" : "左から右へ"})
          </button>
        </div>
      </div>

      <div ref={stageRef} className="relative min-h-[400px] overflow-hidden sm:min-h-[470px]">
        {/* 奥の層。版面の背面に置く */}
        <canvas ref={backRef} aria-hidden="true" className="absolute inset-0 block h-full w-full" />

        <div className="relative flex flex-col gap-8 px-5 py-9 sm:flex-row sm:items-start sm:gap-10 sm:px-10 sm:py-12">
          {/* 縦組みの季節の挨拶。明朝・行は固定 */}
          <div
            className={`${revealClass} ${zenOldMincho.className} order-1 flex flex-row-reverse justify-end gap-3 self-end sm:order-2 sm:ml-auto sm:self-start`}
          >
            {greeting.map((line, i) => (
              <p
                key={line}
                style={{ transitionDelay: inView ? `${120 + i * 140}ms` : "0ms" }}
                className={`text-[15px] leading-[2.1] tracking-[0.12em] text-gray-900 [writing-mode:vertical-rl] sm:text-[17px] ${
                  i === 1 ? "pt-7 text-gray-600" : ""
                } ${revealClass}`}
              >
                {line}
              </p>
            ))}
          </div>

          {/* 本文。背面が白なのでパネルを敷かず、罫線と余白だけでまとめる */}
          <div className={`${revealClass} order-2 max-w-sm sm:order-1`}>
            <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 text-[clamp(1.3rem,4.4vw,1.95rem)] leading-[1.5] font-bold tracking-[0.01em] text-gray-900">
              ひらひらと、
              <br />
              向きを持って落ちる
            </h3>
            <p className="mt-4 bg-white/85 text-[12.5px] leading-[1.95] text-gray-600 sm:text-[13.5px]">
              一枚ずつが面の向きを持っているので、風に乗ると裏が見え、真横を向いた瞬間は線になります。
              枚数を増やさなくても密度が出るのは、動きが「落ちる」だけで終わっていないからです。
            </p>
            <div className="mt-6 flex flex-col items-start gap-2 border-t border-gray-200 pt-4">
              <a
                href="#"
                className="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"
              >
                季節の挨拶ページを相談する
              </a>
              <span className="text-[11px] tracking-[0.04em] text-gray-400">
                {still ? "モーション低減中は散った一枚を静止表示" : "風の強さと向きは上のボタンで切り替わります"}
              </span>
            </div>
          </div>
        </div>

        {/* 手前の層。版面より上に重なり、本文の前を横切る */}
        <canvas
          ref={frontRef}
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 block h-full w-full"
        />
      </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/petal-fall-seasonal-backdrop.json