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

見出しが色ずれしながら乱れる(グリッチ)

デモ

仕様

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

しくみと調整

文字を青みのスレートと黄みのグレージュの 2 枚の版に分け、screen の重ね方で 1 枚に見せます。2 色は補い合う色なので、そろっていればほぼ文字の色に戻り、乱れているコマだけ 2 枚を逆向きにずらすと、縁にくすんだ青と砂色がにじみます。各色の版は横帯に切ってあり、帯ごとに横へ裂くと、壊れた画面のような見た目になります。

乱れ方はすべて、帯や色の番号とコマ番号から作った 1 つのハッシュで決めています。Math.random を使わないので、同じ時刻なら毎回同じ乱れ方になります。静止しているあいだは、切らない 1 枚だけを出して帯の継ぎ目を隠します。

乱れは点滅に近い表現なので、色ずれの幅と入れ替わる速さは控えめにしています。それでも人によってはつらく感じられるため、動きを減らす設定では乱れを完全に止め、一時停止のボタンも添えて、設定を変えなくてもいつでも止められるようにしています。

つまみは乱れる時刻と長さ・入れ替わる速さ・色ずれと裂ける幅・版の 2 色です。版の色を替えるときは、screen で重ねた色が文字の色に近くなる 2 色を選ぶと、静止したときに色が残りません。エラー画面や、テック系イベントのヒーロー見出しに向いています。

コード

HTML
<section class="glitch">
  <div class="glitch__scanlines" aria-hidden="true"></div>
  <p class="glitch__eyebrow">SIGNAL LOST</p>
  <h1 class="glitch__heading" aria-label="接続が切れました">
    <span class="glitch__sizer" aria-hidden="true">接続が切れました</span>
  </h1>
  <p class="glitch__caption">ネットワークに再接続しています…</p>
  <button class="glitch__toggle" type="button" aria-pressed="false" hidden>一時停止</button>
</section>

CSS
.glitch {
  --bg: #111111;
  --ink: #eeece6;
  --mute: #898989;
  --mute-small: #a8a6a0;
  --plate-a: #98b1c4; /* 色の版(スレート)。--plate-b と screen で重ねると、ほぼ --ink になる */
  --plate-b: #d2b999; /* 色の版(グレージュ) */
  position: relative;
  display: grid;
  justify-items: center;
  gap: 24px;
  padding: 96px 6vw;
  overflow: hidden;
  background: var(--bg);
  color: var(--ink);
  text-align: center;
}

.glitch__scanlines {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: repeating-linear-gradient(to bottom, rgb(255 255 255 / 0.035) 0 1px, transparent 1px 4px);
}

.glitch__heading {
  position: relative;
  margin: 0;
  font: 800 clamp(40px, 9vw, 96px)/1 "Avenir Next", "Avenir", "Helvetica Neue", "Hiragino Sans", "Hiragino Kaku Gothic ProN", system-ui, sans-serif;
  letter-spacing: 0.02em;
  white-space: nowrap;
  isolation: isolate; /* 色の版どうしだけを screen で混ぜる */
}

.glitch__sizer { visibility: hidden; }
.glitch__plate { position: absolute; left: 0; top: 0; mix-blend-mode: screen; }
.glitch__plate--a { color: var(--plate-a); }
.glitch__plate--b { color: var(--plate-b); }

.glitch__eyebrow {
  position: relative;
  margin: 0 0 -8px;
  padding-left: 0.14em; /* 字間ぶんを左にも入れて中央をそろえる */
  font-size: 13px;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.14em;
  color: var(--mute-small);
}

.glitch__caption {
  position: relative;
  margin: 0;
  font-size: 18px;
  line-height: 1.8;
  letter-spacing: 0.04em;
  color: var(--mute);
}

.glitch__toggle {
  position: relative;
  height: 40px;
  padding: 0 20px;
  border: 0;
  border-radius: 999px;
  box-shadow: inset 0 0 0 1px var(--ink);
  background: transparent;
  color: var(--ink);
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.04em;
  cursor: pointer;
}

.glitch__toggle[aria-pressed="true"] { background: var(--ink); color: var(--bg); }
.glitch__toggle:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

JavaScript
import { gsap } from "gsap";

const PLATES = ["glitch__plate--a", "glitch__plate--b"]; // 色の版の 2 枚。色は CSS の --plate-a・--plate-b
// デモより入れ替わる速さとずれ幅を落とし、光過敏の人にも配慮している
const FPS = 12;
const SLICES = 7;
const SPLIT = 0.05; // 色ずれの最大幅(字の大きさに対する割合)
const TEAR = 0.22; // 帯の横ずれの最大幅(同上)
const CYCLE = 3.2; // 乱れが繰り返す長さ(秒)
const BURSTS = [
  [0.5, 0.45],
  [1.55, 0.15],
  [2.2, 0.7],
];
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// (a, b) から 0〜1 の値を決める整数ハッシュ。同じ引数なら常に同じ値
function hash(a, b) {
  let h = Math.imul(a + 1, 374761393) ^ Math.imul(b + 1, 668265263);
  h = Math.imul(h ^ (h >>> 13), 1274126177);
  return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}

// 乱れの強さ(0 = 静止、1 = 最大)。区間の中で立ち上がりの速い山にする
function intensity(t) {
  for (const [at, length] of BURSTS) {
    if (t >= at && t < at + length) return Math.sqrt(Math.sin((Math.PI * (t - at)) / length));
  }
  return 0;
}

document.fonts.ready.then(() => {
  document.querySelectorAll(".glitch__heading").forEach((heading) => {
    const text = heading.getAttribute("aria-label"); // 見出しの文字は HTML の aria-label から読む
    const size = parseFloat(getComputedStyle(heading).fontSize);
    const layers = PLATES.map((plate) =>
      Array.from({ length: SLICES }, () => {
        const slice = document.createElement("span");
        slice.textContent = text;
        slice.className = `glitch__plate ${plate}`;
        slice.setAttribute("aria-hidden", "true");
        heading.append(slice);
        return slice;
      })
    );
    const height = heading.offsetHeight;

    function render(frame, amount) {
      if (amount <= 0) {
        heading.style.transform = "";
        for (const slices of layers) {
          slices.forEach((s, k) => {
            s.style.visibility = k === 0 ? "visible" : "hidden";
            s.style.clipPath = "none";
            s.style.transform = "";
          });
        }
        return;
      }
      const cuts = [0, height];
      for (let k = 1; k < SLICES; k++) cuts.push(Math.round(hash(100 + k, frame) * height));
      cuts.sort((a, b) => a - b);
      const tears = Array.from({ length: SLICES }, (_, k) => (hash(k, frame) < 0.5 * amount ? (hash(k + 20, frame) * 2 - 1) * TEAR * size * amount : 0));
      const dir = hash(50, frame) < 0.5 ? 1 : -1;
      const split = (0.35 + 0.65 * hash(51, frame)) * SPLIT * size * amount * dir;
      const shifts = [split, -split];
      layers.forEach((slices, c) =>
        slices.forEach((slice, k) => {
          slice.style.visibility = "visible";
          slice.style.clipPath = `inset(${cuts[k]}px -10% ${height - cuts[k + 1]}px -10%)`;
          slice.style.transform = `translateX(${(shifts[c] + tears[k]).toFixed(1)}px)`;
        })
      );
      const skew = hash(60, frame) < 0.3 * amount ? (hash(61, frame) * 2 - 1) * 14 * amount : 0;
      heading.style.transform = skew ? `skewX(${skew.toFixed(1)}deg)` : "";
    }

    if (reduceMotion) {
      render(0, 0); // 光過敏に配慮し、動きを減らす設定では完全に静止させる
      return;
    }

    const section = heading.closest(".glitch");
    const toggle = section.querySelector(".glitch__toggle");
    let paused = false;
    toggle.hidden = false;
    toggle.addEventListener("click", () => {
      paused = !paused;
      toggle.setAttribute("aria-pressed", String(paused));
      if (paused) render(0, 0);
    });

    gsap.ticker.add((time) => {
      if (paused) return;
      const t = time % CYCLE;
      render(Math.floor(t * FPS), intensity(t));
    });
  });
});

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

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