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

ヒーローの背景で光沢のあるリボンがうねって流れる

デモ

仕様

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

しくみと調整

リボン 1 本は、長さ方向 240 × 幅方向 10 に刻んだ格子の BufferGeometry です。毎フレーム、中心線(長さ方向に進む正弦波の重ね合わせ)と、接線に直交する 2 つの向きをねじれの角度で回した幅の向きから、頂点を計算し直します。形が毎回変わるので、computeVertexNormals で陰影の向きも作り直します。

うねりとねじれの時間の係数を整数にしているので、1 ループ(12 秒)でちょうど元の形に戻ります。リボンは画面の幅より長くし、両端を細くして画面の外へ出しているので、端の切れ目は見えません。横長の画面ではカメラを寄せ、縦長の画面では引いて、うねりが画面に収まるようにしています。

素材は MeshPhysicalMaterial で、クリアコートと布のような光沢(sheen)を重ね、頂点ごとの色で長さ方向のグラデーションを付けます。色はくすんだ中間色どうしでつなぎ、奥のリボンほど暗く、手前ほど明るくして、色相ではなく明るさの差で奥行きを見せています。つやは、暗い部屋に細長い照明を 3 本置いたスタジオを PMREM でぼかして映したものです。小さな照明が多い部屋(RoomEnvironment)を映すと、なめらかな曲面ではつやが水玉のように散ります。細長い照明なら、曲面に沿った長い筋になります。

地は CSS のグラデーションで描き、canvas は透明にして重ねています。リボン 3 本の色(左端・中央・右端)と、後ろから当てる光の色は CSS の変数で変えられます。帯の位置・幅・振れ幅・波の数・ねじれの回数を変えると、印象が大きく変わります。コスメ・ファッション・音楽など、華やかさを出したいヒーローに向いています。

コード

HTML
<section class="ribbon-hero">
  <!-- 背景は飾りなので、読み上げからは外す -->
  <canvas class="ribbon-hero__canvas" aria-hidden="true"></canvas>
  <div class="ribbon-hero__inner">
    <p class="ribbon-hero__eyebrow">SPRING 2027</p>
    <h1 class="ribbon-hero__title">光をまとう、<br>6つの色。</h1>
    <p class="ribbon-hero__lead">ここに説明文が入ります。</p>
    <a class="ribbon-hero__button" href="/collection/">コレクションを見る</a>
  </div>
</section>

CSS
.ribbon-hero {
  /* リボンごとの色(左端・中央・右端)。script.js が読んで、長さ方向のグラデーションにする。奥ほど暗い段、手前ほど明るい段 */
  --ribbon-a: #d2b999 #d29b97 #b0a5ca; /* 中ほど: グレージュ → くすみローズ → ラベンダーグレー */
  --ribbon-b: #4a5b66 #688297 #877fa1; /* 奥: 墨のスレート → スレート → ラベンダーグレー */
  --ribbon-c: #e3d8c7 #d2b999 #d29b97; /* 手前: 明るいグレージュ → グレージュ → くすみローズ */
  --rim: #d29b97;   /* 後ろから当てる光。リボンの縁がこの色に照る */
  --room: #1b1b1f;  /* つやに映り込む部屋の暗がり */
  --night: #0b0b0c;
  --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);
  /* 地は CSS で描き、WebGL の canvas は透明にして重ねる */
  background: radial-gradient(ellipse at 30% 20%, #2a2a2f 0%, #18181b 52%, var(--night) 100%);
}

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

/* 文字の側ほど濃い幕。リボンは明るいので、これが無いと白い文字が読めない */
.ribbon-hero::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(100deg, rgba(11, 11, 12, 0.8), rgba(11, 11, 12, 0.45) 45%, transparent 75%);
}

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

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

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

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

.ribbon-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(--night);
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-decoration: none;
}

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

/* 幅が狭いと、横に流す幕では画面ぜんぶが暗くなる。一様に落として、リボンは透かして見せる */
@media (max-width: 720px) {
  .ribbon-hero::after { background: rgba(11, 11, 12, 0.55); }
}

JavaScript
import * as THREE from "three";

const DURATION = 12; // うねりとねじれがひと回りして、同じ形に戻るまで(秒)
const SEGMENTS = 240, ACROSS = 10; // 長さ方向と幅方向の分割
const LENGTH = 18, TILT = 0.28; // リボンの長さ(画面の幅より長くして、端を外に出す)と、全体の傾き(ラジアン。左下から右上へ)
// y・z: 中心の位置 / width: 幅 / amp・waves: うねりの振れ幅と波の数 / speed: 1 ループで波が進む周数(整数)/ twist: ねじれの回数 / colors: 色の CSS 変数
const RIBBONS = [
  { y: 0.5, z: 0, width: 1.55, amp: 0.85, waves: 1.15, speed: 1, twist: 1.5, phase: 0, colors: "--ribbon-a" },
  { y: -0.25, z: -0.9, width: 1.2, amp: 1.05, waves: 0.9, speed: 1, twist: 2, phase: 2.1, colors: "--ribbon-b" },
  { y: -0.95, z: 0.7, width: 0.85, amp: 0.7, waves: 1.55, speed: 2, twist: 2.5, phase: 4.2, colors: "--ribbon-c" },
];
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const Z = new THREE.Vector3(0, 0, 1), [center, ahead, behind, tangent, side, up, across, point] = Array.from({ length: 8 }, () => new THREE.Vector3());

// 映り込み用のスタジオ。暗い部屋に細長い照明を置き、PMREM でぼかして環境の光にする。細長い照明は曲面に沿った長い筋のつやになる
function studio(renderer, room) {
  const env = new THREE.Scene();
  for (const [geometry, color, x, y, z] of [
    [new THREE.SphereGeometry(20, 32, 16), new THREE.Color(room), 0, 0, 0], // 部屋の壁
    [new THREE.PlaneGeometry(30, 2.2), new THREE.Color(3, 3, 3), 0, 9, 6], // 上の長い照明
    [new THREE.PlaneGeometry(30, 1.2), new THREE.Color(1.2, 1.2, 1.2), 0, -7, 8], // 下からの照り返し
    [new THREE.PlaneGeometry(2.5, 18), new THREE.Color(1.6, 1.6, 1.6), -10, 0, 4], // 左の縦の照明
  ]) {
    const mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({ color, side: THREE.DoubleSide }));
    mesh.position.set(x, y, z); mesh.lookAt(0, 0, 0); env.add(mesh);
  }
  const pmrem = new THREE.PMREMGenerator(renderer), target = pmrem.fromScene(env, 0.02);
  env.traverse((node) => { node.geometry?.dispose(); node.material?.dispose(); }); pmrem.dispose(); // 映し終えたら部屋は要らない
  return target;
}

// 中心線(u: 0〜1)。長さ方向に進む波の重ね合わせなので、流れて見える。時間 a の係数 speed は整数
const centerline = (r, u, a, out) => { const s = u * Math.PI * 2; return out.set((u - 0.5) * LENGTH,
  r.y + r.amp * Math.sin(s * r.waves - a * r.speed + r.phase) + 0.25 * r.amp * Math.sin(s * r.waves * 2.1 + a * r.speed + r.phase * 1.7),
  r.z + 0.8 * Math.cos(s * r.waves * 0.8 - a * r.speed + r.phase)); };

function shape({ def: r, geometry }, a) { // 頂点をすべて時刻から計算し直す(前のコマの結果は使わない)
  const pos = geometry.attributes.position.array, du = 0.5 / SEGMENTS;
  for (let i = 0, k = 0; i <= SEGMENTS; i++) {
    const u = i / SEGMENTS, width = r.width * (0.35 + 0.65 * Math.sin(Math.PI * u)); // 両端ほど細く
    centerline(r, u, a, center);
    tangent.subVectors(centerline(r, u + du, a, ahead), centerline(r, u - du, a, behind)).normalize();
    side.crossVectors(tangent, Z).normalize(); up.crossVectors(side, tangent); // 接線に直交する 2 方向。ねじれの角度で幅の向きを回す
    const twist = r.twist * Math.PI * 2 * u - a * r.speed + r.phase;
    across.copy(side).multiplyScalar(Math.cos(twist)).addScaledVector(up, Math.sin(twist));
    for (let j = 0; j <= ACROSS; j++) point.copy(center).addScaledVector(across, (j / ACROSS - 0.5) * width).toArray(pos, 3 * k++);
  }
  geometry.attributes.position.needsUpdate = true; geometry.computeVertexNormals(); // 形が毎回変わるので、陰影の向きも作り直す
}

function makeRibbon(def, material, css) {
  const count = (SEGMENTS + 1) * (ACROSS + 1), colors = new Float32Array(count * 3), index = [], c = new THREE.Color();
  const stops = css(def.colors).split(/\s+/).map((hex) => new THREE.Color(hex));
  for (let i = 0; i <= SEGMENTS; i++) { // 色は長さ方向のグラデーション(最初に 1 度だけ)
    const f = (i / SEGMENTS) * (stops.length - 1), s = Math.min(Math.floor(f), stops.length - 2);
    c.copy(stops[s]).lerp(stops[s + 1], f - s);
    for (let j = 0; j <= ACROSS; j++) c.toArray(colors, (i * (ACROSS + 1) + j) * 3);
  }
  for (let i = 0; i < SEGMENTS; i++) for (let j = 0; j < ACROSS; j++) { // 格子の 1 マスを、三角形 2 枚で張る
    const p = i * (ACROSS + 1) + j, q = p + ACROSS + 1; index.push(p, q, p + 1, p + 1, q, q + 1);
  }
  const geometry = new THREE.BufferGeometry().setIndex(index).setAttribute("color", new THREE.BufferAttribute(colors, 3));
  geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array(count * 3), 3).setUsage(THREE.DynamicDrawUsage));
  return { def, geometry, mesh: Object.assign(new THREE.Mesh(geometry, material), { frustumCulled: false }) }; // 境界球が古くても消さない
}

document.querySelectorAll(".ribbon-hero").forEach((hero) => {
  const css = (name) => getComputedStyle(hero).getPropertyValue(name).trim();
  const renderer = new THREE.WebGLRenderer({ canvas: hero.querySelector(".ribbon-hero__canvas"), antialias: true, alpha: true }); // 地は CSS
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); renderer.toneMapping = THREE.NeutralToneMapping;
  const scene = new THREE.Scene(), envTarget = studio(renderer, css("--room")); scene.environment = envTarget.texture;
  const camera = new THREE.PerspectiveCamera(35, 1, 0.1, 100).translateZ(10); // 縦横比は resize で入れる
  const key = new THREE.DirectionalLight("#ffffff", 1.8), rim = new THREE.DirectionalLight(css("--rim"), 2.4);
  key.position.set(-3, 5, 6); rim.position.set(4, -2, -4);
  const material = new THREE.MeshPhysicalMaterial({ vertexColors: true, roughness: 0.34, metalness: 0.1, clearcoat: 1,
    clearcoatRoughness: 0.28, sheen: 0.3, sheenRoughness: 0.35, sheenColor: "#ffffff", side: THREE.DoubleSide });
  const ribbons = RIBBONS.map((def) => makeRibbon(def, material, css));
  scene.add(key, rim, new THREE.Group().add(...ribbons.map((ribbon) => ribbon.mesh)).rotateZ(TILT));
  const draw = (t) => {
    const a = ((t % DURATION) / DURATION) * Math.PI * 2;
    for (const ribbon of ribbons) shape(ribbon, a);
    renderer.render(scene, camera);
  };
  const sizes = new ResizeObserver(() => {
    const { clientWidth: w, clientHeight: h } = hero;
    camera.aspect = w / h;
    camera.zoom = Math.max(THREE.MathUtils.clamp(camera.aspect / 1.7, 0.6, 1), camera.aspect / (16 / 9)); // 縦長では引き、横長では寄って端を外に出す
    camera.updateProjectionMatrix();
    renderer.setSize(w, h, false); // 大きさは CSS が決めるので、style は書き換えない
    if (reduceMotion) draw(8.5); // 動きを減らす設定では、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(); ribbons.forEach((ribbon) => ribbon.geometry.dispose());
    material.dispose(); envTarget.dispose(); renderer.dispose(); renderer.forceContextLoss();
  };
});

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

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