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

フォーム

興味のあるジャンルを押すと、タグがエリアへ飛び込んで積み重なる

デモ

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

仕様

用途
フォーム
動作
DOM
種類
レシピ
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

ジャンルのボタンを押すと、ボタンが黒く反転し、同じ文言の色付きのタグがそこから弧を描いて右のエリアへ飛び込み、弾んで積み重なります。もう一度押せばそのタグは縮みながら消え、上に載っていたタグが落ちてきて隙間を詰めます。選んだ数は「3件選択中」のように表示し、1件以上選ぶと「次へ進む」が押せるようになります。

ボタンに見えている部品は、実はチェックボックスです。Tab と Space で選べ、選んだ値はそのままフォームと一緒に送られます。件数の行は role="status" にして、数が変わるたびに読み上げられるようにしました。タグが積み重なるエリアはチェックボックスの内容をくり返しているだけなので、スクリーンリーダーからは外しています。

タグは両端の丸い棒(カプセル)として扱い、重力・反発・摩擦を自前の小さな物理計算で求めています。計算は1/120秒の固定の刻みで進め、接触ごとの力積を少しずつ足していく逐次インパルス法で解きます。大事なのは、跳ね返る速さをいつ測るかです。その刻みで力積を足す前の値を使っていて、足しながら測ると隣の接触の力積が混ざり、積み終えた山がいつまでも小さく震えてしまいます。

投げ入れる軌道は、0.75秒後にエリアの上の狙った点を通る放物線から逆算して、横と縦の速さを決めています。狙う位置は中央から左右に少しずつずらし、タグが同じ所に重ならないようにしました。エリアの左の縁は、タグが上を越えて入れるよう上端までの低い壁にしてあり、ボタンが壁に近くて越えられないときは、届くまでの時間を延ばして高く投げます。

山の形は、反発と摩擦で変わります。反発の0.3を上げるとよく弾み、摩擦の0.55を下げるとよく滑って、低く広がった山になります。

コード

HTML
<form class="interest" action="/signup/step3/" method="post">
  <div class="interest__head">
    <p class="interest__eyebrow">SIGN UP</p>
    <h2 class="interest__title" id="interest-title">興味のあるジャンルを<br>選んでください</h2>
    <p class="interest__help" id="interest-help">いくつでも選べます。あとから変更もできます。</p>
    <!-- 選ぶ部品はチェックボックス。見た目はボタンにして、落ちるタグの色は --drop で決める -->
    <fieldset class="interest__chips" aria-labelledby="interest-title" aria-describedby="interest-help">
      <label class="interest__chip"><input type="checkbox" name="interest" value="cooking"><span>料理</span></label>
      <label class="interest__chip"><input type="checkbox" name="interest" value="travel"><span>旅行</span></label>
      <label class="interest__chip"><input type="checkbox" name="interest" value="interior"><span>インテリア</span></label>
      <label class="interest__chip"><input type="checkbox" name="interest" value="outdoor"><span>アウトドア</span></label>
      <label class="interest__chip"><input type="checkbox" name="interest" value="photo"><span>写真</span></label>
      <label class="interest__chip"><input type="checkbox" name="interest" value="music"><span>音楽</span></label>
      <label class="interest__chip"><input type="checkbox" name="interest" value="books"><span>読書</span></label>
      <label class="interest__chip"><input type="checkbox" name="interest" value="movies"><span>映画</span></label>
      <label class="interest__chip"><input type="checkbox" name="interest" value="fashion"><span>ファッション</span></label>
      <label class="interest__chip"><input type="checkbox" name="interest" value="sports"><span>スポーツ</span></label>
    </fieldset>
  </div>
  <!-- 選んだタグが落ちて積み重なるエリア。中身はチェックボックスと同じなので、読み上げからは外す -->
  <div class="interest__area" aria-hidden="true"><p class="interest__hint">選んだジャンルがここに入ります</p></div>
  <div class="interest__foot">
    <p class="interest__count" role="status" aria-atomic="true"><span class="interest__number">0</span>件選択中</p>
    <button class="interest__next" type="submit" disabled>次へ進む</button>
  </div>
</form>

CSS
.interest {
  --bg: #f2f1ed;
  --surface: #ffffff;
  --ink: #262626;
  --mute: #6b6b6b;
  --line: rgba(38, 38, 38, 0.12);
  --chip-line: rgba(38, 38, 38, 0.2);
  --off: #c8c7c3; /* 押せない「次へ進む」 */
  --hint: #898989; /* 空のエリアの案内文 */
  --switch: 0.15s; /* ボタンの色が切り替わる長さ */
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 520px) minmax(0, 400px);
  grid-template-areas: "head area" "foot area";
  column-gap: clamp(48px, 12vw, 168px);
  row-gap: 56px;
  justify-content: center;
  padding: 64px clamp(24px, 7vw, 96px);
  background: var(--bg);
  color: var(--ink);
}
.interest__head { grid-area: head; }
.interest__eyebrow { margin: 0; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; color: var(--mute); }
.interest__title { margin: 16px 0 0; font-size: clamp(26px, 4vw, 32px); font-weight: 800; line-height: 1.3; letter-spacing: -0.02em; }
.interest__help { margin: 12px 0 0; font-size: 14px; line-height: 1.7; letter-spacing: 0.04em; color: var(--mute); }

.interest__chips { display: flex; flex-wrap: wrap; gap: 12px; margin: 24px 0 0; padding: 0; border: 0; }
/* 落ちるタグの色。並びの順に 6 色を回す */
.interest__chip:nth-child(6n + 1) { --drop: #e18e88; }
.interest__chip:nth-child(6n + 2) { --drop: #98d093; }
.interest__chip:nth-child(6n + 3) { --drop: #88b3d4; }
.interest__chip:nth-child(6n + 4) { --drop: #e1bb8a; }
.interest__chip:nth-child(6n + 5) { --drop: #aa97d8; }
.interest__chip:nth-child(6n + 6) { --drop: #dacc71; }
.interest__chip input { position: absolute; opacity: 0; pointer-events: none; }
/* ボタンと落ちるタグは同じ形。script.js がボタンの文言をコピーしたタグを、この形で落とす */
.interest__chip span,
.interest__drop {
  display: flex;
  align-items: center;
  height: 44px;
  padding: 0 22px;
  border-radius: 22px;
  box-sizing: border-box;
  font-size: 15px;
  font-weight: 600;
  line-height: 1;
  letter-spacing: 0.04em;
  white-space: nowrap;
}
.interest__chip span {
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--chip-line);
  cursor: pointer;
  transition: background-color var(--switch), color var(--switch), box-shadow var(--switch);
}
/* 選んだボタンは明暗を反転する */
.interest__chip input:checked + span { background: var(--ink); color: var(--surface); box-shadow: inset 0 0 0 1px var(--ink); }
.interest__chip input:focus-visible + span { outline: 2px solid var(--ink); outline-offset: 3px; }

.interest__area {
  position: relative;
  grid-area: area;
  align-self: end;
  display: grid;
  place-items: center;
  height: 424px;
  border-radius: 4px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--line);
}
.interest__hint { margin: 0; font-size: 14px; line-height: 1; letter-spacing: 0.04em; color: var(--hint); transition: opacity var(--switch); }
.interest.has-choice .interest__hint { opacity: 0; }
.interest__drop { position: absolute; left: 0; top: 0; color: var(--ink); pointer-events: none; will-change: transform; }
.interest__drop[hidden] { display: none; } /* display: flex に負けないよう、hidden を明示する */
.interest__foot { grid-area: foot; align-self: end; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.interest__count { margin: 0; font-size: 15px; font-weight: 600; line-height: 1; letter-spacing: 0.04em; font-variant-numeric: tabular-nums; }
.interest__next {
  height: 48px;
  padding: 0 32px;
  border: 0;
  border-radius: 999px;
  background: var(--ink);
  color: #ffffff;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.04em;
  cursor: pointer;
  transition: background-color var(--switch);
}
.interest__next:disabled { background: var(--off); cursor: default; }
.interest__next:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

/* 狭い画面では、ボタンの並び → エリア → 件数と「次へ進む」の順に縦へ並べる */
@media (max-width: 860px) {
  .interest { grid-template-columns: minmax(0, 1fr); grid-template-areas: "head" "area" "foot"; row-gap: 32px; }
  .interest__area { height: 320px; }
}

JavaScript
import { gsap } from "gsap";

const GRAVITY = 1800, BOUNCE = 0.3, FRICTION = 0.55; // 重力(px/秒²)、反発(0 で弾まない)、摩擦(小さいほど滑る)
const STEP = 1 / 120; // 物理を進める固定の刻み(秒)。画面の書き換えの速さに左右されず、同じ積み上がり方になる
const FLIGHT = 0.75, OUT = 0.25; // 投げたタグがエリアの上に届くまでの最短の秒数(長いほど高く弧を描く)と、外したタグが縮んで消えるまでの秒数
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const cross = (ax, ay, bx, by) => ax * by - ay * bx;
// タグは両端の丸い棒(カプセル)として扱う。芯の両端の点
const ends = (b) => [-1, 1].map((s) => [b.x + s * Math.cos(b.a) * b.hl, b.y + s * Math.sin(b.a) * b.hl]);
function row(A, B, px, py, x, y) { // 接触点で向き (x, y) に効く力積の通り道。腕との外積と、押しにくさ(有効質量)を先に計算する
  const ca = cross(px - A.x, py - A.y, x, y), cb = 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) };
}
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); // 接触点での相対速度(B − A)
const push = ({ A, B }, r, j) => [[A, -j, r.ca], [B, j, r.cb]].forEach(([b, s, c]) => { b.vx += s * r.x * b.im; b.vy += s * r.y * b.im; b.spin += s * c * b.ii; });
function touch(A, B, out) { // 片方の芯の端から相手の芯までの距離で調べると、寝たタグの上に寝たタグが 2 点で載って安定する
  for (const [P, Q] of [[A, B], [B, A]]) {
    const [[qx, qy], [sx, sy]] = ends(Q);
    ends(P).forEach(([ex, ey], m) => {
      const k = gsap.utils.clamp(0, 1, ((ex - qx) * (sx - qx) + (ey - qy) * (sy - qy)) / ((sx - qx) ** 2 + (sy - qy) ** 2 || 1));
      const dx = qx + (sx - qx) * k - ex, dy = qy + (sy - qy) * k - ey, d = Math.hypot(dx, dy), pen = P.r + Q.r - d;
      if (d < 1e-6 || pen < -1) return;
      const nx = dx / d, ny = dy / d, mid = (d + P.r - Q.r) / 2, c = { A: P, B: Q, key: `${P.id}/${Q.id}/${m}` };
      c.n = row(P, Q, ex + nx * mid, ey + ny * mid, nx, ny); c.t = row(P, Q, ex + nx * mid, ey + ny * mid, -ny, nx);
      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(".interest").forEach((form) => {
  const area = form.querySelector(".interest__area"), number = form.querySelector(".interest__number"), next = form.querySelector(".interest__next");
  const at = (el) => { const r = el.getBoundingClientRect(), f = form.getBoundingClientRect(); return { x: r.left - f.left, y: r.top - f.top, w: r.width, h: r.height }; };
  let box = at(area), warm = new Map(), acc = 0;
  // エリアの床と左右の壁(動かない棒)。床と左の壁は細い棒(端も丸いので、太くすると外まで伸びる)。左の壁はエリアの上端までにして、タグが上を越えて入れるようにする
  const walls = () => [[box.x + box.w / 2, box.y + box.h + 2, 0, 2, box.w / 2], [box.x - 2, box.y + box.h / 2, 1, 2, box.h / 2], [box.x + box.w + 200, box.y + box.h / 2 - 400, 1, 200, box.h / 2 + 400]]
    .map(([x, y, turn, r, hl], i) => ({ id: `w${i}`, x, y, a: (turn * Math.PI) / 2, r, hl, im: 0, ii: 0, vx: 0, vy: 0, spin: 0 }));
  const bodies = [...form.querySelectorAll(".interest__chip")].map((chip, id) => {
    const input = chip.querySelector("input"), label = chip.querySelector("span"), node = document.createElement("span");
    node.className = "interest__drop"; node.textContent = label.textContent; node.hidden = true; node.style.background = getComputedStyle(chip).getPropertyValue("--drop"); // ボタンと同じ文言の、色付きのタグ
    form.append(node);
    return { id, input, label, node, on: false, out: 0, x: 0, y: 0, a: 0, vx: 0, vy: 0, spin: 0 };
  });
  const step = () => { // 1 ステップ: 重力 → 接触を集める → 解く(逐次インパルス法)→ 位置を進める
    const live = bodies.filter((b) => b.on), contacts = [], fixed = walls();
    for (const b of live) b.vy += GRAVITY * STEP;
    live.forEach((A, i) => [...live.slice(i + 1), ...fixed].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); } // 前のステップの力積から始めると震えない
    for (let k = 0; k < 12 * contacts.length; k++) { // 接触ごとに、めり込まない・滑りすぎない力積を順に足していく。12 周すると積み重ねが安定する
      const c = contacts[k % contacts.length], ln = Math.max(0, c.ln + c.n.mass * (c.bias - speed(c, c.n)));
      push(c, c.n, ln - c.ln);
      const lt = gsap.utils.clamp(-FRICTION * ln, FRICTION * ln, c.lt - c.t.mass * speed(c, c.t));
      push(c, c.t, lt - c.lt); c.ln = ln; c.lt = lt;
    }
    warm = new Map(contacts.map((c) => [c.key, [c.ln, c.lt]]));
    for (const b of live) {
      b.vx /= 1 + STEP * 0.1; b.vy /= 1 + STEP * 0.1; b.spin /= 1 + STEP * 1.2; // 少し減衰させて止める
      b.x += b.vx * STEP; b.y += b.vy * STEP; b.a += b.spin * STEP;
    }
  };
  const place = () => bodies.forEach((b) => {
    b.node.hidden = !b.on && !(b.out > 0);
    if (b.node.hidden) return;
    const e = b.on ? 0 : b.out ** 2, upright = b.a - Math.PI * Math.round(b.a / Math.PI); // 180° 回しても同じ形なので、文字が逆さまにならない向きに畳む
    b.node.style.opacity = String(1 - e);
    b.node.style.transform = `translate(${b.x - b.w / 2}px, ${b.y - b.h / 2}px) rotate(${upright}rad) scale(${1 - 0.4 * e})`;
  });
  const tick = (time, deltaTime) => {
    const dt = Math.min(deltaTime, 100) / 1000; // タブから戻ったとき、溜まった時間をまとめて進めない
    for (acc += dt; acc >= STEP; acc -= STEP) step();
    for (const b of bodies) if (!b.on && b.out > 0) b.out = b.out + dt / OUT >= 1 ? 0 : b.out + dt / OUT;
    place();
  };
  // 選んだタグを、ボタンの位置からエリアへ投げ入れる。flight 秒後にエリアの上の狙った点を通る放物線にし、左の壁の上を越えられるまで flight を延ばす
  const drop = (b) => {
    const chip = at(b.label), x = chip.x + chip.w / 2, y = chip.y + chip.h / 2, tx = box.x + box.w * (0.5 + 0.18 * (Math.random() * 2 - 1)), r = chip.h / 2, hl = (chip.w - chip.h) / 2, m = (4 * hl * r + Math.PI * r * r) / 1000;
    let vx = 0, vy = 0, tc = 0; // tc は左の壁の上を通る時刻。回っていても当たらないよう、タグの長さの半分だけ上を通す
    for (let flight = FLIGHT; flight < 1.6 && !(tc < 0 || y + vy * tc + 0.5 * GRAVITY * tc ** 2 < box.y - chip.w / 2 - 16); flight += 0.05) { vx = (tx - x) / flight; vy = (box.y - 40 - y - 0.5 * GRAVITY * flight ** 2) / flight; tc = (box.x - x) / vx; }
    b.node.style.width = `${chip.w}px`;
    Object.assign(b, { on: true, out: 0, w: chip.w, h: chip.h, r, hl, im: 1 / m, ii: 12 / (m * (chip.w ** 2 + chip.h ** 2)), x, y, a: 0, vx, vy, spin: (Math.random() * 2 - 1) * 1.2 });
  };
  const update = (count = bodies.filter((b) => b.input.checked).length) => { number.textContent = String(count); next.disabled = count === 0; form.classList.toggle("has-choice", count > 0); }; // 件数と「次へ進む」。1 件以上選ぶと押せる
  for (const b of bodies) b.input.addEventListener("change", () => {
    if (b.input.checked) drop(b);
    else Object.assign(b, { on: false, out: reduceMotion ? 0 : 1e-6 }); // 外したタグは縮んで消え、上のタグが落ちて詰まる
    if (reduceMotion) for (let i = 0; i < 240; i++) step(); // 動きを減らす設定では、積み終えた形をすぐ見せる
    update(); place();
  });
  new ResizeObserver(() => { // エリアが動いたら、積んだタグも同じだけ動かす
    const moved = at(area);
    for (const b of bodies) { b.x += moved.x - box.x; b.y += moved.y + moved.h - box.y - box.h; }
    box = moved; place();
  }).observe(form);
  // 画面に入っている間だけ進める
  new IntersectionObserver(([entry]) => (entry.isIntersecting ? gsap.ticker.add(tick) : gsap.ticker.remove(tick))).observe(form);
  update();
}));

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

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