PGE-04Pages
キャンバス粒子ヒーロー(方眼+バネ物理)
Canvas 2D の粒子をランダムではなく方眼グリッドに並べ、ポインタの斥力で押し出し、フックの法則と減衰でホーム座標へ戻す背景ヒーロー。「整っていたものが乱れて戻る」動きはドット方眼の標本モチーフと噛み合い、近傍の粒子だけアンバー(#d97706)へ寄る。速度と減衰はdtで正規化するため120Hzでも倍速にならず、prefers-reduced-motion・画面外(IntersectionObserver)・タブ非表示の3条件でrAFを止める。無操作が続くとLissajous曲線の仮想ポインタへ引き継ぐのでタッチ環境でも成立する。依存ライブラリゼロ・画像素材ゼロ。Canvasの粒子+ポインタ物理という着想を、コード・文言・配色・構図すべてFirst CHのオリジナルとして再構築した(外部コードの取り込みなし・2026-08-08)。
- 追加日:
- 2026-08-08
- 依存:
- なし
- tags
- #hero #page #canvas #particles #physics #interactive #motion #no-image #no-dependency
プレビュー
"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>
);
}
shadcn CLI でプロジェクトに追加
npx shadcn@latest add https://designs.first-ch.com/r/particle-grid-hero.json