デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
無数の粒を、ノイズから作った流れ場に沿って右へ流し続けています。ノイズの値をそのまま向きとして使うと、線が数本の太い筋に吸い込まれてしまうため、ノイズの勾配を 90 度回した向き(カールノイズ)を使い、画面全体に線を行き渡らせています。
粒は画面の外に出ると位置と尾の軌跡をリセットし、また左側から生まれ直します。尾は直近の位置をいくつか覚えておくだけで描けるので、粒の数が多くても軽く動かせます。
描画の負荷を抑えるため、要素が画面の外に出たら描画そのものを止めています。動きを減らす設定のときは、粒を流さずに 1 回だけ描き、線が止まった静止画として見えるようにしています。
粒の数や流れる速さ、色を変えると、印象を静かにもにぎやかにも調整できます。暗い背景と相性がよく、データや AI 系サービスなど、テクノロジーを感じさせたいヒーローに向いています。
コード
<section class="flow-hero">
<!-- 背景は飾りなので、読み上げからは外す -->
<canvas class="flow-hero__canvas" aria-hidden="true"></canvas>
<div class="flow-hero__inner">
<p class="flow-hero__eyebrow">Realtime</p>
<h1 class="flow-hero__title">開発の流れを、<br>止めない。</h1>
<p class="flow-hero__lead">ここに説明文が入ります。</p>
<a class="flow-hero__button" href="/trial/">無料ではじめる</a>
</div>
</section>
.flow-hero {
--ink: #ffffff;
--soft: #eeece6;
/* 線の色。script.js はこの名前だけを参照し、値はここでだけ決める */
--line1: #748c9b;
--line2: #93b2c9;
--line3: #aea0cf;
--line4: #d99590;
--line5: #d1c57a;
--line6: #d9ba92;
--gutter: clamp(24px, 7vw, 96px);
position: relative;
display: grid;
align-items: center;
min-height: min(90vh, 720px);
padding: 0 var(--gutter);
overflow: hidden;
color: var(--ink);
/* 読み込み中と、canvas が使えないときの地 */
background: radial-gradient(120% 90% at 70% 45%, #161a1d 0%, #0b0c0d 70%);
}
.flow-hero__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
/* 線の上・文字の下に置く暗い幕。文字のある左ほど濃くする */
.flow-hero::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgba(11, 12, 13, 0.72) 0%, rgba(11, 12, 13, 0.38) 55%, rgba(11, 12, 13, 0.2) 100%);
}
.flow-hero__inner {
position: relative;
z-index: 1; /* 幕(::after)より前に出す */
max-width: 34em;
}
.flow-hero__eyebrow {
margin: 0;
color: var(--soft);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.flow-hero__title {
margin: 24px 0 0;
font-size: clamp(40px, 8vw, 72px);
line-height: 1.05;
letter-spacing: -0.02em;
}
.flow-hero__lead {
margin: 24px 0 0;
font-size: 18px;
line-height: 1.8;
color: var(--soft);
}
.flow-hero__button {
display: inline-grid;
place-items: center;
height: 48px;
margin-top: 32px;
padding: 0 32px;
border-radius: 999px;
background-color: var(--ink);
color: #111111;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
}
.flow-hero__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
import { gsap } from "gsap";
const COUNT = 420; // 粒の数
const TRAIL = 16; // 尾として描く、最後の点の数
const SPEED = 70; // 流れる速さの目安(px/秒)
const SCALE = 220; // 流れ場の模様の細かさ(px)。小さいほど渦が細かい
const DRIFT = 0.35; // 全体を右へ流す成分(渦の速さを 1 とした比)
const WOBBLE = 12; // 流れ場がゆっくり変わる周期(秒)
// 色は CSS 変数の名前だけをここに持ち、値は style.css の --line1 などで決める
const PALETTE_VARS = ["--line1", "--line2", "--line3", "--line4", "--line5", "--line6"];
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 格子点をハッシュして補間するだけの簡易ノイズ(-値も出るよう 0.5 を引く)
function hash(x, y) {
const s = Math.sin(x * 127.1 + y * 311.7) * 43758.5453; return s - Math.floor(s) - 0.5; // 負の値も出す
}
function noise(x, y) {
const xi = Math.floor(x), yi = Math.floor(y), xf = x - xi, yf = y - yi;
const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf);
const a = hash(xi, yi), b = hash(xi + 1, yi), c = hash(xi, yi + 1), d = hash(xi + 1, yi + 1);
return a + (b - a) * u + (c - a) * v + (a - b - c + d) * u * v;
}
// カールノイズ(ノイズの勾配を 90° 回した向き)。値をそのまま角度にすると線が数本の筋に吸い込まれる
function curl(x, y, t) {
const e = 2;
const n1 = noise((x + e) / SCALE, y / SCALE + t), n2 = noise((x - e) / SCALE, y / SCALE + t);
const n3 = noise(x / SCALE, (y + e) / SCALE + t), n4 = noise(x / SCALE, (y - e) / SCALE + t);
return { vx: (n3 - n4) / (2 * e), vy: -(n1 - n2) / (2 * e) };
}
document.querySelectorAll(".flow-hero").forEach((hero) => {
const canvas = hero.querySelector(".flow-hero__canvas");
const g = canvas.getContext("2d");
// canvas の描画は var() を解釈できないので、CSS 変数の値をここで読んでおく
const palette = PALETTE_VARS.map((name) => getComputedStyle(hero).getPropertyValue(name).trim());
let w = 0, h = 0, particles = [], last;
function spawn(p, edge) {
p.x = edge ? -20 : Math.random() * w;
p.y = Math.random() * h;
p.trail = new Float32Array(TRAIL * 2).fill(NaN); p.head = 0;
p.hue = palette[Math.floor(Math.random() * palette.length)];
}
function draw(time) {
const dt = last === undefined || reduceMotion ? 0 : Math.min(time - last, 0.1);
last = time;
g.clearRect(0, 0, w, h);
g.lineCap = "round";
g.lineWidth = 1.4;
g.globalCompositeOperation = "lighter";
g.globalAlpha = 0.55;
for (const p of particles) {
const { vx, vy } = curl(p.x, p.y, time / WOBBLE);
p.x += (vx + DRIFT) * SPEED * dt;
p.y += vy * SPEED * dt;
p.trail[p.head * 2] = p.x;
p.trail[p.head * 2 + 1] = p.y;
p.head = (p.head + 1) % TRAIL;
if (p.x > w + 20 || p.y < -20 || p.y > h + 20) spawn(p, true);
g.strokeStyle = p.hue;
g.beginPath();
let started = false;
for (let i = 0; i < TRAIL; i++) {
const idx = (p.head + i) % TRAIL;
const x = p.trail[idx * 2], y = p.trail[idx * 2 + 1];
if (Number.isNaN(x)) continue;
started ? g.lineTo(x, y) : g.moveTo(x, y);
started = true;
}
g.stroke();
}
g.globalAlpha = 1;
g.globalCompositeOperation = "source-over";
}
const sizes = new ResizeObserver(() => {
const rect = hero.getBoundingClientRect();
w = rect.width; h = rect.height;
const dpr = Math.min(window.devicePixelRatio, 2);
canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr);
g.setTransform(dpr, 0, 0, dpr, 0, 0);
particles = Array.from({ length: COUNT }, () => { const p = {}; spawn(p, false); return p; });
last = undefined;
if (reduceMotion) draw(0); // 動きを減らす設定では、1 枚だけ描いて止める
});
sizes.observe(hero);
if (reduceMotion) return; // 動かす口(gsap.ticker)を足さなければ、resize が描いた 1 枚のまま
// 重い canvas なので、画面外では GSAP の時計ごと止める
gsap.ticker.add(draw);
const view = new IntersectionObserver(([e]) => (e.isIntersecting ? gsap.ticker.wake() : gsap.ticker.sleep()));
view.observe(hero);
hero.destroy = () => { // PJAX で幕ごと捨てるときに呼ぶ
gsap.ticker.remove(draw);
sizes.disconnect();
view.disconnect();
};
});