デモ
デモを読み込めませんでした。
仕様
- 用途
- カード
- 動作
- DOM+WebGL
- 種類
- シェーダー
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
最初は粗いモザイクだった記事カードの画像が、ブロックを半分ずつ細かくしながら、光が差し込んで徐々にピントが合っていくように鮮明になります。画像に少し遅れて、タイトルなどのテキストも行ごとに下からせり上がります。
画像は WebGL のテクスチャにして、シェーダーでモザイク状に描いています。ブロックは128pxから始まり、モザイク7段のあと、最後の1段で元の画像に切り替わります。全体で1.6秒。色はブロックの真ん中の点から取らず、ブロックの半分ほどの大きさにあたるミップマップの段を textureLod で読み取っています。範囲を平均したような色になり、段が変わるときのちらつきも出ません。
明るさは、粗い段ほど持ち上げ、細かくなるにつれて本来の明るさへ戻しています。白を重ねると色がグレーに濁るので、乗算で持ち上げ、そこにごく少しの加算(最大0.05)を添えることにしました。光が差し込んで見えるのは、この明るさの戻り方のおかげです。
WebGL のコンテキストは、カードが画面に入ったときに作り、表示し終えたら元の img 要素に戻して解放します。ブラウザが同時に保てる数には上限があるので、何枚並んでいても、使うのは動いている最中のカードの分だけにしました。画像は decode() を待ってからテクスチャにします。待たずに貼ると、黒い平面が表示されます。別のドメインの画像は、配信元で CORS を許可し、crossorigin 属性を付けないとテクスチャに使えません。
テキストの初期位置は、スクリプトで yPercent を入れています。CSS の transform で下げておくと、GSAP がそれを px のずれとして読み込み、yPercent を0に戻してもテキストが上がってきません。Tab で画面外のカードへフォーカスが移ったときは、隠れたテキストにフォーカスが残らないよう、待たずに表示します。
段の数を減らすと1段あたりが長くなり、ゲーム画面のように切り替わりがはっきりします。増やすほど滑らかに鮮明になります。時間を短くすればぱっとピントが合う軽い出方に、長くすれば変化をじっくり見せる出方になり、明るさの持ち上げを強めるほど、光の浮かび上がる感じが強まります。
コード
<section class="stories" aria-labelledby="stories-title">
<div class="stories__head">
<div>
<p class="stories__label">Journal</p>
<h2 class="stories__title" id="stories-title">新着記事</h2>
</div>
<a class="stories__all" href="/journal/">すべての記事を見る <span aria-hidden="true">→</span></a>
</div>
<!-- 画像は見えたときに WebGL のモザイクから現れる。行(.story__line)は 1 行ずつ下からせり上がる -->
<div class="stories__grid">
<article class="story">
<div class="story__frame"><img class="story__image" src="/img/journal-night-lights.jpg" alt="" width="672" height="544"></div>
<p class="story__line story__meta"><span><b>まち</b> · <time datetime="2026-09-12">9月12日</time></span></p>
<h3 class="story__title"><a href="/journal/night-lights/"><span class="story__line"><span>夜の街が灯るまで</span></span><span class="story__line"><span>ある夜勤の記録</span></span></a></h3>
<p class="story__line story__read"><span>8分で読めます</span></p>
</article>
<article class="story">
<div class="story__frame"><img class="story__image" src="/img/journal-slow-living.jpg" alt="" width="672" height="544"></div>
<p class="story__line story__meta"><span><b>インテリア</b> · <time datetime="2026-09-09">9月9日</time></span></p>
<h3 class="story__title"><a href="/journal/slow-living/"><span class="story__line"><span>風の通る部屋で、</span></span><span class="story__line"><span>ゆっくり暮らす</span></span></a></h3>
<p class="story__line story__read"><span>6分で読めます</span></p>
</article>
<article class="story">
<div class="story__frame"><img class="story__image" src="/img/journal-records.jpg" alt="" width="672" height="544"></div>
<p class="story__line story__meta"><span><b>音楽</b> · <time datetime="2026-09-03">9月3日</time></span></p>
<h3 class="story__title"><a href="/journal/records/"><span class="story__line"><span>それでも土曜日に</span></span><span class="story__line"><span>レコードを買う理由</span></span></a></h3>
<p class="story__line story__read"><span>5分で読めます</span></p>
</article>
</div>
</section>
.stories {
--bg: #111111;
--panel: #262626; /* 画像が現れる前のパネルの色。script.js がここを読んでシェーダーへ渡すので、#rrggbb で書く */
--ink: #eeece6;
--mute-small: #a8a6a0; /* 16px 未満の文字 */
padding: 56px clamp(16px, 7.5vw, 96px) 96px;
background: var(--bg);
color: var(--ink);
}
.stories__head {
display: flex;
flex-wrap: wrap;
align-items: last baseline;
justify-content: space-between;
gap: 16px 32px;
}
.stories__label {
margin: 0;
color: var(--mute-small);
font-size: 13px;
font-weight: 700;
line-height: 1;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.stories__title { margin: 16px 0 0; font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; }
.stories__all {
color: inherit;
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
white-space: nowrap;
text-decoration-thickness: 1px;
text-underline-offset: 0.4em;
}
.stories__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 280px), 1fr));
gap: 48px 40px;
margin-top: 48px;
}
/* カード全体を押せるよう、題のリンクの ::after をカードに広げる */
.story { position: relative; }
.story__title a { color: inherit; text-decoration: none; }
.story__title a::after { content: ""; position: absolute; inset: 0; }
.story__title a:focus-visible { outline: none; }
.story:has(a:focus-visible) { outline: 2px solid var(--ink); outline-offset: 8px; }
/* 画像のコンテナ。WebGL の canvas は、現れる途中のあいだだけ画像の上に重ねる */
.story__frame {
position: relative;
overflow: hidden;
aspect-ratio: 21 / 17;
border-radius: 4px;
background: var(--panel);
}
.story__frame > * { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
.story__image { object-fit: cover; }
/* 行を包む要素。中の span を要素の下から持ち上げる */
.story__line { display: block; overflow: hidden; margin: 0; }
.story__line > span { display: block; }
.story__meta {
margin-top: 24px;
color: var(--mute-small);
font-size: 13px;
line-height: 1.5;
letter-spacing: 0.04em;
font-variant-numeric: tabular-nums;
}
.story__meta > span { padding-bottom: 8px; }
.story__meta b { font-weight: 700; letter-spacing: 0.06em; }
.story__title { margin: 0; font-size: 24px; font-weight: 700; line-height: 1.3; letter-spacing: -0.01em; }
.story__read { color: var(--mute-small); font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; }
.story__read > span { padding-top: 8px; }
import * as THREE from "three";
import { gsap } from "gsap";
const STEPS = 7; // 段の数。最初のブロックは 2^STEPS px(128px)で、1 段ごとに半分になり、最後に元の画像になる
const REVEAL = 1.6; // 最初のモザイクから鮮明になるまで(秒)
const FADE_IN = 0.12; // パネルの色から最初のモザイクに変わるまで(秒)
const LIFT = 0.5; // 粗いうちに明るく持ち上げる量(0 = 持ち上げない)
const STAGGER = 0.35; // 同時に見えたカードどうしの間(秒)
const TEXT_DELAY = 0.2; // 画像が現れ始めてから、行が上がり始めるまで(秒)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const vertexShader = "varying vec2 vUv; void main() { vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }";
const fragmentShader = /* glsl */ `
uniform sampler2D uTex; uniform float uProgress; uniform float uOpacity; uniform float uLift;
uniform vec2 uSize; uniform vec2 uCover; uniform float uTexScale; uniform vec3 uPanel; varying vec2 vUv;
void main() {
float level = min(floor(uProgress * ${STEPS + 1}.0), ${STEPS}.0); // 段ごとにブロックを半分にする
vec2 pos = (vUv - 0.5) * uSize; // 表示領域の中心を原点にした CSS px。格子を中心から切ると、端のはみ出しが左右でそろう
float block = exp2(${STEPS}.0 - level);
vec2 uv = clamp((floor(pos / block) + 0.5) * block / uSize + 0.5, 0.0, 1.0); // ブロックの中心
uv = 0.5 + (uv - 0.5) * uCover; // object-fit: cover と同じく、画像の中央を切り抜く
// ブロックの色は、ブロックの半分の大きさのミップマップのレベルから取る(1 点で取るよりちらつかず、範囲の平均に近い)
vec3 color = level < ${STEPS}.0 ? textureLod(uTex, uv, max(log2(block * uTexScale) - 1.0, 0.0)).rgb : texture2D(uTex, 0.5 + (vUv - 0.5) * uCover).rgb;
color = min(color * (1.0 + uLift), 1.0) + uLift * 0.1; // 粗いうちは色を乗算して明るくする(白を重ねるとグレーに濁る)
gl_FragColor = vec4(mix(uPanel, color, uOpacity), 1.0);
}
`;
function reveal(card, delay) {
if (card.dataset.revealed) return;
card.dataset.revealed = "";
const frame = card.querySelector(".story__frame"), img = frame.querySelector("img"), lines = card.querySelectorAll(".story__line > span");
const show = () => { gsap.set(img, { autoAlpha: 1 }); gsap.to(lines, { yPercent: 0, duration: 0.7, ease: "power3.out", stagger: 0.06, delay }); };
// 読み込み前の画像をテクスチャにすると真っ黒に表示されるので、デコードを待つ。読めなかったら文字だけ表示する
img.decode().then(() => {
const canvas = frame.appendChild(document.createElement("canvas"));
const renderer = new THREE.WebGLRenderer({ canvas }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
const texture = new THREE.Texture(img); texture.needsUpdate = true; // ミップマップ(ブロックの平均色)も作られる
const hex = Number.parseInt(getComputedStyle(card).getPropertyValue("--panel").trim().slice(1), 16);
const uniforms = { uTex: { value: texture }, uProgress: { value: 0 }, uOpacity: { value: 0 }, uLift: { value: 0 }, uSize: { value: new THREE.Vector2() },
uCover: { value: new THREE.Vector2() }, uTexScale: { value: 1 }, uPanel: { value: new THREE.Vector3((hex >> 16) / 255, ((hex >> 8) & 255) / 255, (hex & 255) / 255) } };
const mesh = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader }));
const scene = new THREE.Scene().add(mesh), camera = new THREE.Camera(), state = { progress: 0, opacity: 0 };
const draw = () => {
const { clientWidth: w, clientHeight: h } = frame, ratio = w / h / (img.naturalWidth / img.naturalHeight);
if (w !== uniforms.uSize.value.x || h !== uniforms.uSize.value.y) renderer.setSize(w, h, false); // 途中で要素の大きさが変わっても合わせる
uniforms.uSize.value.set(w, h);
uniforms.uCover.value.set(Math.min(1, ratio), Math.min(1, 1 / ratio)); // 画像のうち、要素に見える範囲の比
uniforms.uTexScale.value = Math.min(img.naturalWidth / w, img.naturalHeight / h); // 画像の画素 / CSS px
uniforms.uProgress.value = state.progress;
uniforms.uOpacity.value = state.opacity;
uniforms.uLift.value = LIFT * (1 - state.progress) ** 2; // 細かくなるにつれて本来の明るさへ戻す
renderer.render(scene, camera);
};
// 現れ終えたら元の画像を見せ、WebGL を破棄する。カードが何枚あっても、動いている間の分しかコンテキストを持たない
const finish = () => {
gsap.set(img, { autoAlpha: 1 }); canvas.remove();
mesh.geometry.dispose(); mesh.material.dispose(); texture.dispose(); renderer.dispose(); renderer.forceContextLoss();
};
draw();
gsap.timeline({ delay, onUpdate: draw, onComplete: finish })
.to(state, { opacity: 1, duration: FADE_IN, ease: "none" }, 0)
.to(state, { progress: 1, duration: REVEAL, ease: "none" }, 0)
.to(lines, { yPercent: 0, duration: 0.7, ease: "power3.out", stagger: 0.06 }, TEXT_DELAY);
}, show);
}
document.querySelectorAll(".stories").forEach((section) => {
if (reduceMotion) return; // 動きを減らす設定では何も隠さず、はじめから画像と文字を表示しておく
gsap.set(section.querySelectorAll(".story__image"), { autoAlpha: 0 });
// 行は下へずらして隠しておく。CSS の transform で書くと、GSAP が px のずれとして読み込み、yPercent を 0 にしても現れない
gsap.set(section.querySelectorAll(".story__line > span"), { yPercent: 110 });
// 画面の下から 15% の線を越えたら 1 回だけ動かす。同時に越えたカードは STAGGER ずつずらす
const io = new IntersectionObserver((entries) => {
entries.filter((e) => e.isIntersecting).forEach((e, i) => { io.unobserve(e.target); reveal(e.target, i * STAGGER); });
}, { rootMargin: "0px 0px -15%" });
section.querySelectorAll(".story").forEach((card) => {
io.observe(card);
// Tab で線より下のカードへ移ったときは、見えないままフォーカスが残らないよう、すぐ動かす
card.addEventListener("focusin", () => { io.unobserve(card); reveal(card, 0); });
});
section.destroy = () => io.disconnect(); // PJAX でページを差し替えるときに呼ぶ(現れる途中の WebGL は、現れ終えると自分で破棄する)
});