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

ヒーローの背景で網点の大きさが波のように膨らんでは引く

デモ

仕様

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

しくみと調整

Canvas 2D に、網角を変えた2つの格子で点を並べ、半径を波の位相から決める背景です。点の半径は「濃さの平方根 × 最大半径」で決まるので、点の面積が濃さに比例します。墨のスレートの版は中心から広がる波紋、ローズの版は斜めに進む波です。2色は明るさを離して版を見分けやすくし、2枚を multiply で重ねると、刷り重なったところが濃くなります。

点の間隔は14px、点の半径は間隔の0.66倍までです。波が一巡する秒数は8秒で、1ループで進む波の数を整数にしているので、繰り返しても継ぎ目が出ません。左側は見出しのために薄く空け、幅の30〜75%のあたりで濃さがそろうようにしています。

canvas は devicePixelRatio に合わせて大きさを決め、コンテナの大きさが変わるたびに点の並びを作り直します。動きを減らす設定では波を止め、最初の1コマだけを描いて動かしません。

版の色と角度、点の間隔を変えるだけで、印刷物のような質感を保ったまま雰囲気を変えられます。印刷・出版・ファッション・音楽イベントなど、紙めいた手触りを出したいヒーローの背景に向いています。

コード

HTML
<section class="halftone-hero">
  <!-- 背景は飾りなので、読み上げからは外す -->
  <canvas class="halftone-hero__canvas" aria-hidden="true"></canvas>
  <div class="halftone-hero__inner">
    <p class="halftone-hero__eyebrow">PRINT STUDIO</p>
    <h1 class="halftone-hero__title">刷り重ねる、<br>色の手ざわり。</h1>
    <p class="halftone-hero__lead">ここに説明文が入ります。</p>
    <a class="halftone-hero__button" href="/shop/">ショップを見る</a>
  </div>
</section>

CSS
.halftone-hero {
  --bg: #f2f1ed;  /* 網点を刷る紙の色 */
  --ink: #262626;
  --plate-1: #3a5f77; /* 版 1(波紋)の色。墨のスレート */
  --plate-2: #ca6259; /* 版 2(斜めの波)の色。ローズ */
  --gutter: clamp(24px, 7vw, 96px);
  --dots: side; /* 網点を置く側。side は字の右、below は字の下(狭い画面) */
  position: relative;
  box-sizing: border-box;
  display: grid;
  align-items: center;
  min-height: min(90vh, 640px);
  padding: 0 var(--gutter);
  overflow: hidden;
  color: var(--ink);
  background-color: var(--bg);
}

/* 縦長や狭い画面では、字を上に寄せ、網点は字の下から右下へ広げる */
@media (max-width: 720px), (orientation: portrait) {
  .halftone-hero {
    --dots: below;
    align-items: start;
    padding-top: clamp(56px, 12vw, 96px);
    padding-bottom: 160px;
  }
}

.halftone-hero__canvas {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}

.halftone-hero__inner {
  position: relative; /* 網点の上に載せる */
  max-width: 30em;
}

.halftone-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);
}

.halftone-hero__title {
  margin: 24px 0 0;
  font-size: clamp(36px, 7vw, 60px);
  font-weight: 800;
  line-height: 1.05;
  letter-spacing: -0.02em;
}

.halftone-hero__lead {
  margin: 24px 0 0;
  font-size: 18px;
  line-height: 1.8;
  word-break: auto-phrase; /* 語の途中で折り返さない */
}

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

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

JavaScript
import { gsap } from "gsap";

const CELL = 14; // 網点の間隔(px)
const MAX_RADIUS = 0.66; // 濃さ 1 の点の半径(間隔に対する比)
const DURATION = 8; // 波がひと巡りする秒数
const FADE = [0.3, 0.75]; // 網点が現れ始める位置と、濃さがそろう位置(幅に対する比)
const BELOW = { gap: 24, tilt: 0.5 }; // 狭い画面: 字の下から空ける間(px)と、右下へ寄せる傾き
// angle: 網角(度)/ mode: "ring"(中心から広がる波紋)| "line"(斜めに進む波)。色は CSS 変数から読む
const PLATES = [
  { angle: 15, mode: "ring", cx: 0.82, cy: 0.55, length: 0.3, laps: 2 },
  { angle: 75, mode: "line", dir: -32, length: 0.6, laps: 1 },
];
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const smoothstep = (a, b, v) => {
  const k = Math.min(1, Math.max(0, (v - a) / (b - a)));
  return k * k * (3 - 2 * k);
};

document.querySelectorAll(".halftone-hero").forEach((hero) => {
  const canvas = hero.querySelector(".halftone-hero__canvas");
  const inner = hero.querySelector(".halftone-hero__inner");
  const style = getComputedStyle(hero);
  const bg = style.getPropertyValue("--bg").trim();
  // 版の色は CSS 変数から読む(PLATES の並び順に対応させる)
  const colors = [style.getPropertyValue("--plate-1").trim(), style.getPropertyValue("--plate-2").trim()];
  const g = canvas.getContext("2d");
  let W, H, plates;

  // 版ごとに、網角だけ回した格子の点の位置を作り直す(大きさが変わるたび)
  function layout() {
    W = hero.clientWidth;
    H = hero.clientHeight;
    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);
    const reach = Math.ceil(Math.hypot(W, H) / 2 / CELL) + 1;
    // 字の下に置くときは、字の箱の下端から測る(字の後ろに網点を入れない)
    const below = style.getPropertyValue("--dots").trim() === "below";
    const edge = inner.getBoundingClientRect().bottom - hero.getBoundingClientRect().top + BELOW.gap;
    const ramp = Math.max(80, (H - edge) / 2);
    const fade = (x, y) =>
      below ? smoothstep(edge, edge + ramp, y + (x - W) * BELOW.tilt) : smoothstep(FADE[0] * W, FADE[1] * W, x);
    plates = PLATES.map((plate, index) => {
      const cos = Math.cos((plate.angle * Math.PI) / 180);
      const sin = Math.sin((plate.angle * Math.PI) / 180);
      const dx = Math.cos(((plate.dir ?? 0) * Math.PI) / 180);
      const dy = Math.sin(((plate.dir ?? 0) * Math.PI) / 180);
      const cy = below ? edge + (H - edge) * 0.7 : plate.cy * H;
      const dots = [];
      for (let i = -reach; i <= reach; i++) {
        for (let j = -reach; j <= reach; j++) {
          const x = W / 2 + (i * cos - j * sin) * CELL;
          const y = H / 2 + (i * sin + j * cos) * CELL;
          if (x < -CELL || x > W + CELL || y < -CELL || y > H + CELL) continue;
          const cap = fade(x, y);
          if (cap <= 0.01) continue;
          const distance = plate.mode === "ring" ? Math.hypot(x - plate.cx * W, y - cy) : x * dx + y * dy;
          dots.push(x, y, distance / (plate.length * H), cap);
        }
      }
      return { ...plate, color: colors[index], dots: new Float32Array(dots) };
    });
    draw(reduceMotion ? 0 : gsap.ticker.time % DURATION);
  }

  // 2 版を multiply で重ねる(紙の上に刷ったように、重なったところが濃くなる)
  function draw(t) {
    g.fillStyle = bg;
    g.fillRect(0, 0, W, H);
    g.globalCompositeOperation = "multiply";
    const maxR = MAX_RADIUS * CELL;
    for (const plate of plates) {
      const shift = (plate.laps * t) / DURATION;
      const d = plate.dots;
      g.fillStyle = plate.color;
      g.beginPath();
      for (let i = 0; i < d.length; i += 4) {
        const wave = 0.5 + 0.5 * Math.cos(Math.PI * 2 * (d[i + 2] - shift));
        const r = maxR * Math.sqrt(d[i + 3] * (0.1 + 0.9 * wave * wave));
        if (r < 0.4) continue;
        g.moveTo(d[i] + r, d[i + 1]);
        g.arc(d[i], d[i + 1], r, 0, Math.PI * 2);
      }
      g.fill();
    }
    g.globalCompositeOperation = "source-over";
  }

  // 書体が読み込まれて字の箱の高さが変わったときも測り直す
  const observer = new ResizeObserver(layout);
  observer.observe(hero);
  observer.observe(inner);
  layout();

  // 動きを減らす設定では、波を止めて 1 コマだけ見せる
  if (reduceMotion) return;
  gsap.ticker.add((time) => draw(time % DURATION));
});

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

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