デモ
デモを読み込めませんでした。
仕様
- 用途
- フォーム
- 動作
- 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 にすると伸びずに滑るだけになり、横に並べた選択肢なら、上端と下端の代わりに左端と右端を動かします。配送や支払いの方法、料金プラン、アンケートの選択肢に向いています。
コード
<!-- 本物の 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>
.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; }
}
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);
});