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

散らばった粒が集まってヒーローの見出しの文字になる

デモ

仕様

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

しくみと調整

画面に散らばった粒が、弧を描きながら集まって見出しの文字になる背景です。見えている見出しの実際の位置と書体をもとに、Canvas 2Dで一度だけ文字を描き、文字の上にある点を間隔ごとに採っています。行は、行末の「、」や「。」を数えず、その手前までの字面の左右の端の中央でそろえ、約物は右へぶら下げます。約物まで含めて中央にそろえると、約物の空きの分だけ字が左へ寄って見えます。measureText で約物の手前までの字面を測り、ずれの分だけ行を動かします。

粒は文字の位置へ直線ではなく、進む向きと直角に膨らんだ弧をたどって集まります。左側の粒から先に動き出すので、見出しを左から読むのと同じ順で文字が組み上がって見えます。粒の半分強は見出しと同じ色にし、残りにくすんだ4色を混ぜています。

見出しの文字そのものは画面に残したまま透明にしておき、読み上げには影響しません。粒が集まり終えたらその位置に留まり、動きを減らす設定では粒を出さず、見出しの文字をそのまま表示します。

粒の色の配分や集まる速さを変えるだけで、印象を静かにも賑やかにも調整できます。スタジオやプロダクト、イベントのティザーのように、見出しの登場そのものを見せ場にしたいヒーローに向いています。

コード

HTML
<section class="particles-hero">
  <!-- 粒は飾りなので読み上げから外す。見出しの文字そのものは下に置いて読み上げに残す -->
  <canvas class="particles-hero__canvas" aria-hidden="true"></canvas>
  <p class="particles-hero__eyebrow">DESIGN STUDIO</p>
  <h1 class="particles-hero__title">
    <span class="particles-hero__line">ひらめきを、</span>
    <span class="particles-hero__line">かたちに。</span>
  </h1>
  <p class="particles-hero__lead">ここに説明文が入ります。</p>
  <a class="particles-hero__button" href="/contact/">制作のご相談</a>
</section>

CSS
.particles-hero {
  --bg: #f2f1ed;
  --ink: #262626;
  /* 粒の色(見出しと同じ色を主に、残りに数色を混ぜる) */
  --c1: #262626;
  --c2: #688297; /* スレート */
  --c3: #b27771; /* くすみローズ */
  --c4: #a28767; /* グレージュ */
  --c5: #739374; /* セージ */
  --gutter: clamp(24px, 7vw, 96px);
  position: relative;
  padding: 96px var(--gutter);
  background: var(--bg);
  color: var(--ink);
  text-align: center;
}

.particles-hero__canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  pointer-events: none;
}

.particles-hero.is-forming .particles-hero__canvas { opacity: 1; }
.particles-hero.is-forming .particles-hero__title { opacity: 0; }

.particles-hero__eyebrow {
  margin: 0;
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: color-mix(in srgb, var(--ink) 60%, transparent);
}

.particles-hero__title {
  margin: 24px 0 0;
  font-size: clamp(40px, 10vw, 128px); /* 粒で描く字そのものが見どころなので、見出しの段より大きくする */
  font-weight: 800;
  line-height: 1.05;
  letter-spacing: -0.02em;
}

.particles-hero__line { display: block; }

.particles-hero__lead {
  max-width: 34em;
  margin: 24px auto 0;
  font-size: 18px;
  line-height: 1.8;
  word-break: auto-phrase; /* 狭い画面で、語の途中(「プ/ロダクト」)で折り返さない */
}

.particles-hero__button {
  display: inline-grid;
  place-items: center;
  height: 48px;
  margin-top: 32px;
  padding: 0 32px;
  border-radius: 999px;
  background: var(--ink);
  color: var(--bg);
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-decoration: none;
}

.particles-hero__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

JavaScript
import { gsap } from "gsap";

const GAP = 1 / 25; // 文字の上の点を採る間隔(字の大きさに対する比。128px の字で 5px)
const RADIUS = [0.4, 0.54]; // 粒の半径の範囲(点の間隔に対する比)
const CURVE = 18; // 集まるときの弧の曲がり(px)
const GATHER = { start: 0.2, spread: 0.8, duration: 1.1 }; // 集まり始める時刻・最後の粒までのずれ・1 粒の所要時間
const HOLD_FROM = GATHER.start + GATHER.spread + GATHER.duration;
const WEIGHTS = [0.56, 0.16, 0.14, 0.09, 0.05]; // 5 色の粒の割合(半分強は見出しと同じ色にする)
const TAU = Math.PI * 2;
const gatherEase = gsap.parseEase("power3.inOut");
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

document.querySelectorAll(".particles-hero").forEach((hero) => {
  // 字形が確定してから点を採る。仮の書体の幅で採ると、本物の書体に替わったところで粒と文字がずれる
  document.fonts.ready.then(() => {
    const canvas = hero.querySelector(".particles-hero__canvas");
    const title = hero.querySelector(".particles-hero__title");
    // 粒の色は CSS 変数から読む
    const heroStyle = getComputedStyle(hero);
    const palette = WEIGHTS.map((w, i) => [heroStyle.getPropertyValue(`--c${i + 1}`).trim(), w]);
    const box = hero.getBoundingClientRect();
    const W = Math.round(box.width), H = Math.round(box.height);
    const dpr = Math.min(window.devicePixelRatio, 2);
    Object.assign(canvas, { width: W * dpr, height: H * dpr });
    const g = canvas.getContext("2d", { willReadFrequently: true });
    g.setTransform(dpr, 0, 0, dpr, 0, 0);

    // 見出しの行ごとに、実際の位置と書体・字間で文字を描き、上にある点を採る
    const lineStyle = getComputedStyle(title);
    const gap = Math.max(2.5, parseFloat(lineStyle.fontSize) * GAP);
    Object.assign(g, { fillStyle: "#000", textBaseline: "middle", textAlign: "center", lang: document.documentElement.lang || "ja" });
    Object.assign(g, { font: `${lineStyle.fontWeight} ${lineStyle.fontSize} ${lineStyle.fontFamily}`, letterSpacing: lineStyle.letterSpacing });
    const lines = title.querySelectorAll(".particles-hero__line");
    // 行末の約物(「、」「。」)は数えず、その手前までの字面の中央を行の中央にそろえ、約物は右へぶら下げる。textAlign が center なので、字面の端は約物の手前までの文字列の中央から測られる(ずれは em で持つ)
    for (const line of lines) {
      const full = g.measureText(line.textContent).width, ink = g.measureText(line.textContent.replace(/[、。,.」』)]+$/u, ""));
      line.style.translate = `${(full - ink.width - ink.actualBoundingBoxRight + ink.actualBoundingBoxLeft) / 2 / parseFloat(lineStyle.fontSize)}em`;
    }
    if (reduceMotion) return; // 動きを減らす設定では、見出しの文字をそのまま出す
    for (const line of lines) {
      const rect = line.getBoundingClientRect();
      g.fillText(line.textContent, rect.left - box.left + rect.width / 2, rect.top - box.top + rect.height / 2);
    }
    const { data } = g.getImageData(0, 0, canvas.width, canvas.height);
    const points = [];
    for (let y = gap / 2; y < H; y += gap) {
      for (let x = gap / 2; x < W; x += gap) {
        if (data[(Math.floor(y * dpr) * canvas.width + Math.floor(x * dpr)) * 4 + 3] > 128) points.push([x, y]);
      }
    }

    // 粒ごとの値は組み立てるときに決めて、以後は変えない
    const xs = points.map(([x]) => x);
    const minX = Math.min(...xs);
    const maxX = Math.max(...xs) || 1;
    const particles = points.map(([x, y]) => {
      let pick = Math.random();
      let color = 0;
      while (color < WEIGHTS.length - 1 && pick > WEIGHTS[color]) pick -= WEIGHTS[color++];
      const along = (x - minX) / (maxX - minX || 1);
      return {
        tx: x,
        ty: y,
        sx: -0.1 * W + Math.random() * 1.2 * W,
        sy: -0.1 * H + Math.random() * 1.2 * H,
        curve: (Math.random() - 0.5) * 2 * CURVE,
        gather: GATHER.start + (along * 0.7 + Math.random() * 0.3) * GATHER.spread,
        r: gap * (RADIUS[0] + Math.random() * (RADIUS[1] - RADIUS[0])),
        color,
      };
    });
    const groups = palette.map(([color], i) => ({ color, items: particles.filter((p) => p.color === i) }));

    // 集まり具合 k: 0 = 散らばっている、1 = 文字の位置。直線ではなく弧をたどって集まる
    function draw(t) {
      g.clearRect(0, 0, W, H);
      for (const { color, items } of groups) {
        g.fillStyle = color;
        g.beginPath();
        for (const p of items) {
          const k = gatherEase(Math.min(1, Math.max(0, (t - p.gather) / GATHER.duration)));
          const dx = p.tx - p.sx;
          const dy = p.ty - p.sy;
          const len = Math.hypot(dx, dy) || 1;
          const bulge = Math.sin(Math.PI * k) * p.curve;
          const x = p.sx + dx * k - (dy / len) * bulge;
          const y = p.sy + dy * k + (dx / len) * bulge;
          g.moveTo(x + p.r, y);
          g.arc(x, y, p.r, 0, TAU);
        }
        g.fill();
      }
    }

    hero.classList.add("is-forming");
    const clock = { t: 0 };
    gsap.to(clock, { t: HOLD_FROM, duration: HOLD_FROM, ease: "none", onUpdate: () => draw(clock.t) });
  });
});

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

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