デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM+WebGL
- 種類
- シェーダー
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
ヒーローの画像を前と次の 2 枚のテクスチャにし、画像と同じ枠に重ねた WebGL の板のシェーダーで切り替えます。場所ごとに「どの順番で焼けるか」の値を、細かさの違うノイズを 5 段重ねたもの(fbm)と斜めの傾きを 45 対 55 で混ぜて作り、しきい値を下回った所から次の画像にします。しきい値を進み具合に合わせて動かすと、ギザギザの縁で焼け落ちるように、「次へ」なら右下から、「前へ」なら左下から広がります。見出しやボタンは HTML のまま画像の上に置くので、焼けるのは画像だけです。
しきい値の手前 0.1 の幅を焦がして暗くし、さらに手前の 0.05 を燃えさしの色(くすみローズから淡いグレージュ)で光らせます。現れた側にも境目の近くにくすみローズの熱を少し残すと、火が通り過ぎたあとに見えます。燃えさしの色を橙や黄にすると、はっきり火らしくなります。焼けきるまでは 2.2 秒の power1.inOut で、次の画像は 1.06 倍から等倍へ引いて落ち着かせます。押すたびにノイズを引く位置を変えるので、同じ形の繰り返しに見えません。
しきい値を 0 から 1 ちょうどまで動かすと、始まった瞬間にもう焦げが見え、終わっても燃えさしが残ります。焦げの幅より広い余白(0.12)を両端に取り、進み具合が 0 と 1 のときは元の画像そのものにしておくと、切り替えの間だけ canvas を出し、終わったら img 要素に戻しても画像が跳びません。画像の比が枠と違うときは、CSS の object-fit: cover と同じ切り抜きをシェーダーでもしてください。
画像は WebGL が読むので、同じオリジンに置くか、配信側で CORS を許します。焼けている間は次の押下を受けず、番号と名前は読み上げにも届く領域に書き換えます。動きを減らす設定では板を作らず、画像をすぐ差し替えます。PJAX でページを差し替えるときは、destroy を呼んで WebGL の文脈を捨ててください。
焼けきるまでの時間、焦げと光の幅、ノイズの細かさ、傾きを混ぜる割合で調整します。傾きを減らすと、あちこちから同時に穴があく焼け方になります。焙煎・料理・写真のように、熱や質感を感じさせたいサイトのヒーローの画像スライダーに向いています。
コード
<section class="burn-hero" aria-roledescription="カルーセル" aria-label="産地の風景">
<!-- 画像は WebGL が読むので、同じオリジンに置く(別のドメインなら crossorigin="anonymous" を付け、配信側で CORS を許す) -->
<div class="burn-hero__media">
<img class="burn-hero__image" src="/img/guji.jpg" alt="夜明けの丘に広がるコーヒー畑" data-name="エチオピア・グジ">
<img class="burn-hero__image" src="/img/huila.jpg" alt="山あいの谷に続くコーヒー畑" data-name="コロンビア・ウイラ" hidden>
<img class="burn-hero__image" src="/img/kona.jpg" alt="夕暮れの斜面のコーヒー畑" data-name="ハワイ・コナ" hidden>
<!-- 焼け落ちる切り替えを描く板。切り替えのあいだだけ出す -->
<canvas class="burn-hero__canvas" aria-hidden="true" hidden></canvas>
</div>
<div class="burn-hero__inner">
<p class="burn-hero__eyebrow">Roastery</p>
<h1 class="burn-hero__title">焙煎したてを、<br>一杯ずつ。</h1>
<p class="burn-hero__lead">ここに説明文が入ります。</p>
<a class="burn-hero__button" href="/beans/">コーヒー豆を見る</a>
</div>
<div class="burn-hero__controls">
<p class="burn-hero__caption" aria-live="polite">
<span class="burn-hero__count">01 / 03</span>
<span class="burn-hero__name">エチオピア・グジ</span>
</p>
<button class="burn-hero__arrow" type="button" data-step="-1" aria-label="前の画像">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" /></svg>
</button>
<button class="burn-hero__arrow" type="button" data-step="1" aria-label="次の画像">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</button>
</div>
</section>
.burn-hero {
--ink: #ffffff;
--soft: #eeece6;
--mute: #dedcd6;
--on-ink: #111111;
--gutter: clamp(16px, 7.5vw, 96px);
position: relative;
display: grid;
grid-template-rows: 1fr auto;
gap: 48px;
box-sizing: border-box; /* 高さに上下の余白を含める */
min-height: min(90vh, 720px);
padding: 96px var(--gutter) 56px;
overflow: clip;
color: var(--ink);
background: #111111; /* 画像が届くまでの地 */
}
/* 画像と、切り替えのあいだだけ重ねる canvas。どちらも枠いっぱいに object-fit: cover で敷く */
.burn-hero__media { position: absolute; inset: 0; }
.burn-hero__media > * { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.burn-hero__media > [hidden] { display: none; }
.burn-hero__image { object-fit: cover; }
/* 文字の側を濃くした黒の幕。canvas の上にも掛かるので、焼けている間も文字が読める */
.burn-hero__media::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(90deg, rgba(0, 0, 0, 0.6), rgba(0, 0, 0, 0.3));
}
.burn-hero__inner { position: relative; align-self: center; max-width: 34em; }
.burn-hero__eyebrow {
margin: 0;
color: var(--mute);
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.burn-hero__title { margin: 24px 0 0; font-size: clamp(40px, 8vw, 72px); font-weight: 800; line-height: 1.05; letter-spacing: -0.02em; }
.burn-hero__lead { margin: 24px 0 0; color: var(--soft); font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; word-break: auto-phrase; }
.burn-hero__button {
display: inline-grid;
place-items: center;
height: 48px;
margin-top: 32px;
padding: 0 32px;
border-radius: 999px;
background: var(--ink);
color: var(--on-ink);
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
text-decoration: none;
}
.burn-hero__controls { position: relative; display: flex; align-items: center; justify-content: flex-end; gap: 16px; }
.burn-hero__caption { display: flex; align-items: baseline; gap: 16px; margin: 0 8px 0 0; line-height: 1; white-space: nowrap; }
.burn-hero__count { color: var(--mute); font-size: 13px; font-weight: 600; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
.burn-hero__name { font-size: 15px; font-weight: 600; letter-spacing: 0.04em; }
/* 線の丸のボタン。アイコンは 20px で描くので、線は 48 ÷ 20 */
.burn-hero__arrow {
display: grid;
place-items: center;
flex: none;
width: 48px;
height: 48px;
padding: 0;
border: 0;
border-radius: 50%;
background: none;
box-shadow: inset 0 0 0 1px var(--ink);
color: var(--ink);
cursor: pointer;
}
.burn-hero__arrow svg { width: 20px; height: 20px; fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
.burn-hero a:focus-visible, .burn-hero__arrow:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }
@media (max-width: 600px) {
.burn-hero__caption { flex-direction: column; gap: 8px; margin-right: auto; }
}
import * as THREE from "three";
import { gsap } from "gsap";
const DURATION = 2.2; // 焼け落ちるまで(秒)
const vertexShader = "varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }";
// CHAR・GLOW・HEAT は焦げ・燃えさし・残る熱の幅。MARGIN を CHAR より広くして、始まりと終わりの絵を元の画像そのものにする
const fragmentShader = /* glsl */ `
precision highp float;
uniform sampler2D uFrom, uTo; uniform vec2 uCoverFrom, uCoverTo, uDir, uSeed, uSize; uniform float uProgress;
varying vec2 vUv;
const float CHAR = 0.1, GLOW = 0.05, HEAT = 0.05, MARGIN = 0.12;
const vec3 CHAR_TINT = vec3(0.30, 0.24, 0.21), EMBER = vec3(0.82, 0.61, 0.59), EMBER_HOT = vec3(0.94, 0.89, 0.80), HEAT_TINT = EMBER; // 焦げ・燃えさし(くすみローズ → 淡いグレージュ)・残る熱の色
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); }
float noise(vec2 p) { // グラディエントノイズ(-1..1)
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);
}
float fbm(vec2 p) { float v = 0.0, a = 0.5; for (int i = 0; i < 5; i++) { v += a * noise(p); p = p * 2.03 + vec2(11.3, 7.7); a *= 0.5; } return v; }
void main() {
// 溶ける順番: 細かさの違うノイズを 5 段重ねたものと、斜めの傾きを 45 対 55 で混ぜる
float field = mix(fbm(vUv * vec2(uSize.x / uSize.y, 1.0) * 2.4 + uSeed) * 0.5 + 0.5, clamp(dot(vUv - 0.5, uDir) + 0.5, 0.0, 1.0), 0.55);
float d = field - mix(-MARGIN, 1.0 + MARGIN, uProgress); // 正: 前の画像が残る / 負: 焼け落ちて次の画像
// uCover は object-fit: cover と同じ切り抜き。前の画像はわずかに寄り、次の画像は 1.06 倍から引く
vec3 from = texture2D(uFrom, (vUv - 0.5) / (1.0 + 0.03 * uProgress) * uCoverFrom + 0.5).rgb;
vec3 to = texture2D(uTo, (vUv - 0.5) / (1.06 - 0.06 * uProgress) * uCoverTo + 0.5).rgb;
from = mix(from, from * CHAR_TINT, (1.0 - smoothstep(0.0, CHAR, d)) * 0.9); // 焦げ
float glow = 1.0 - smoothstep(0.0, GLOW, d); // 燃えさし。境目にいちばん近い所は明るく
from = mix(from, mix(EMBER, EMBER_HOT, glow * glow), glow);
to = mix(to, HEAT_TINT, (1.0 - smoothstep(0.0, HEAT, -d)) * 0.55); // 現れた側に残る熱
float aa = fwidth(d); // 境目は 1px ほどでなめらかに
gl_FragColor = vec4(mix(to, from, smoothstep(-aa, aa, d)), 1.0);
}
`;
const reduceMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".burn-hero").forEach((hero) => {
const media = hero.querySelector(".burn-hero__media"), canvas = hero.querySelector(".burn-hero__canvas");
const images = [...hero.querySelectorAll(".burn-hero__image")];
const count = hero.querySelector(".burn-hero__count"), name = hero.querySelector(".burn-hero__name");
const pad = (i) => String(i + 1).padStart(2, "0");
let index = 0, tween = null, gl = null;
const caption = (i) => { count.textContent = `${pad(i)} / ${pad(images.length - 1)}`; name.textContent = images[i].dataset.name; };
const show = (i) => { images.forEach((img, k) => (img.hidden = k !== i)); index = i; };
// <img> の object-fit: cover と同じ所を切り抜く(画像の比が枠と違っても、切り替えの前後で絵が跳ばない)
const cover = (img, out) => { const k = media.clientWidth / media.clientHeight / (img.naturalWidth / img.naturalHeight); out.set(Math.min(1, k), Math.min(1, 1 / k)); };
// 動きを減らす設定では板を作らず、画像をすぐ差し替える
if (!reduceMotion) {
const renderer = new THREE.WebGLRenderer({ canvas });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
const scene = new THREE.Scene(), camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const textures = images.map((img) => {
const texture = new THREE.Texture(img);
img.decode().then(() => { texture.needsUpdate = true; renderer.initTexture(texture); }, () => {}); // 先に GPU へ送り、押した瞬間に詰まらせない
return texture;
});
const uniforms = { uFrom: { value: null }, uTo: { value: null }, uProgress: { value: 0 } };
for (const key of ["uCoverFrom", "uCoverTo", "uDir", "uSeed", "uSize"]) uniforms[key] = { value: new THREE.Vector2() };
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader }));
scene.add(mesh);
const sizes = new ResizeObserver(() => {
renderer.setSize(media.clientWidth, media.clientHeight, false); // 大きさは CSS が決めるので、style は書き換えない
uniforms.uSize.value.set(media.clientWidth, media.clientHeight);
});
sizes.observe(media);
gl = { renderer, scene, camera, textures, uniforms, mesh, sizes };
}
const go = (step) => {
if (tween?.isActive()) return; // 焼けきるまでは次の押下を受けない
const next = (index + step + images.length) % images.length, from = images[index], to = images[next];
if (!gl || !from.naturalWidth || !to.naturalWidth) { show(next); caption(next); return; } // 画像が読めていなければ、そのまま差し替える
const { renderer, scene, camera, textures, uniforms: u } = gl;
u.uFrom.value = textures[index];
u.uTo.value = textures[next];
cover(from, u.uCoverFrom.value);
cover(to, u.uCoverTo.value);
u.uDir.value.set(step > 0 ? -0.55 : 0.55, 0.45); // 次へは右下から、前へは左下から焼ける
u.uSeed.value.set(Math.random() * 20, Math.random() * 20); // 毎回ちがう形に焼ける
const render = () => renderer.render(scene, camera);
tween = gsap.timeline({ onComplete: () => { show(next); canvas.hidden = true; } }) // 出し終えたら <img> に戻す
.fromTo(u.uProgress, { value: 0 }, { value: 1, duration: DURATION, ease: "power1.inOut", onUpdate: render, onStart: () => { render(); canvas.hidden = false; } })
// 番号と名前は始めに消し、焼けていく途中で次のものを出す
.to([count, name], { opacity: 0, duration: DURATION * 0.15, ease: "none" }, 0)
.call(caption, [next], DURATION * 0.2)
.to([count, name], { opacity: 1, duration: DURATION * 0.2, ease: "none" }, DURATION * 0.25);
};
hero.querySelectorAll(".burn-hero__arrow").forEach((button) => button.addEventListener("click", () => go(Number(button.dataset.step))));
hero.destroy = () => { // PJAX で差し替えるときに呼ぶ。放っておくと WebGL の文脈が積み上がる
tween?.kill();
if (!gl) return;
gl.sizes.disconnect(); gl.mesh.geometry.dispose(); gl.mesh.material.dispose();
gl.textures.forEach((texture) => texture.dispose()); gl.renderer.dispose(); gl.renderer.forceContextLoss();
};
});