First CH Designs
PGE-04Pages

Canvas Particle Hero (Grid + Spring Physics)

A hero whose Canvas 2D particles sit on a measured grid rather than at random: the pointer repels them and a spring with damping pulls them home, so order dissolves and reforms in a way that echoes the gallery's dotted specimen grid. Particles near the pointer shift to amber (#d97706). Velocity and damping are normalised by delta time so 120Hz displays don't run double speed, and the rAF loop stops on three conditions — prefers-reduced-motion, off-screen (IntersectionObserver) and hidden tab. After a pause, a Lissajous virtual pointer takes over so the effect still reads on touch devices. No dependencies, no image assets. Only the idea (canvas particles + pointer physics) is borrowed from the reference pen; all code, copy, colour and composition are original.

Added:
2026-08-08
Dependencies:
None
tags
#hero #page #canvas #particles #physics #interactive #motion #no-image #no-dependency

Preview

First CH Studio

Space responds.

見た瞬間に、手を動かしたくなるか。First CH は、触れて確かめられるWebをつくります。

01設計
情報の順番から決める
02実装
軽く、壊れない
03運用
公開したあとも伴走
"use client";

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

const montserrat = Montserrat({ weight: ["700"], subsets: ["latin"], display: "swap" });

/**
 * キャンバス・パーティクル背景ヒーロー(PGE-04)
 *
 * 汎用技法メモ(web-design-playbook へ還流する要点):
 * 1. 方眼グリッド+バネ物理: 粒子は「ホーム座標」を持ち、ポインタの斥力で押し出され、
 *    フックの法則(v += (home - pos) * SPRING)と減衰で戻る。ランダム配置より
 *    「整っていたものが乱れて戻る」ほうが知的に見え、ドット方眼の標本モチーフとも噛み合う。
 * 2. dt スケール: 速度・減衰をフレーム固定値にせず dt/16.667 で正規化する
 *    (120Hz ディスプレイで倍速にならない)。減衰は Math.pow(DAMP, dtScale)。
 * 3. ResizeObserver の contentRect は CSS transform の影響を受けない=レイアウト寸法。
 *    一覧カードのように scale が掛かる場所でもバッキングストアの解像度が崩れない。
 *    逆にポインタ座標は getBoundingClientRect(変形後)なので、比率で論理座標へ換算する。
 * 4. 描画のバッチ: 静止中の粒子は fillStyle 1回+単一 Path でまとめて塗り、
 *    ポインタ近傍で色が変わる粒子だけ個別に描く(状態変更の回数を最小化)。
 * 5. 停止条件を3つ持つ: prefers-reduced-motion / IntersectionObserver(画面外)/
 *    visibilitychange(タブ非表示)。同種ブロックが並ぶ一覧ページでは必須。
 * 6. ポインタが一定時間止まったら Lissajous 曲線の「仮想ポインタ」へ引き継ぐ。
 *    タッチ環境でも、まだ触っていないファーストビューでも演出が成立する。
 *
 * 着想: Canvas の粒子+ポインタ物理という発想。
 * コード・文言・配色・構図は First CH のオリジナルとして新規に書き起こしている。
 */

type Particle = {
  hx: number;
  hy: number;
  x: number;
  y: number;
  vx: number;
  vy: number;
  ph: number;
  t: number;
};

const GAP = 28; // 方眼の目安ピッチ(px)
const RADIUS = 124; // ポインタの影響半径(px)
const PUSH = 2.4; // 斥力の強さ
const SPRING = 0.055; // ホームへ戻すバネ定数
const DAMP = 0.86; // 60fps基準の減衰率
const IDLE_MS = 2400; // これだけ触られなければ仮想ポインタへ引き継ぐ
const TAU = Math.PI * 2;

export default function ParticleGridHero({
  headingTag: Heading = "h1",
}: {
  /**
   * 見出しのタグ。実案件ではそのまま(h1)。ギャラリーのプレビューは1ページに複数の標本が
   * 並ぶため "p" を渡してh1の重複を避ける(見た目はclassNameで決まるので変わらない)。
   */
  headingTag?: "h1" | "p";
}) {
  const [inView, setInView] = useState(false);
  const [touched, setTouched] = useState(false);
  const stageRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  // マウント後にリビール開始(reduced-motion 時は motion-reduce: で即時表示)
  useEffect(() => {
    const id = requestAnimationFrame(() => setInView(true));
    return () => cancelAnimationFrame(id);
  }, []);

  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 points: Particle[] = [];
    let raf = 0;
    let last = 0;
    let clock = 0;

    // 実ポインタの目標値と、それを追いかける演出用ポインタ
    const target = { x: 0, y: 0, live: false, at: -Infinity };
    const cursor = { x: 0, y: 0, s: 0 };
    let pulse = 0;

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

    const build = () => {
      const cols = Math.max(2, Math.round(w / GAP));
      const rows = Math.max(2, Math.round(h / GAP));
      const stepX = w / cols;
      const stepY = h / rows;
      points = [];
      for (let r = 0; r <= rows; r++) {
        for (let c = 0; c <= cols; c++) {
          const hx = stepX * c;
          const hy = stepY * r;
          points.push({ hx, hy, x: hx, y: hy, vx: 0, vy: 0, ph: ((c * 7 + r * 13) % 61) / 61 * TAU, t: 0 });
        }
      }
    };

    const draw = () => {
      ctx.clearRect(0, 0, w, h);

      // ポインタの計測リング(標本モチーフ。低アルファのヘアライン)
      if (cursor.s > 0.02) {
        const ring = RADIUS * (0.3 + cursor.s * 0.12);
        ctx.strokeStyle = `rgba(217, 119, 6, ${0.34 * cursor.s})`;
        ctx.lineWidth = 1;
        ctx.beginPath();
        ctx.arc(cursor.x, cursor.y, ring, 0, TAU);
        ctx.stroke();
        ctx.beginPath();
        for (let i = 0; i < 4; i++) {
          const a = (i / 4) * TAU;
          const cos = Math.cos(a);
          const sin = Math.sin(a);
          ctx.moveTo(cursor.x + cos * (ring + 4), cursor.y + sin * (ring + 4));
          ctx.lineTo(cursor.x + cos * (ring + 10), cursor.y + sin * (ring + 10));
        }
        ctx.stroke();
      }

      // 静止中の粒子は1パス・1 fillStyle でまとめて塗る
      const active: Particle[] = [];
      ctx.fillStyle = "rgba(34, 30, 25, 0.28)";
      ctx.beginPath();
      for (const p of points) {
        if (p.t >= 0.03) {
          active.push(p);
          continue;
        }
        ctx.moveTo(p.x + 1.25, p.y);
        ctx.arc(p.x, p.y, 1.25, 0, TAU);
      }
      ctx.fill();

      // 影響圏の粒子だけアンバーへ寄せ、半径も持ち上げる
      for (const p of active) {
        const t = p.t;
        ctx.fillStyle = `rgba(217, 119, 6, ${0.28 + t * 0.62})`;
        ctx.beginPath();
        ctx.arc(p.x, p.y, 1.2 + t * 2.3, 0, TAU);
        ctx.fill();
      }
    };

    const step = (dtScale: number) => {
      // 実ポインタが止まって IDLE_MS 経てば Lissajous の仮想ポインタへ引き継ぐ
      const idle = !target.live || clock - target.at > IDLE_MS;
      let tx = target.x;
      let ty = target.y;
      let ts = 1;
      if (idle) {
        const a = clock * 0.00022;
        tx = w * (0.5 + 0.34 * Math.sin(a));
        ty = h * (0.5 + 0.28 * Math.sin(a * 1.61 + 1.1));
        ts = 0.72;
      }
      cursor.x += (tx - cursor.x) * Math.min(1, 0.14 * dtScale);
      cursor.y += (ty - cursor.y) * Math.min(1, 0.14 * dtScale);
      cursor.s += (ts - cursor.s) * Math.min(1, 0.05 * dtScale);
      pulse *= Math.pow(0.93, dtScale);

      const strength = cursor.s * (1 + pulse);
      const damp = Math.pow(DAMP, dtScale);

      for (const p of points) {
        // ホーム自体をごく浅く漂わせ、無操作でも静止画に見えないようにする
        const hx = p.hx + Math.sin(clock * 0.0004 + p.ph) * 1.6;
        const hy = p.hy + Math.cos(clock * 0.0005 + p.ph) * 1.6;

        let t = 0;
        const dx = p.x - cursor.x;
        const dy = p.y - cursor.y;
        const d2 = dx * dx + dy * dy;
        if (d2 < RADIUS * RADIUS) {
          const d = Math.sqrt(d2) || 0.0001;
          t = (1 - d / RADIUS) * strength;
          const f = t * t * PUSH;
          p.vx += (dx / d) * f * dtScale;
          p.vy += (dy / d) * f * dtScale;
        }
        p.t = Math.min(1, t);

        p.vx += (hx - p.x) * SPRING * dtScale;
        p.vy += (hy - p.y) * SPRING * dtScale;
        p.vx *= damp;
        p.vy *= damp;
        p.x += p.vx * dtScale;
        p.y += p.vy * dtScale;
      }
    };

    const frame = (time: number) => {
      if (!last) last = time;
      const dt = Math.min(50, time - last);
      last = time;
      clock += dt;
      step(dt / 16.6667);
      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 / 停止中のリサイズ用)
    const drawStatic = () => {
      for (const p of points) {
        p.x = p.hx;
        p.y = p.hy;
        p.vx = 0;
        p.vy = 0;
        p.t = 0;
      }
      cursor.s = 0;
      draw();
    };

    const ro = new ResizeObserver((entries) => {
      // contentRect はレイアウト寸法(CSS transform の影響を受けない)
      const box = entries[0].contentRect;
      w = Math.max(1, Math.round(box.width));
      h = Math.max(1, Math.round(box.height));
      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);
      build();
      cursor.x = w / 2;
      cursor.y = h / 2;
      if (!raf) drawStatic();
    });
    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, clientY: number) => {
      const rect = stage.getBoundingClientRect();
      if (!rect.width || !rect.height) return;
      // 変形後の矩形 → 論理座標へ比率で換算(親に scale が掛かっていても合う)
      target.x = ((clientX - rect.left) / rect.width) * w;
      target.y = ((clientY - rect.top) / rect.height) * h;
      target.live = true;
      target.at = clock;
    };

    const onMove = (e: PointerEvent) => {
      toLocal(e.clientX, e.clientY);
      if (!firstMove) {
        firstMove = true;
        setTouched(true);
      }
    };
    const onDown = (e: PointerEvent) => {
      toLocal(e.clientX, e.clientY);
      pulse = 0.9;
      if (!firstMove) {
        firstMove = true;
        setTouched(true);
      }
    };
    const onLeave = () => {
      target.live = false;
    };
    const onMotionChange = () => {
      if (mql.matches) {
        stop();
        drawStatic();
      } else {
        start();
      }
    };

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

    if (mql.matches) drawStatic();
    else start();

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

  // Tailwind は動的な [transition-delay:*] を静的解析できないので delay は style で渡す
  const revealClass = `transition-[opacity,transform] duration-700 ease-out motion-reduce:transition-none ${
    inView
      ? "translate-y-0 opacity-100"
      : "translate-y-4 opacity-0 motion-reduce:translate-y-0 motion-reduce:opacity-100"
  }`;
  const delay = (ms: number) => ({ transitionDelay: `${ms}ms` });

  const steps = [
    { no: "01", label: "設計", body: "情報の順番から決める" },
    { no: "02", label: "実装", body: "軽く、壊れない" },
    { no: "03", label: "運用", body: "公開したあとも伴走" },
  ];

  return (
    <div
      ref={stageRef}
      className="relative w-full max-w-4xl overflow-hidden rounded-2xl border border-gray-200 bg-white"
    >
      <canvas ref={canvasRef} aria-hidden="true" className="absolute inset-0 block h-full w-full" />
      {/* 文字の背後だけ粒子を白へ沈める。右側は洗わず、方眼が見える"生きた余白"として残す */}
      <div
        aria-hidden="true"
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(100%_88%_at_10%_46%,rgba(255,255,255,0.96)_0%,rgba(255,255,255,0.76)_42%,rgba(255,255,255,0)_100%)] sm:bg-[radial-gradient(70%_86%_at_4%_46%,rgba(255,255,255,0.97)_0%,rgba(255,255,255,0.78)_45%,rgba(255,255,255,0)_100%)]"
      />

      <div className="relative flex min-h-[440px] flex-col justify-center px-7 py-14 sm:min-h-[520px] sm:px-12 sm:py-20">
        <p
          style={delay(0)}
          className={`${revealClass} text-[11px] font-bold tracking-[0.25em] text-amber-600 uppercase`}
        >
          First CH Studio
        </p>
        <Heading
          style={delay(120)}
          className={`${montserrat.className} ${revealClass} mt-5 text-[clamp(2.3rem,6.4vw,4rem)] leading-[1.05] font-bold tracking-tight text-gray-900`}
        >
          Space responds.
        </Heading>
        <p
          style={delay(240)}
          className={`${revealClass} mt-6 max-w-md text-[15px] leading-[1.95] font-medium tracking-[0.03em] text-gray-700 sm:text-[16px]`}
        >
          見た瞬間に、手を動かしたくなるか。First CH は、触れて確かめられるWebをつくります。
        </p>

        <div style={delay(360)} className={`${revealClass} mt-10 max-w-lg border-t border-gray-200 pt-5`}>
          <dl className="flex flex-col gap-3 sm:flex-row sm:gap-0">
            {steps.map((s, i) => (
              <div
                key={s.no}
                className={`flex items-baseline gap-3 sm:flex-1 sm:flex-col sm:items-start sm:gap-1.5 ${
                  i > 0 ? "sm:border-l sm:border-gray-200 sm:pl-5" : ""
                }`}
              >
                <dt className="flex items-baseline gap-2">
                  <span className="font-mono text-[10px] font-bold tracking-[0.18em] text-amber-600 tabular-nums">
                    {s.no}
                  </span>
                  <span className="text-[13px] font-bold tracking-[0.12em] text-gray-900">
                    {s.label}
                  </span>
                </dt>
                <dd className="text-[12px] leading-relaxed text-gray-500">{s.body}</dd>
              </div>
            ))}
          </dl>
        </div>

        <div
          style={delay(480)}
          className={`${revealClass} mt-10 flex flex-wrap items-center gap-x-8 gap-y-4`}
        >
          <a
            href="#"
            className="group inline-flex items-center gap-3 text-sm font-semibold text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
          >
            <span className="relative">
              制作のご相談
              <span className="absolute -bottom-1 left-0 h-px w-full origin-left scale-x-100 bg-gray-900 transition-transform duration-300 ease-out group-hover:scale-x-0 motion-reduce:transition-none" />
              <span className="absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-amber-600 transition-transform delay-150 duration-300 ease-out group-hover:origin-left group-hover:scale-x-100 motion-reduce:transition-none" />
            </span>
            <span
              aria-hidden="true"
              className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1 motion-reduce:transition-none"
            >
              →
            </span>
          </a>
          <a
            href="#"
            className="group inline-flex items-center gap-2 text-sm font-medium text-gray-500 transition-colors hover:text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
          >
            制作実績を見る
            <span
              aria-hidden="true"
              className="inline-block h-px w-5 bg-gray-300 transition-transform duration-300 ease-out group-hover:scale-x-150 group-hover:bg-amber-600 motion-reduce:transition-none"
            />
          </a>
        </div>
      </div>

      {/* 操作ヒント: 一度でも操作したら消える。文言はポインタ種別で純CSS切替 */}
      <p
        aria-hidden="true"
        className={`pointer-events-none absolute right-5 bottom-5 flex items-center gap-2 text-[10px] font-medium tracking-[0.18em] text-gray-400 uppercase transition-opacity duration-500 motion-reduce:transition-none sm:right-8 sm:bottom-7 ${
          touched ? "opacity-0" : "opacity-100"
        }`}
      >
        <span className="inline-block h-1.5 w-1.5 rounded-full bg-amber-600" />
        <span className="[@media(pointer:coarse)]:hidden">Move your cursor</span>
        <span className="hidden [@media(pointer:coarse)]:inline">Touch the field</span>
      </p>
    </div>
  );
}

Add to your project via shadcn CLI

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