本文へスキップ
HELLO!
HIGASHIYUKI®.COM
0   /   100
ヒーロー

ヒーローの背景でノイズの流れに沿って無数の光の線が流れる

デモ

仕様

用途
ヒーロー
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

無数の粒を、ノイズから作った流れ場に沿って右へ流し続けています。ノイズの値をそのまま向きとして使うと、線が数本の太い筋に吸い込まれてしまうため、ノイズの勾配を 90 度回した向き(カールノイズ)を使い、画面全体に線を行き渡らせています。

粒は画面の外に出ると位置と尾の軌跡をリセットし、また左側から生まれ直します。尾は直近の位置をいくつか覚えておくだけで描けるので、粒の数が多くても軽く動かせます。

描画の負荷を抑えるため、要素が画面の外に出たら描画そのものを止めています。動きを減らす設定のときは、粒を流さずに 1 回だけ描き、線が止まった静止画として見えるようにしています。

粒の数や流れる速さ、色を変えると、印象を静かにもにぎやかにも調整できます。暗い背景と相性がよく、データや AI 系サービスなど、テクノロジーを感じさせたいヒーローに向いています。

コード

HTML
<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>

CSS
.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; }

JavaScript
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();
  };
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。