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

ヒーローの背景でラベルの玉が落ちて弾み、積み重なる

デモ

操作:ドラッグ。 「さわってみる」を押すと自動再生が止まり、マウスで動かせます。

仕様

用途
ヒーロー
動作
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 にして動かないようにしています。タグはマウスでつかんで投げられます。指で触る端末ではページのスクロールを優先し、つかみません。動きを減らす設定では、落とさずに積み終えた形から見せます。

落とす範囲を右に寄せると山になり、左の見出しが空きます。狭い画面では全幅に広げ、タグの数を減らします。反発を上げるとよく弾み、摩擦を下げるとよく滑って低く広がります。ヒーローが画面に入っている間だけ動かすので、見えたときに落ち始めます。エージェンシーやサービス紹介、採用ページのように、扱う領域の幅を見せたいヒーローに向いています。

コード

HTML
<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>

CSS
.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; }
}

JavaScript
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));
}));

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

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