デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
文字を青みのスレートと黄みのグレージュの 2 枚の版に分け、screen の重ね方で 1 枚に見せます。2 色は補い合う色なので、そろっていればほぼ文字の色に戻り、乱れているコマだけ 2 枚を逆向きにずらすと、縁にくすんだ青と砂色がにじみます。各色の版は横帯に切ってあり、帯ごとに横へ裂くと、壊れた画面のような見た目になります。
乱れ方はすべて、帯や色の番号とコマ番号から作った 1 つのハッシュで決めています。Math.random を使わないので、同じ時刻なら毎回同じ乱れ方になります。静止しているあいだは、切らない 1 枚だけを出して帯の継ぎ目を隠します。
乱れは点滅に近い表現なので、色ずれの幅と入れ替わる速さは控えめにしています。それでも人によってはつらく感じられるため、動きを減らす設定では乱れを完全に止め、一時停止のボタンも添えて、設定を変えなくてもいつでも止められるようにしています。
つまみは乱れる時刻と長さ・入れ替わる速さ・色ずれと裂ける幅・版の 2 色です。版の色を替えるときは、screen で重ねた色が文字の色に近くなる 2 色を選ぶと、静止したときに色が残りません。エラー画面や、テック系イベントのヒーロー見出しに向いています。
コード
<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>
.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; }
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));
});
});
});