デモ
デモを読み込めませんでした。
仕様
- 用途
- フォーム
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
つまみを端から端へ滑らせながら、地の色を淡い灰から墨色へ変えます。つまみは動き出すと横に伸び、着く前にまた丸へ戻ります。伸び縮みがあるだけで、ただ滑るより勢いがついて見えます。
つまみの移動は 0.36 秒の power3.inOut、地の色は 0.3 秒で切り替わります。伸びは 0.16 秒で 1.28 倍まで、そこから 0.2 秒かけて戻します。いちばん伸びるのが道のりの 3 分の 1 ほどの位置なので、伸びたつまみが枠の端からはみ出しません。
伸ばすのに scaleX を使うと、丸い端まで一緒に引き伸ばされて楕円に歪みます。幅そのものを補間し、border-radius を高さの半分で固定しておくと、どの幅でも端は丸いままです。
オンであることは、色ではなく明暗で伝えます。白いつまみをいつも地より明るくしておけば、切り替えの途中でもつまみが地に溶けません。つまみまで反転させると、途中で地と同じ明るさになり、一瞬つまみが消えて見えます。
オンとオフは 1 本のタイムラインの再生と逆再生なので、途中で押し返してもその位置から引き返します。押す部品を button にしておけば Enter とスペースでも切り替わり、状態は aria-checked が伝えます。設定画面、通知の受け取り、月額と年額の切り替えに向いています。
コード
<!-- その場で状態が変わるので 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();
});
});