デモ
デモを読み込めませんでした。
仕様
- 用途
- セクション
- 動作
- DOM+WebGL
- 種類
- シーン
- 使うもの
- ScrollTrigger
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
画面に固定された商品紹介のセクションでは、スクロールするたびに左の黒いコーヒー缶が回り、ラベルの別の面が正面を向きます。右の説明は「豆」「焙煎」「保存」の3段で入れ替わり、最後の段では缶が手前へ傾いて、ふたまで見えるようになります。
缶は、底から上の縁の巻き締めまでの輪郭を LatheGeometry で回してできた回転体です。ラベルは、文字を canvas に描いた CanvasTexture を、缶よりわずかに太い円筒に巻いて重ねました。3つの面は120°ずつ並んでいて、段が進むたびに缶を120°回せば、その段の面が正面に来ます。面の文字はページの HTML に書いてあるので、銘柄を差し替えるときに画像を作り直す必要はありません。
缶の向きはスクロールの進み具合にそのまま合わせていますが、段の中ほどでは止め、境目の前後20%の幅でだけ次の向きへ回します。説明まで同じようにスクロールに比例させると、途中で止めたときに2つの段が半分ずつ重なって読めなくなります。だから説明のほうは、境目を越えた瞬間から時間で入れ替えることにしました。前の段を0.35秒で消し、0.2秒遅れて次の段を下から表示します。
黒い金属の黒さとつやは、映り込みだけで作っています。暗い部屋に縁のぼけたソフトボックスを置いたスタジオを、PMREM でぼかして環境マップにしました。紙のラベルは映り込みだけでは暗く見えるので、平行光と環境光を足しています。缶の黒さを変えたいときは、金属の色ではなく部屋の明るさを変えてください。つやの筋を保ったまま明るさが変わります。
コード
<!-- セクションの高さ(style.css の --length)のあいだ、中の 1 画面を固定し、スクロールに合わせて缶を回す -->
<section class="product" aria-labelledby="product-title">
<div class="product__stage">
<div class="product__visual">
<canvas class="product__canvas" role="img" aria-label="NORTH HILL ROASTERY のコーヒー缶"></canvas>
<!-- 缶のラベルの文字。li 1 つが 1 面で、缶をひと回りするあいだに 120° ずつ並ぶ。data-size はラベルの画像の中での字の大きさ(px) -->
<ol class="product__label" hidden>
<li><span data-size="26">NORTH HILL ROASTERY</span><span data-size="136">COFFEE</span><span data-size="44">ETHIOPIA</span><span data-size="24">LIGHT ROAST</span></li>
<li><span data-size="26">NORTH HILL ROASTERY</span><span data-size="92">ROASTED</span><span data-size="92">DAILY</span><span data-size="24">SMALL BATCH · 5KG</span></li>
<li><span data-size="26">NORTH HILL ROASTERY</span><span data-size="92">KEEP</span><span data-size="92">SEALED</span><span data-size="24">NET WT. 200g</span></li>
</ol>
</div>
<div class="product__text">
<h2 class="product__eyebrow" id="product-title">Our coffee</h2>
<!-- 段の数と、ラベルの面の数をそろえる。段ごとに、缶がその面を正面へ向ける -->
<ol class="product__steps">
<li class="product__step">
<p class="product__step-label">01 — Beans</p>
<h3 class="product__step-title">エチオピアの浅煎り</h3>
<p>ここに説明文が入ります。</p>
</li>
<li class="product__step">
<p class="product__step-label">02 — Roast</p>
<h3 class="product__step-title">小さな釜で、<wbr>毎朝焙煎</h3>
<p>ここに説明文が入ります。</p>
</li>
<li class="product__step">
<p class="product__step-label">03 — Storage</p>
<h3 class="product__step-title">開けたての香りを、<br>缶のまま</h3>
<p>ここに説明文が入ります。</p>
</li>
</ol>
<div class="product__dots" aria-hidden="true"><span class="is-current"></span><span></span><span></span></div>
</div>
</div>
</section>
.product {
--bg: #f2f1ed;
--ink: #262626;
--mute-small: #6b6b6b; /* 16px 未満の文字 */
--line: rgb(38 38 38 / 0.12);
--length: 300vh; /* 固定している間のスクロール量(1 画面分を含む) */
/* 缶。金属の黒さは、映り込みの部屋の暗さ(--room)で決まる */
--metal: #b4b7bb;
--room: #262626;
/* ラベル(紙はオフホワイト、文字はブラック、上下の線はグレージュ) */
--label-paper: #ece6db;
--label-ink: #262626;
--label-accent: #bd894c;
--label-font: "Avenir Next", "Helvetica Neue", Arial, sans-serif; /* ラベルの英字の書体(ページの書体とは別に、商品のデザインとして決める) */
position: relative;
height: var(--length);
background: var(--bg);
color: var(--ink);
}
.product__stage {
position: sticky;
top: 0;
height: 100svh;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
overflow: clip;
}
/* 缶の後ろは無彩色。缶の後ろがいちばん明るい。床の影は、script.js が缶の底の位置(--foot-x / --foot-y)を入れる */
.product__visual {
position: relative;
overflow: clip;
background: radial-gradient(ellipse 60% 52% at 50% 46%, #fbfbfa 0%, #ecebe7 62%, var(--bg) 100%);
}
.product__visual::before {
content: "";
position: absolute;
left: var(--foot-x, 50%);
top: var(--foot-y, 88%);
width: 69%;
aspect-ratio: 440 / 64;
translate: -50% -34%;
background: radial-gradient(closest-side, rgb(17 17 17 / 0.32), rgb(17 17 17 / 0.12) 55%, rgb(17 17 17 / 0));
}
.product__canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.product__text { align-self: center; padding: 0 clamp(16px, 7vw, 96px) 0 clamp(16px, 5vw, 64px); }
.product__eyebrow,
.product__step-label { margin: 0; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; text-transform: uppercase; }
.product__eyebrow { color: var(--mute-small); }
.product__steps { display: grid; margin: 40px 0 0; padding: 0; list-style: none; }
.product__step-title { margin: 16px 0 0; font-size: clamp(28px, 3.6vw, 44px); font-weight: 800; line-height: 1.2; letter-spacing: -0.02em; word-break: keep-all; }
.product__step p:last-child { margin: 24px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }
/* JS が動いているときだけ、3 段を同じ所に重ねて入れ替える(動かないときは縦に並ぶ) */
.product.is-ready .product__step { grid-area: 1 / 1; }
/* 段の印。今の段だけ濃くする */
.product__dots { display: flex; gap: 8px; margin-top: 32px; }
.product__dots span { width: 40px; height: 2px; border-radius: 1px; background: var(--line); transition: background-color 0.4s; }
.product__dots .is-current { background: var(--ink); }
/* 狭い画面では、缶を上に、説明を下に置く */
@media (max-width: 760px) {
.product__stage { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) auto; }
.product__text { padding: 0 16px 48px; }
.product__steps { margin-top: 24px; }
}
@media (prefers-reduced-motion: reduce) {
.product__dots span { transition: none; }
}
import * as THREE from "three";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
// 缶の回転体の輪郭 [半径, 高さ](下から上へ。上の縁の巻き締めと、ふたの浅い溝まで)
const PROFILE = [[0, -1.3], [0.9, -1.3], [0.96, -1.29], [0.99, -1.27], [1, -1.24], [1, -1.2], [1.012, -1.18], [1, -1.16], [1, 1.16], [1.012, 1.18], [1, 1.2],
[1, 1.25], [1.02, 1.28], [1.02, 1.31], [0.99, 1.33], [0.95, 1.32], [0.94, 1.27], [0.85, 1.27], [0.83, 1.285], [0.78, 1.285], [0.76, 1.27], [0, 1.27]];
// 段ごとの缶の向き [回転, 手前への傾き, 左右の傾き](ラジアン)。回転を 120° ずつ進めて、ラベルの次の面を正面へ向ける
const POSES = [[-0.18, 0.1, 0], [-0.18 - (Math.PI * 2) / 3, 0.16, -0.08], [-0.18 - (Math.PI * 4) / 3, 0.5, 0.05]];
const TURN = 0.2; // 段の境目の前後で、次の向きへ回りきるまでのスクロールの幅(進み具合 0〜1 に対する割合)
// 映り込みのソフトボックス [幅, 高さ, 明るさ, x, y, z]: 真上の広い平面・左右手前の縦長・奥の左右の縦長(縁の明るい線)
const LIGHTS = [[16, 4, 6, 0, 10, 2], [3, 16, 5, -9, 1, 4], [2.4, 16, 3, 9, 1, 3], [1.6, 16, 8, -6, 1, -9], [1.6, 16, 8, 6, 1, -9]];
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const { smoothstep, clamp } = THREE.MathUtils;
const smooth = (x) => smoothstep(clamp(x, 0, 1), 0, 1);
// 映り込み用のスタジオを PMREM でぼかした環境テクスチャ(画面には描かない)。暗い部屋が金属の黒に、ソフトボックスが白い筋になる
const studio = (renderer, room) => {
const size = 32, data = new Uint8Array(size * size * 4).fill(255), fall = (v) => smoothstep(v, 0, 0.3) * smoothstep(1 - v, 0, 0.3);
for (let i = 0; i < size * size; i++) data.fill(Math.round(255 * fall(((i % size) + 0.5) / size) * fall((Math.floor(i / size) + 0.5) / size)), i * 4, i * 4 + 3);
const box = new THREE.DataTexture(data, size, size), scene = new THREE.Scene(), parts = [box]; // box: 縁がなめらかに暗くなるソフトボックスの面
box.magFilter = box.minFilter = THREE.LinearFilter; box.needsUpdate = true;
const add = (geometry, material, x, y, z, mesh = new THREE.Mesh(geometry, material)) => { mesh.position.set(x, y, z); mesh.lookAt(0, 0, 0); scene.add(mesh); parts.push(geometry, material); };
add(new THREE.SphereGeometry(20, 32, 16), new THREE.MeshBasicMaterial({ color: room, side: THREE.BackSide }), 0, 0, 0);
for (const [w, h, power, x, y, z] of LIGHTS) add(new THREE.PlaneGeometry(w, h), new THREE.MeshBasicMaterial({ color: new THREE.Color(power, power, power), map: box, side: THREE.DoubleSide }), x, y, z);
const pmrem = new THREE.PMREMGenerator(renderer), target = pmrem.fromScene(scene, 0.01);
return parts.forEach((part) => part.dispose()), pmrem.dispose(), target;
};
// ラベルの画像: HTML の li を 1 面ずつ横に並べ、面ごとに文字を縦に積んで中央にそろえる。上下の縁に線を引く
const drawLabel = (list, css, font) => {
const canvas = Object.assign(document.createElement("canvas"), { width: 2048, height: 616 }), g = canvas.getContext("2d"), panels = [...list.children];
[["--label-paper", 0, 616], ["--label-accent", 28, 10], ["--label-accent", 578, 10]].forEach(([name, y, h]) => { g.fillStyle = css(name); g.fillRect(0, y, 2048, h); });
g.fillStyle = css("--label-ink"); g.textAlign = "center";
panels.forEach((li, i) => {
const lines = [...li.children].map((span) => [Number(span.dataset.size), span.textContent]);
const gap = (size) => size * (size >= 80 ? 0.96 : 1.5); // 大きな字は詰め、小さな字は間をあける
let y = (616 - lines.reduce((sum, [size]) => sum + gap(size), 0)) / 2;
for (const [size, text] of lines) {
y += gap(size);
g.font = `${size >= 80 ? 800 : size >= 40 ? 700 : 600} ${size}px ${font}`;
g.letterSpacing = size >= 80 ? "-0.01em" : "0.2em"; // 字間を広げた小さな行は、最後の字の後ろにも付く字間の半分だけ右へ寄せる
g.fillText(text, (2048 / panels.length) * (i + 0.5) + (size >= 80 ? 0 : size * 0.1), y - size * (size >= 80 ? 0.12 : 0.3));
}
});
return Object.assign(new THREE.CanvasTexture(canvas), { colorSpace: THREE.SRGBColorSpace, anisotropy: 8 });
};
await document.fonts.ready; // ラベルの英字を、読み込んだ書体で描く
document.querySelectorAll(".product").forEach((section) => {
const css = (name) => getComputedStyle(section).getPropertyValue(name).trim();
const visual = section.querySelector(".product__visual"), steps = [...section.querySelectorAll(".product__step")], dots = [...section.querySelectorAll(".product__dots span")];
const renderer = new THREE.WebGLRenderer({ canvas: section.querySelector(".product__canvas"), antialias: true, alpha: true });
renderer.setPixelRatio(2); renderer.toneMapping = THREE.NeutralToneMapping; // 2 倍で描いて縮め、金属の縁の細い映り込みのギザギザを消す
const scene = new THREE.Scene(), env = studio(renderer, css("--room")), key = new THREE.DirectionalLight("#ffffff", 2.2);
scene.environment = env.texture; key.position.set(-3, 4, 6);
scene.add(key, new THREE.AmbientLight("#ffffff", 0.9)); // ラベルの紙を照らす光(金属は映り込みだけで足りる)
const camera = new THREE.PerspectiveCamera(28, 1, 0.1, 100); // 縦横比は大きさが決まってから入れる
camera.position.set(0, 1.1, 8.2); camera.lookAt(0, -0.05, 0);
// 缶: 黒い金属の回転体に、ラベルの円筒を重ねる。ラベルの 1 面目の中央が正面(+z)に来るよう、円筒の始まりを 60° ずらす
const can = new THREE.Group(), label = drawLabel(section.querySelector(".product__label"), css, css("--label-font"));
can.add(new THREE.Mesh(new THREE.LatheGeometry(PROFILE.map(([r, y]) => new THREE.Vector2(r, y)), 160),
new THREE.MeshPhysicalMaterial({ color: css("--metal"), metalness: 1, roughness: 0.3, clearcoat: 1, clearcoatRoughness: 0.08 })));
can.add(new THREE.Mesh(new THREE.CylinderGeometry(1.004, 1.004, 1.9, 160, 1, true, -Math.PI / 3), new THREE.MeshStandardMaterial({ map: label, roughness: 0.62 })));
scene.add(can);
// 進み具合 p(0〜1)の向き。段の中ほどでは止まり、境目の前後 TURN の幅で次の段の向きへなめらかに回る
const pose = (p) => {
const mix = (axis) => POSES.reduce((v, pose, i) => (i ? v + (pose[axis] - POSES[i - 1][axis]) * smooth((p - i / POSES.length + TURN / 2) / TURN) : pose[axis]), 0);
can.rotation.set(mix(1), mix(0), mix(2));
renderer.render(scene, camera);
};
// 段の入れ替わり: 境目を越えた瞬間から時間で動かす(スクロールの途中で止めても、半分だけ入れ替わった状態で止まらない)
let current = 0, progress = 0;
gsap.set(steps, { opacity: (i) => (i ? 0 : 1) }); section.classList.add("is-ready");
const swap = (to, time = reduceMotion ? 0 : 1) => {
gsap.to(steps[current], { opacity: 0, y: -16, duration: 0.35 * time, ease: "power1.in", overwrite: true });
if (gsap.getProperty(steps[to], "opacity") < 0.01) gsap.set(steps[to], { y: 24 }); // 消えきっていたら下から、消えかけなら今の位置から戻す
gsap.to(steps[to], { opacity: 1, y: 0, duration: 0.5 * time, delay: 0.2 * time, ease: "power2.out", overwrite: true });
dots.forEach((dot, i) => dot.classList.toggle("is-current", i === to));
current = to;
};
const update = () => {
const step = Math.min(steps.length - 1, Math.floor(progress * steps.length));
pose(reduceMotion ? (step + 0.5) / steps.length : progress); // 動きを減らす設定では、段ごとの向きへ切り替えるだけにする
if (step !== current) swap(step);
};
const trigger = ScrollTrigger.create({ trigger: section, start: "top top", end: "bottom bottom", onUpdate: (self) => { progress = self.progress; update(); } });
// 大きさは CSS が決める。床の影は、缶の底の真下に来るよう、底の中心を画面に投影して置く
const sizes = new ResizeObserver(() => {
const { clientWidth: w, clientHeight: h } = visual;
camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h, false);
const foot = new THREE.Vector3(0, -1.3, 0).project(camera);
visual.style.setProperty("--foot-x", `${((foot.x + 1) / 2) * 100}%`); visual.style.setProperty("--foot-y", `${((1 - foot.y) / 2) * 100}%`);
update();
});
sizes.observe(visual);
section.destroy = () => { // PJAX でセクションごと破棄するときに呼ぶ。放っておくと WebGL のコンテキストが積み上がる
trigger.kill(); sizes.disconnect(); gsap.killTweensOf(steps); scene.traverse((o) => o.isMesh && (o.geometry.dispose(), o.material.dispose()));
label.dispose(); env.dispose(); renderer.dispose(); renderer.forceContextLoss();
};
});