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

ラジオボタンの選んだ印が、前の選択肢から滑って移る

デモ

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

仕様

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

しくみと調整

選んだ印(丸い点)はページに 1 つだけ置き、選ぶたびに、前の選択肢の丸から次の丸へ滑らせます。点は上端と下端を別々に動かし、進む向きの端が先に出て、後ろの端が少し遅れて付いてきます。動いている間だけ縦に伸びたカプセルになり、着くと丸に戻るので、しずくが流れ込むように見えます。移る途中で選び直しても、今の点の位置から次の丸へ向かいます。

前の端は 0.5 秒前後の power3.inOut で動かし、後ろの端を 0.08 秒遅らせます。この遅れが伸びる長さを決め、0.15 秒ほどにすると大きくとろりと伸びます。移る時間は、1 つ離れるごとに 0.1 秒ずつ長くするので、遠い選択肢へ移るほど長く伸びます。

点は、SVG の rect の rx を半径のままにして、y と高さだけを書き換えます。scaleY で縦に伸ばすと、丸い端まで一緒に引き伸ばされて楕円に歪みます。印は丸ごとに持たせず、丸の列に重ねた 1 枚の SVG に描きます。丸ごとに持たせると、前の丸で消して次の丸で出すことしかできず、間を滑らせられません。丸の位置は CSS が決めるので、書体の読み込みや画面の幅が変わったら測り直します。

土台は本物の radio のまとまりで、見えなくした input を label で包みます。クリックでも矢印キーでも change が届き、フォームの送信もそのまま使えます。選んだカードは白い面と 2px の枠で示し、選んでいない丸の線は補足の灰色にします。罫線の淡い色では、丸が地に溶けます。動きを減らす設定では、滑らせずにその場へ移します。

このデモでは、カードを押すと選べます(矢印キーは、コードのほうの本物の radio が受けます)。遅れを 0 にすると伸びずに滑るだけになり、横に並べた選択肢なら、上端と下端の代わりに左端と右端を動かします。配送や支払いの方法、料金プラン、アンケートの選択肢に向いています。

コード

HTML
<!-- 本物の radio のまとまり。クリックでも矢印キーでも選べ、フォームでそのまま送れる -->
<fieldset class="choice">
  <legend class="choice__legend">お届け方法</legend>
  <div class="choice__list">
    <label class="choice__option">
      <input class="choice__input" type="radio" name="delivery" value="standard" checked>
      <span class="choice__ring" aria-hidden="true"></span>
      <span class="choice__text">
        <span class="choice__name">通常配送</span>
        <span class="choice__note">3〜5日でお届けします</span>
      </span>
      <span class="choice__price">無料</span>
    </label>
    <label class="choice__option">
      <input class="choice__input" type="radio" name="delivery" value="express">
      <span class="choice__ring" aria-hidden="true"></span>
      <span class="choice__text">
        <span class="choice__name">お急ぎ便</span>
        <span class="choice__note">明日の午前中にお届けします</span>
      </span>
      <span class="choice__price">¥500</span>
    </label>
    <label class="choice__option">
      <input class="choice__input" type="radio" name="delivery" value="scheduled">
      <span class="choice__ring" aria-hidden="true"></span>
      <span class="choice__text">
        <span class="choice__name">日時指定便</span>
        <span class="choice__note">ご希望の日時にお届けします</span>
      </span>
      <span class="choice__price">¥300</span>
    </label>
    <label class="choice__option">
      <input class="choice__input" type="radio" name="delivery" value="pickup">
      <span class="choice__ring" aria-hidden="true"></span>
      <span class="choice__text">
        <span class="choice__name">店舗で受け取る</span>
        <span class="choice__note">最寄りの店舗に取り置きます</span>
      </span>
      <span class="choice__price">無料</span>
    </label>
    <!-- 選んだ印は 1 つだけ。丸の列に重ねた SVG に描き、選ぶたびに前の丸から滑らせる -->
    <svg class="choice__track" aria-hidden="true" focusable="false">
      <rect class="choice__dot" />
    </svg>
  </div>
</fieldset>

CSS
.choice {
  --ink: #262626;                 /* 文字・選んだ枠と印 */
  --mute: #6b6b6b;                /* 補足の文字 */
  --ring: #898989;                /* 選んでいない丸(罫線の色だと、丸が地に溶ける) */
  --line: rgba(38, 38, 38, 0.12); /* 選んでいないカードの枠 */
  --surface: #ffffff;             /* 選んだカードの面 */
  --dot: 12px;                    /* 印の直径。JS もここから読む */
  min-width: 0;
  max-width: 560px;
  margin: 0;
  padding: 0;
  border: 0;
  color: var(--ink);
}

.choice__legend {
  margin-bottom: 16px;
  padding: 0;
  font-size: 20px;
  font-weight: 700;
}

.choice__list {
  position: relative;
  display: grid;
  gap: 12px;
}

.choice__option {
  position: relative;
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: 16px;
  padding: 16px 24px;
  border-radius: 4px;
  box-shadow: inset 0 0 0 1px var(--line);
  cursor: pointer;
  transition: background-color 0.3s, box-shadow 0.3s;
}

/* 選んだカードは、白い面と 2px の枠で示す */
.choice__option:has(:checked) {
  background-color: var(--surface);
  box-shadow: inset 0 0 0 2px var(--ink);
}

.choice__option:has(:focus-visible) {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}

/* 本物の radio は見えなくするだけ(display: none にすると、キーボードで選べなくなる) */
.choice__input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: 0;
  opacity: 0;
  pointer-events: none;
}

.choice__ring {
  width: 24px;
  height: 24px;
  box-sizing: border-box;
  border: 2px solid var(--ring);
  border-radius: 50%;
  transition: border-color 0.3s;
}

.choice__option:has(:checked) .choice__ring { border-color: var(--ink); }
.choice__text { display: grid; gap: 2px; }
.choice__name { font-size: 16px; font-weight: 700; letter-spacing: 0.02em; }
.choice__note { font-size: 13px; letter-spacing: 0.04em; color: var(--mute); }
.choice__price { font-size: 15px; font-weight: 700; font-variant-numeric: tabular-nums; }

/* 印の層はカードより手前に置き、押した先を奪わないようにする */
.choice__track {
  position: absolute;
  inset: 0;
  z-index: 1;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
}

.choice__dot { fill: var(--ink); }

@media (prefers-reduced-motion: reduce) {
  .choice__option,
  .choice__ring { transition: none; }
}

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const LAG = 0.08; // 後ろの端が遅れて付いてくる時間(秒)。長くすると大きく伸びる

document.querySelectorAll(".choice").forEach((group) => {
  const track = group.querySelector(".choice__track");
  const dot = group.querySelector(".choice__dot");
  const inputs = [...group.querySelectorAll(".choice__input")];
  const edge = { top: 0, bottom: 0 }; // 印の上端と下端(SVG の中の座標)
  let current = inputs.find((input) => input.checked) ?? null;
  let r = 0;

  // 丸の中心。丸の位置は CSS が決めるので、使うたびに測る
  const centerOf = (input) => {
    const ring = input.parentElement.querySelector(".choice__ring").getBoundingClientRect();
    const box = track.getBoundingClientRect();
    return { x: ring.left - box.left + ring.width / 2, y: ring.top - box.top + ring.height / 2 };
  };

  // rx を半径のままにして y と高さだけを書き換えると、どの長さでも端は丸いまま伸びる
  const draw = () => {
    dot.setAttribute("y", edge.top);
    dot.setAttribute("height", Math.max(edge.bottom - edge.top, 0));
  };

  // 動かさずに、選んでいる丸へ置く(最初と、書体や画面の幅で行の高さが変わったとき)
  const place = () => {
    gsap.killTweensOf(edge);
    dot.style.visibility = current ? "visible" : "hidden";
    if (!current) return;
    r = parseFloat(getComputedStyle(group).getPropertyValue("--dot")) / 2;
    const c = centerOf(current);
    edge.top = c.y - r;
    edge.bottom = c.y + r;
    dot.setAttribute("x", c.x - r);
    dot.setAttribute("width", r * 2);
    dot.setAttribute("rx", r);
    draw();
  };

  // クリックでも矢印キーでも、選ばれた radio から change が届く
  inputs.forEach((input) => {
    input.addEventListener("change", () => {
      const from = current;
      current = input;
      if (!from || reduceMotion) return place(); // 初めて選んだとき・動きを減らす設定では、その場に出す
      const to = centerOf(input).y;
      const down = to > (edge.top + edge.bottom) / 2; // 移る途中で選び直しても、今の印の位置から向かう
      const duration = 0.4 + 0.1 * Math.abs(inputs.indexOf(input) - inputs.indexOf(from)); // 遠いほど少し長く
      gsap.killTweensOf(edge);
      // 進む向きの端が先に出て、後ろの端が少し遅れて付いてくる。その間だけ印が伸びる
      gsap.to(edge, { [down ? "bottom" : "top"]: to + (down ? r : -r), duration, ease: "power3.inOut", onUpdate: draw });
      gsap.to(edge, { [down ? "top" : "bottom"]: to + (down ? -r : r), duration, delay: LAG, ease: "power3.inOut", onUpdate: draw });
    });
  });

  place();
  document.fonts.ready.then(place);
  window.addEventListener("resize", place);
});

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

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