デモ
デモを読み込めませんでした。
仕様
- 用途
- フォーム
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
丸いハンドルが反対の端へ滑るあいだ、一瞬だけ横に伸びて、着く手前でまた丸に戻ります。同時に背景は淡いグレーからブラックへ。ただ滑るだけより、伸び縮みが加わるぶん勢いがついて見えます。
移動は0.36秒の power3.inOut。ハンドルがいちばん伸びるのは、道のりの3分の1ほど進んだところ。端から離れた位置なので、伸びたハンドルがスイッチの端からはみ出しません。伸びる量は1.28倍の幅までです。
伸ばすのに scaleX を使いたくなりますが、それだと丸い端まで横に引き伸ばされ、楕円に歪みます。そこで幅そのものを補間し、border-radius は高さの半分で固定しました。幅がいくつになっても、ハンドルの端は丸いままです。
オンとオフは、色ではなく明暗で見分けられるようにしています。白いハンドルはいつも背景より明るいので、切り替えの途中でも背景に溶けません。ハンドルの色まで反転させると、途中で背景と同じ明るさになる瞬間があり、ハンドルが一瞬消えたように見えます。
オンとオフの切り替えは、1本のタイムラインを再生するか逆再生するかだけです。切り替えの途中でもう一度押すと、その位置からそのまま引き返します。
コード
<!-- その場で状態が変わるので button。状態は aria-checked、名前は中の文言が持つ -->
<button class="switch" type="button" role="switch" aria-checked="false">
<span class="switch__label">通知を受け取る</span>
<span class="switch__track" aria-hidden="true">
<span class="switch__thumb"></span>
</span>
</button>
.switch {
--ink: #262626; /* オンのときの背景色 */
--off: rgba(38, 38, 38, 0.12); /* オフのときの背景色(罫線の色) */
--knob: #ffffff; /* ノブの色。いつも背景色より明るくしておく */
--track-width: 44px;
--track-height: 24px;
--pad: 3px; /* ノブとトラックの端のすきま */
display: inline-flex;
align-items: center;
gap: 12px;
padding: 0;
border: 0;
background: none;
color: inherit;
font: inherit;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
}
.switch__label {
font-size: 15px;
font-weight: 600;
letter-spacing: 0.04em;
}
.switch__track {
position: relative;
flex: none;
width: var(--track-width);
height: var(--track-height);
border-radius: calc(var(--track-height) / 2);
background-color: var(--off);
}
/* 角丸を高さの半分で固定しておくと、幅を変えても端が丸いままになる */
.switch__thumb {
position: absolute;
left: var(--pad);
top: var(--pad);
width: calc(var(--track-height) - var(--pad) * 2);
height: calc(var(--track-height) - var(--pad) * 2);
border-radius: calc(var(--track-height) / 2);
background-color: var(--knob);
}
.switch:focus-visible {
outline: 2px solid var(--ink);
outline-offset: 4px;
border-radius: 4px;
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
document.querySelectorAll(".switch").forEach((button) => {
const track = button.querySelector(".switch__track");
const thumb = button.querySelector(".switch__thumb");
const styles = getComputedStyle(button);
// 色は CSS で決める。GSAP は var(--name) のままでは補間できないので、値にして渡す
const off = styles.getPropertyValue("--off").trim();
const on = styles.getPropertyValue("--ink").trim();
// 寸法も CSS で決める。レイアウトが決まった大きさから、動く量と伸びた幅を計算する
const knob = thumb.offsetWidth;
const travel = track.offsetWidth - track.offsetHeight;
const stretched = Math.round(knob * 1.28);
const toggle = gsap
.timeline({ paused: true })
.fromTo(track, { backgroundColor: off }, { backgroundColor: on, duration: 0.3, ease: "none" }, 0)
.fromTo(thumb, { x: 0 }, { x: travel, duration: 0.36, ease: "power3.inOut" }, 0)
// scaleX で伸ばすと丸い端まで引き伸ばされて楕円に歪むので、幅そのものを補間する
.fromTo(thumb, { width: knob }, { width: stretched, duration: 0.16, ease: "power2.out" }, 0)
.fromTo(thumb, { width: stretched }, { width: knob, duration: 0.2, ease: "power2.in", immediateRender: false }, 0.16);
// 動きを減らす設定では、ほぼ一瞬で切り替える
if (reduceMotion) toggle.timeScale(20);
// 最初からオンの場合(設定を読み込んだときなど)は、動かさずに終わりの状態にする
if (button.getAttribute("aria-checked") === "true") toggle.progress(1);
// button なので、Enter とスペースでも同じ click が起きる
button.addEventListener("click", () => {
const next = button.getAttribute("aria-checked") !== "true";
button.setAttribute("aria-checked", String(next));
next ? toggle.play() : toggle.reverse();
});
});