デモ
デモを読み込めませんでした。
仕様
- 用途
- ヒーロー
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
サービス名のタグを両端の丸い棒(カプセル)として扱い、重力・反発・摩擦を自前の小さな物理で計算して、上から順に落とし、弾ませ、転がして積み重ねます。物理は 1/120 秒の固定の刻みで進め、gsap.ticker で画面の書き換えの時間を貯めて、その分だけ刻みを回します。書き換えの速さが違う画面でも、同じように積み上がります。
重力はヒーローの高さの 2.5 倍毎秒毎秒、反発は 0.3、摩擦は 0.55 です。接触は、片方の芯の端から相手の芯までの距離で調べます。寝たタグの上に寝たタグが 2 点で載るので、崩れずに積み上がります。接触ごとの力積は、めり込まない・滑りすぎないように 1 つずつ足していく方法(逐次インパルス法)で 12 周解き、前の刻みの力積から始めます。
つまずきやすいのは跳ね返りです。跳ね返る速さを、その刻みの力積を足しながら測った速さで決めると、積み重なった山の中で隣の接触の力積が混ざり、止まっているはずのタグが跳ね続けて、山がいつまでも震えます。跳ね返りは、力積を足す前に測った速さで決めます。こうすると、落ちてきたタグは弾み、積み終えた山はぴたりと止まります。
カプセルは 180° 回しても同じ形なので、描くときだけ角度を ±90° に畳み、文字が逆さまにならないようにします。床と左右の壁は、画面の外に置いた太い棒で、質量の逆数を 0 にして動かないようにしています。タグはマウスでつかんで投げられます。指で触る端末ではページのスクロールを優先し、つかみません。動きを減らす設定では、落とさずに積み終えた形から見せます。
落とす範囲を右に寄せると山になり、左の見出しが空きます。狭い画面では全幅に広げ、タグの数を減らします。反発を上げるとよく弾み、摩擦を下げるとよく滑って低く広がります。ヒーローが画面に入っている間だけ動かすので、見えたときに落ち始めます。エージェンシーやサービス紹介、採用ページのように、扱う領域の幅を見せたいヒーローに向いています。
コード
<section class="tags-hero" aria-labelledby="tags-hero-title">
<p class="tags-hero__eyebrow">CREATIVE STUDIO</p>
<h1 class="tags-hero__title" id="tags-hero-title">発想を、積み重ねる。</h1>
<p class="tags-hero__lead">ここに説明文が入ります。</p>
<!-- スクリプトが上から順に落として積み重ねる。丸いタグは --round、色は data-color で選ぶ -->
<ul class="tags-hero__tags" aria-label="扱う領域">
<li class="tags-hero__tag" data-color="ink">ブランディング</li>
<li class="tags-hero__tag" data-color="sumi">モーション</li>
<li class="tags-hero__tag tags-hero__tag--round" data-color="sage">UX</li>
<li class="tags-hero__tag" data-color="greige">Webデザイン</li>
<li class="tags-hero__tag" data-color="line">戦略設計</li>
<li class="tags-hero__tag tags-hero__tag--round" data-color="rose">3D</li>
<li class="tags-hero__tag" data-color="sage">イラスト</li>
<li class="tags-hero__tag" data-color="ink">パッケージ</li>
<li class="tags-hero__tag" data-color="rose">コンテンツ</li>
<li class="tags-hero__tag" data-color="sumi">アートディレクション</li>
<li class="tags-hero__tag tags-hero__tag--round" data-color="greige">AI</li>
<li class="tags-hero__tag" data-color="line">リサーチ</li>
<li class="tags-hero__tag" data-color="greige">タイポグラフィ</li>
<li class="tags-hero__tag" data-color="sage">SNS運用</li>
<li class="tags-hero__tag" data-color="ink">映像</li>
<li class="tags-hero__tag" data-color="sumi">ロゴ</li>
<li class="tags-hero__tag" data-color="rose">キャンペーン</li>
<li class="tags-hero__tag" data-color="line">編集</li>
</ul>
</section>
.tags-hero {
--bg: #111111;
--ink: #eeece6;
--mute: #a8a6a0;
--drop-from: 0.44; /* タグを落とす範囲の左端(幅に対する比)。右に寄せて山にし、左の見出しを空ける */
position: relative;
overflow: hidden;
min-height: 100svh;
box-sizing: border-box;
padding: 128px clamp(24px, 7.5vw, 96px) 96px;
background: var(--bg);
color: var(--ink);
}
.tags-hero__eyebrow {
margin: 0;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--mute);
}
.tags-hero__title {
margin: 24px 0 0;
font-size: clamp(28px, 8vw, 72px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.02em;
}
.tags-hero__lead {
max-width: 24em;
margin: 24px 0 0;
font-size: 18px;
line-height: 1.8;
letter-spacing: 0.04em;
}
/* タグが動く面。ヒーロー全体に重ね、床と壁はこの箱の縁になる */
.tags-hero__tags {
position: absolute;
inset: 0;
margin: 0;
padding: 0;
list-style: none;
pointer-events: none;
}
.tags-hero__tag {
position: absolute;
left: 0;
top: 0;
transform: translateY(-100%); /* スクリプトが動かすまでは、ヒーローの上の外に隠す */
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
height: 64px;
padding: 0 32px;
border-radius: 999px;
background: var(--ink);
color: var(--bg);
font-size: 22px;
font-weight: 700;
letter-spacing: -0.01em;
white-space: nowrap;
pointer-events: auto;
cursor: grab;
user-select: none;
}
.tags-hero__tag:active {
cursor: grabbing;
}
.tags-hero__tag--round {
width: 84px;
height: 84px;
padding: 0;
font-size: 28px;
}
/* 中身の色(アッシュ寄りのパレット)。色相より明るさで見分けられるよう、明るい段と暗い段と線だけを混ぜる */
.tags-hero__tag[data-color="greige"] { background: #d2b999; }
.tags-hero__tag[data-color="sage"] { background: #a5c0a3; }
.tags-hero__tag[data-color="rose"] { background: #d29b97; }
.tags-hero__tag[data-color="sumi"] { background: #4a5b66; color: var(--ink); }
.tags-hero__tag[data-color="line"] { background: var(--bg); color: var(--ink); box-shadow: inset 0 0 0 2px var(--ink); }
/* 狭い画面では、タグを小さくして数を減らし、見出しと文を隠さない */
@media (max-width: 640px) {
.tags-hero { --drop-from: 0.04; }
.tags-hero__tag { height: 40px; padding: 0 20px; font-size: 15px; }
.tags-hero__tag--round { width: 52px; height: 52px; font-size: 18px; }
.tags-hero__tag:nth-child(n + 11) { display: none; }
}
import { gsap } from "gsap";
const GRAVITY = 2.5; // 重力(ヒーローの高さ / 秒²)
const BOUNCE = 0.3; // 反発。0 で弾まず、1 で同じ高さまで跳ね返る
const FRICTION = 0.55; // 摩擦。小さいほどよく滑る
const STEP = 1 / 120; // 物理を進める固定の刻み(秒)。画面の書き換えの速さに左右されず、同じ積み上がり方になる
const EVERY = 0.12; // 次のタグが落ち始めるまで(秒)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const [clamp, cross] = [gsap.utils.clamp, (ax, ay, bx, by) => ax * by - ay * bx];
// タグは両端の丸い棒(カプセル)として扱う。芯の両端の点(丸いタグは 1 点)
const ends = (b) => (b.hl > 0 ? [-1, 1].map((s) => [b.x + s * Math.cos(b.a) * b.hl, b.y + s * Math.sin(b.a) * b.hl]) : [[b.x, b.y]]);
// 接触点 p で向き (x, y) に効く力積の通り道。p までの腕との外積(回る分)と、押しにくさ(有効質量)を先に出しておく
function row(A, B, [px, py], x, y) {
const [ca, cb] = [cross(px - A.x, py - A.y, x, y), cross(px - B.x, py - B.y, x, y)];
return { x, y, ca, cb, mass: 1 / (A.im + B.im + A.ii * ca * ca + B.ii * cb * cb) };
}
// 接触点での相対速度(B − A)の、その向きの成分。力積 j は B に加えて A から引く
const speed = ({ A, B }, r) => B.vx * r.x + B.vy * r.y + B.spin * r.cb - (A.vx * r.x + A.vy * r.y + A.spin * r.ca);
const push = ({ A, B }, r, j) => [[A, -j, r.ca], [B, j, r.cb]].forEach(([b, s, c]) => Object.assign(b, { vx: b.vx + s * r.x * b.im, vy: b.vy + s * r.y * b.im, spin: b.spin + s * c * b.ii }));
// 2 つの棒の接触。片方の芯の端から相手の芯までの距離で調べると、寝たタグの上に寝たタグが 2 点で載って安定する
function touch(A, B, out) {
for (const [P, Q] of [[A, B], [B, A]]) {
const [[qx, qy], [sx, sy]] = [ends(Q)[0], ends(Q).at(-1)];
ends(P).forEach(([ex, ey], m) => {
const k = clamp(0, 1, ((ex - qx) * (sx - qx) + (ey - qy) * (sy - qy)) / ((sx - qx) ** 2 + (sy - qy) ** 2 || 1)); // 相手の芯でいちばん近い点
const [dx, dy] = [qx + (sx - qx) * k - ex, qy + (sy - qy) * k - ey];
const d = Math.hypot(dx, dy);
const [nx, ny, pen, mid] = [dx / d, dy / d, P.r + Q.r - d, (d + P.r - Q.r) / 2]; // 向き(P → Q)・めり込み・表面の中点までの距離
if (d < 1e-6 || pen < -1) return;
const p = [ex + nx * mid, ey + ny * mid];
const c = { A: P, B: Q, n: row(P, Q, p, nx, ny), t: row(P, Q, p, -ny, nx), key: `${P.i}/${Q.i}/${m}` };
// 跳ね返りは、この回の力積を足す前の速さで決める。足しながら測ると、積み重なった山が跳ね続けて震える
const vn = speed(c, c.n);
out.push(Object.assign(c, { bias: Math.max((0.2 / STEP) * Math.max(0, pen - 0.5), vn < -60 ? -BOUNCE * vn : 0) }));
});
}
}
document.fonts.ready.then(() => document.querySelectorAll(".tags-hero").forEach((hero) => {
const left = parseFloat(getComputedStyle(hero).getPropertyValue("--drop-from")) || 0; // 落とす範囲の左端(幅に対する比)
let [W, H, clock, acc, warm, grab] = [hero.clientWidth, hero.clientHeight, 0, 0, new Map(), null];
const random = (size) => (Math.random() - 0.5) * size;
const bodies = [...hero.querySelectorAll(".tags-hero__tag")].filter((el) => el.offsetWidth).map((el, i) => {
const [w, h, r, hl] = [el.offsetWidth, el.offsetHeight, el.offsetHeight / 2, (el.offsetWidth - el.offsetHeight) / 2];
const m = (4 * hl * r + Math.PI * r * r) / 1000; // 質量は面積に比例
const x = clamp(w / 2, W - w / 2, (left + (0.96 - left) * ((i * 0.618 + 0.1) % 1)) * W); // 黄金比でずらして散らす
const ii = hl > 0 ? 12 / (m * (w * w + h * h)) : 2 / (m * r * r); // 回りにくさ(慣性モーメント)の逆数
return { el, i, w, h, r, hl, im: 1 / m, ii, spawn: 0.2 + i * EVERY, x, y: -r - 20 - Math.random() * 60, a: random(0.7), vx: random(120), vy: 0, spin: random(1.6) };
});
const step = () => { // 1 ステップ進める: 重力 → 接触を集める → 解く → 位置を進める
[W, H] = [hero.clientWidth, hero.clientHeight];
// 床と左右の壁は、画面の外に置いた太い棒。質量の逆数を 0 にすると、押しても動かない
const walls = [[W / 2, H + 1e4, 0], [-1e4, H / 2, 1], [W + 1e4, H / 2, 1]].map(([x, y, turn], i) => ({ i: -1 - i, x, y, a: (turn * Math.PI) / 2, r: 1e4, hl: 1e5, im: 0, ii: 0, vx: 0, vy: 0, spin: 0 }));
const [live, contacts] = [bodies.filter((b) => clock >= b.spawn), []];
for (const b of live) {
if (grab?.b !== b) b.vy += GRAVITY * H * STEP;
else [b.vx, b.vy] = [(grab.x - grab.dx - b.x) * 12, (grab.y - grab.dy - b.y) * 12]; // つかんだタグは、ポインタへ向かう速さで引く
}
live.forEach((A, i) => [...live.slice(i + 1), ...walls].forEach((B) => Math.hypot(A.x - B.x, A.y - B.y) < A.hl + B.hl + A.r + B.r + 1 && touch(A, B, contacts)));
for (const c of contacts) {
[c.ln, c.lt] = warm.get(c.key) ?? [0, 0]; // 前のステップの力積から始めると、積み重ねが震えない
push(c, c.n, c.ln);
push(c, c.t, c.lt);
}
// 接触ごとに、めり込まない・滑りすぎない力積を順に足していく(逐次インパルス法)。12 周すると積み重ねが安定する
for (let k = 0; k < 12 * contacts.length; k++) {
const c = contacts[k % contacts.length];
const ln = Math.max(0, c.ln + c.n.mass * (c.bias - speed(c, c.n)));
push(c, c.n, ln - c.ln);
const lt = clamp(-FRICTION * ln, FRICTION * ln, c.lt - c.t.mass * speed(c, c.t));
push(c, c.t, lt - c.lt);
[c.ln, c.lt] = [ln, lt];
}
warm = new Map(contacts.map((c) => [c.key, [c.ln, c.lt]]));
for (const b of live) {
const [vx, vy, spin] = [b.vx / (1 + STEP * 0.1), b.vy / (1 + STEP * 0.1), b.spin / (1 + STEP * 1.2)]; // 少し減衰させて止める
Object.assign(b, { vx, vy, spin, x: b.x + vx * STEP, y: b.y + vy * STEP, a: b.a + spin * STEP });
}
clock += STEP;
};
const tick = (time, deltaTime) => {
for (acc += Math.min(deltaTime, 100) / 1000; acc >= STEP; acc -= STEP) step(); // タブから戻ったとき、溜まった時間をまとめて進めない
// 180° 回しても同じ形なので、文字が逆さまにならない向き(±90°)に畳んで描く
for (const b of bodies) b.el.style.transform = `translate(${b.x - b.w / 2}px, ${b.y - b.h / 2}px) rotate(${b.a - Math.PI * Math.round(b.a / Math.PI)}rad)`;
};
if (reduceMotion) while (clock < 4 + bodies.length * EVERY) step(); // 動きを減らす設定では、積み終えた形から見せる
tick(0, 0);
// 画面に入っている間だけ進める。見えたときに落ち始めるので、最初の動きを見逃さない
new IntersectionObserver(([entry]) => (entry.isIntersecting ? gsap.ticker.add(tick) : gsap.ticker.remove(tick))).observe(hero);
// マウスでつかんで投げる(指で触る端末では、ページのスクロールを優先してつかまない)
hero.addEventListener("pointerdown", (e) => {
const b = bodies.find((body) => body.el === e.target);
if (!b || e.pointerType === "touch") return;
grab = { b, x: e.clientX, y: e.clientY, dx: e.clientX - b.x, dy: e.clientY - b.y }; // つかんだ所と中心のずれを保ったまま引く
e.preventDefault(); // 引いている間に文字が選ばれないように
});
window.addEventListener("pointermove", (e) => grab && Object.assign(grab, { x: e.clientX, y: e.clientY })); // ヒーローの外まで引いても付いてくる
window.addEventListener("pointerup", () => (grab = null));
}));