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

ヒーローの背景の暗がりで、発光する色がにじみ合いながら漂う

デモ

仕様

用途
ヒーロー
動作
WebGL
種類
シェーダー
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

全画面の板に、フラグメントシェーダーを 1 枚かけた背景です。黒に近い地の上に、くすみローズ・ラベンダーグレー・スレート・墨のスレートの 4 つの光の中心を置き、画面の各点で、中心からの距離に応じた重み(ガウス)を出します。中心は周波数を整数にした円運動で動かし、座標のゆがみも、ノイズを引く位置を円周に沿って回して作るので、10 秒でひと回りして元の見た目に戻ります。

色と明るさを分けて決めるのが要です。色は 4 つの重みで平均した色相、明るさは 1 − exp(−重みの合計 × 1.5) で決め、地の色から光の色へ混ぜます。光を足し算で重ねると、重なった所が白く飛びます。この形なら、重なっても明るさは 1 に近づくだけで、色相だけがなめらかに混ざります。明るい芯には同じ色を少し足して、発光しているように見せています。4 色はローズ・ラベンダー・スレートの淡い 3 色に暗い墨のスレートを 1 つ混ぜたもので、色相より明るさの差で奥行きを出しています。

暗い地のなめらかなグラデーションは、色の段差が縞(バンディング)になって見えやすくなります。gl_FragCoord から作った ±0.5 階調の細かな揺らぎを色に足して、縞を散らしています。また、座標をゆがめる量を 0 にすると、光がただの丸いぼかしに戻ります。高さの 0.3 倍ほどゆがめると、輪郭がにじみ合う有機的な形になります。

4 色と地の色は CSS 変数で変えられます。光の強さを上げると明るい面が広がり、下げると暗がりが増えます。文字を載せる側には黒の幕を重ねて、白い見出しが読めるようにしています。動きを減らす設定では 1 枚だけ描いて止め、画面の外にあるあいだは描き直しを止めます。SaaS やプロダクト、テック系のサービスの暗いヒーローに向いています。

コード

HTML
<section class="mesh-hero">
  <!-- 背景は飾りなので、読み上げからは外す -->
  <canvas class="mesh-hero__canvas" aria-hidden="true"></canvas>
  <div class="mesh-hero__inner">
    <p class="mesh-hero__eyebrow">Beta</p>
    <h1 class="mesh-hero__title">すべての数字を、<br>ひとつの画面で。</h1>
    <p class="mesh-hero__lead">ここに説明文が入ります。</p>
    <a class="mesh-hero__button" href="/signup/">無料で試してみる</a>
  </div>
</section>

CSS
.mesh-hero {
  /* 地の色と、にじませる 4 色。script.js がこの 5 つを読んでシェーダーへ渡すので、#rrggbb で書く */
  --base: #0e0f11;
  --rose: #e48b85; /* ローズ */
  --lavender: #aa95da; /* ラベンダー */
  --slate: #87b4d5; /* スレート */
  --slate-deep: #3a5f77; /* 墨のスレート。暗い 1 色を混ぜ、明るさの差で奥行きを出す */
  --ink: #ffffff;
  --soft: #eeece6;
  --mute: #dedcd6;
  --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);
  /* 読み込み中と、WebGL が使えないときの地 */
  background-color: var(--base);
}

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

/* 文字の側だけ地を暗く落とす。左の光はローズが明るく、白い文字が沈む */
.mesh-hero::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(100deg, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.4) 45%, transparent 75%);
  pointer-events: none;
}

.mesh-hero__inner {
  position: relative;
  z-index: 1; /* 幕(::after)より前に出す */
  max-width: 32em;
}

.mesh-hero__eyebrow {
  margin: 0;
  color: var(--mute);
  font-size: 13px;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
}

.mesh-hero__title {
  margin: 24px 0 0;
  font-size: clamp(36px, 8vw, 64px);
  line-height: 1.05;
  letter-spacing: -0.02em;
}

.mesh-hero__lead {
  margin: 24px 0 0;
  color: var(--soft);
  font-size: 18px;
  line-height: 1.8;
  word-break: auto-phrase; /* 和文を文節の切れ目で折り返す(対応していないブラウザでは、ふつうに折り返す) */
}

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

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

/* 幅が狭いと、横に流す幕では文字の下が明るいまま残る。一様に落とす */
@media (max-width: 720px) {
  .mesh-hero::after { background: rgba(0, 0, 0, 0.5); }
}

JavaScript
import * as THREE from "three";

const DURATION = 10; // 光の中心とゆがみがひと回りして、同じ絵に戻るまで(秒)
const GLOW = 1.5; // 光の強さ。上げると明るい面が広がり、下げると暗がりが増える
const WARP = 0.3; // ノイズで座標をゆがめる量。0 にすると丸い光の塊になる
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

const vertexShader = /* glsl */ `
  varying vec2 vUv;
  void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }
`;

const fragmentShader = /* glsl */ `
  precision highp float;
  uniform vec2 uResolution;
  uniform float uPhase, uGlow, uWarp;
  uniform vec3 uBase;
  uniform vec3 uColors[4];
  varying vec2 vUv;

  vec2 hash2(vec2 p) {
    p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));
    return -1.0 + 2.0 * fract(sin(p) * 43758.5453123);
  }
  // グラディエントノイズ(-1..1)
  float noise(vec2 p) {
    vec2 i = floor(p), f = fract(p), u = f * f * (3.0 - 2.0 * f);
    return mix(mix(dot(hash2(i), f), dot(hash2(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0)), u.x),
      mix(dot(hash2(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)), dot(hash2(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0)), u.x), u.y);
  }
  // 中心 c からの距離で決まる重み(ガウス)。r2 が大きいほど光が広がる
  float weight(vec2 q, vec2 c, float r2) { vec2 d = q - c; return exp(-dot(d, d) / r2); }

  void main() {
    vec2 p = (vUv - 0.5) * vec2(uResolution.x / uResolution.y, 1.0);
    float a = uPhase;
    // 座標をノイズでゆがめて、光の輪郭を有機的にする。ノイズを引く位置は円周上を 1 周する
    vec2 loop = vec2(cos(a), sin(a)) * 0.7;
    vec2 q = p + vec2(noise(p * 1.3 + loop), noise(p * 1.3 - loop.yx + 7.3)) * uWarp;
    // 色の中心点。時間の係数(a の倍数)は整数にしてループをつなぐ
    float w0 = weight(q, vec2(-0.52 + 0.22 * sin(a), -0.12 + 0.16 * cos(a * 2.0)), 0.1);
    float w1 = weight(q, vec2(0.48 + 0.2 * cos(a), 0.16 + 0.14 * sin(a * 2.0 + 1.0)), 0.09);
    float w2 = weight(q, vec2(0.04 + 0.36 * sin(a * 2.0 + 2.0), -0.3 + 0.12 * cos(a + 0.5)), 0.08);
    float w3 = weight(q, vec2(-0.08 + 0.3 * cos(a + 3.0), 0.34 + 0.1 * sin(a * 3.0)), 0.07);
    float sum = w0 + w1 + w2 + w3;
    // 色は重みの平均(重なると色相が混ざる)、明るさは重みの合計から決める。足し算しないので白く飛ばない
    vec3 hue = (uColors[0] * w0 + uColors[1] * w1 + uColors[2] * w2 + uColors[3] * w3) / max(sum, 1e-4);
    float light = 1.0 - exp(-sum * uGlow);
    vec3 color = mix(uBase, hue, light);
    color += hue * pow(light, 3.0) * 0.22; // 明るい芯を少し持ち上げて、発光しているように見せる
    color = mix(color, uBase, smoothstep(0.55, 1.05, length(p * vec2(0.75, 1.0))) * 0.6); // 四隅を地の色へ沈める
    // 暗い地のグラデーションは段差(バンディング)が出やすいので、細かい揺らぎで散らす
    color += (fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453) - 0.5) / 255.0;
    gl_FragColor = vec4(color, 1.0);
  }
`;

document.querySelectorAll(".mesh-hero").forEach((hero) => {
  const css = (name) => getComputedStyle(hero).getPropertyValue(name).trim();
  // 色は CSS 変数から読む。THREE.Color は線形に直すので、hex を 255 で割った値をそのまま渡す
  const rgb = (name) => { const hex = Number.parseInt(css(name).slice(1), 16); return new THREE.Vector3((hex >> 16) / 255, ((hex >> 8) & 255) / 255, (hex & 255) / 255); };
  const uniforms = {
    uPhase: { value: 0 }, uResolution: { value: new THREE.Vector2(1, 1) }, uGlow: { value: GLOW }, uWarp: { value: WARP },
    uBase: { value: rgb("--base") }, uColors: { value: ["--rose", "--lavender", "--slate", "--slate-deep"].map(rgb) },
  };
  const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".mesh-hero__canvas") });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
  const scene = new THREE.Scene();
  const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); // 板は画面ぴったりの 2 × 2。頂点シェーダーがそのまま座標に使う
  const plane = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader }));
  scene.add(plane);

  const draw = (t) => {
    uniforms.uPhase.value = ((t % DURATION) / DURATION) * Math.PI * 2;
    renderer.render(scene, camera);
  };
  const sizes = new ResizeObserver(() => {
    const { clientWidth: w, clientHeight: h } = hero;
    renderer.setSize(w, h, false); // 大きさは CSS が決めるので、style は書き換えない
    uniforms.uResolution.value.set(w, h);
    if (reduceMotion) draw(4); // 動きを減らす設定では、1 枚だけ描いて止める
  });
  const view = new IntersectionObserver(([e]) => renderer.setAnimationLoop(e.isIntersecting && !reduceMotion ? (ms) => draw(ms / 1000) : null)); // 画面の外では回さない
  sizes.observe(hero); view.observe(hero);
  hero.destroy = () => { // PJAX で幕ごと捨てるときに呼ぶ。放っておくと WebGL の文脈が積み上がる
    renderer.setAnimationLoop(null); sizes.disconnect(); view.disconnect();
    plane.geometry.dispose(); plane.material.dispose(); renderer.dispose(); renderer.forceContextLoss();
  };
});

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

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