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

フォーム

ラジオボタンの選択を示すマーカーが、前の選択肢から滑って移る

デモ

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

仕様

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

しくみと調整

選び直したときに丸い点がしずくのように縦に伸びて見えるのは、点の上端と下端を別々に動かしているからです。進む向きの端が先に動き、後ろの端が少し遅れて付いてくるので、移動中だけカプセルの形になり、次の丸に着くと元の丸に戻ります。移る途中で別の選択肢を選んでも、点は今いる位置から次の丸へ向かいます。

点は選択肢ごとに持たせず、丸の列全体に重ねた1枚の SVG にひとつだけ描いています。丸ごとに印を持たせると、前の丸で消して次の丸で表示するしかなく、そのあいだを滑らせられません。形は rect の rx を半径のまま固定し、y 座標と高さだけを書き換えます。scaleY で引き伸ばすと、丸い端まで伸びて楕円に歪みます。

伸び具合を決めているのは、後ろの端の遅れです。前の端は0.5秒前後の power3.inOut で動かし、後ろの端は0.08秒遅れて動き始めます。この遅れを0.15秒ほどにすると大きくとろりと伸び、0にすると伸びずに丸のまま滑るだけになります。移る時間は選択肢が離れるほど長くしてあるので、遠くへ移るときほど長く伸びます。

土台は本物の radio のグループで、見えなくした input を label で包んであり、矢印キーでも change イベントが届きます。丸の位置は CSS のレイアウトで決まるので、Web フォントの読み込みが終わったときや画面の幅が変わったときに測り直しています。選んでいない丸の線は、罫線と同じ淡い色では背景に溶けてしまうので、グレーにしました。選択肢を横に並べるなら、上端と下端の代わりに左端と右端を動かします。

コード

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

/* ドットの SVG はカードより手前に置き、クリックを奪わないようにする */
.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 サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。