デモ
デモを読み込めませんでした。
仕様
- 用途
- ボタン
- 動作
- DOM
- 種類
- レシピ
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
横長のボタンの左端につまみを置き、右端まで引いたときだけ確定します。引いている間は、つまみの後ろに淡い塗りが付いてきて、案内の文字を覆いながら薄くします。途中で離すと、つまみは左の端へ戻り、端に当たって小さく弾みます。右端に届くと、ボタン全体が成功の緑に塗られ、つまみの矢印がチェックに形を変えて、「予約を確定しました」の文字が下から出ます。
戻りは 0.8 秒の bounce.out です。端で 2 度ほど弾むので、届かなかったことが手に伝わります。矢じりとチェックは、どちらも 3 点の折れ線にしてあるので、path の d の数字をそのまま補間するだけで形が変わり、専用のプラグインは要りません。確定は、離すのを待たずに、右端へ届いた時点で行います。
引く操作だけにすると、キーボードや支援技術を使う人が確定できません。つまみを本物の button にし、Enter / Space で押されたとき(click の detail が 0)は、つまみを右端まで送ってから確定します。マウスや指で押しただけでは確定せず、少し動かして戻して、引く操作だと伝えます。結果は role="status" の知らせに書き、読み上げにも届けます。
引いている間はポインタを捕まえ(setPointerCapture)、溝の外へはみ出しても追えるようにします。つまみには touch-action の none を付け、指で引くときにページが一緒にスクロールしないようにします。
弾みの強さ、つまみの後ろの塗りの色、確定の色で印象が変わります。長押しで確定する形と比べて、進み具合が目に見え、やめたいときは指を離すだけで戻れます。予約・送金・購入の確定、アカウントの削除のように、取り消しにくい操作に向いています。
コード
<div class="slide-confirm">
<div class="slide-confirm__track">
<span class="slide-confirm__hint" aria-hidden="true">
スライドして予約を確定
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M6 6l6 6-6 6M13 6l6 6-6 6" /></svg>
</span>
<span class="slide-confirm__fill" aria-hidden="true"></span>
<span class="slide-confirm__done" aria-hidden="true">予約を確定しました</span>
<!-- つまみは本物のボタン。引けない人は Enter / Space(と支援技術の操作)で確定できる -->
<button class="slide-confirm__knob" type="button" aria-label="予約を確定する" aria-describedby="slide-confirm-note">
<svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true" focusable="false">
<path class="slide-confirm__shaft" d="M5 12 L19 12" />
<!-- 矢じり。確定のチェックと同じ 3 点の折れ線 -->
<path class="slide-confirm__head" d="M13 18 L19 12 L13 6" />
</svg>
</button>
</div>
<p class="slide-confirm__note" id="slide-confirm-note" role="status">つまみを右端まで引くか、Enterキーで確定します。</p>
</div>
.slide-confirm {
--ink: #eeece6; /* つまみと文字の色 */
--ground: #111111; /* 溝の地の色。矢印の色にもなる */
--surface: #262626; /* まわりの面 */
--mute: #a8a6a0; /* 案内の文字 */
--line: rgba(238, 236, 230, 0.14);
--trail: #31373c; /* つまみの後ろの塗り(地にスレートを混ぜた色) */
--success: #4f8a64; /* 確定の塗りとチェック */
--success-text: #6ba37f; /* 確定の知らせ(小さな文字でも読める濃さ) */
--height: 64px;
--pad: 8px; /* つまみと溝のすきま */
max-width: 400px;
padding: 24px;
border-radius: 4px;
background-color: var(--surface);
color: var(--ink);
}
.slide-confirm__track {
position: relative;
height: var(--height);
border-radius: 999px;
background-color: var(--ground);
box-shadow: inset 0 0 0 1px var(--line);
user-select: none;
-webkit-user-select: none;
}
.slide-confirm__hint,
.slide-confirm__done {
position: absolute;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
letter-spacing: 0.04em;
white-space: nowrap;
}
/* 案内の文字は、つまみの右から溝の右端までの中央に置く */
.slide-confirm__hint {
left: var(--height);
right: 16px;
color: var(--mute);
font-size: 15px;
font-weight: 600;
}
.slide-confirm__hint svg { fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }
/* つまみの後ろの塗り。幅は JS がつまみの位置から決める */
.slide-confirm__fill { position: absolute; left: 0; top: 0; bottom: 0; width: var(--height); border-radius: inherit; background-color: var(--trail); }
/* 確定の文字は、右端のつまみを除いた幅の中央に置く */
.slide-confirm__done { left: 0; right: var(--height); color: #ffffff; font-size: 16px; font-weight: 700; opacity: 0; }
.slide-confirm__knob {
position: absolute;
left: var(--pad);
top: var(--pad);
display: grid;
place-items: center;
width: calc(var(--height) - var(--pad) * 2);
height: calc(var(--height) - var(--pad) * 2);
padding: 0;
border: 0;
border-radius: 50%;
background-color: var(--ink);
color: var(--ground);
cursor: grab;
touch-action: none; /* 指で引くときに、ページが一緒にスクロールしないように */
transition: box-shadow 0.15s;
}
.slide-confirm__knob svg { overflow: visible; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.slide-confirm__knob:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
.slide-confirm.is-dragging .slide-confirm__knob { cursor: grabbing; box-shadow: 0 0 0 6px rgba(238, 236, 230, 0.18); }
.slide-confirm__knob[aria-disabled="true"] { cursor: default; }
.slide-confirm__note { margin: 12px 0 0; color: var(--mute); font-size: 13px; line-height: 1.6; letter-spacing: 0.04em; }
.slide-confirm.is-done .slide-confirm__note { color: var(--success-text); font-weight: 600; }
@media (prefers-reduced-motion: reduce) {
.slide-confirm__knob { transition: none; }
}
import { gsap } from "gsap";
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 20 : 1; // 動きを減らす設定では、ほぼ一瞬で済ませる
const CHECK = "M5.5 12.5 L10 17 L18.5 7.5"; // 矢じりと同じ 3 点の折れ線なので、d の数字をそのまま補間できる
document.querySelectorAll(".slide-confirm").forEach((root) => {
const [track, hint, fill, done, knob, note] = ["track", "hint", "fill", "done", "knob", "note"].map((part) => root.querySelector(`.slide-confirm__${part}`));
const [shaft, head] = knob.querySelectorAll("path");
const success = getComputedStyle(root).getPropertyValue("--success").trim();
let x = 0; // つまみの位置(左の端からの px)
let max = 0; // つまみが動ける幅
let from = 0, start = 0, moved = false; // つかんだ位置と、引いたかどうか
let confirmed = false, back = null;
// つまみ・後ろの塗り・案内の文字を、つまみの位置から描く
const draw = (value) => {
x = value;
gsap.set(knob, { x });
fill.style.width = `${x + knob.offsetLeft * 2 + knob.offsetWidth}px`;
hint.style.opacity = String(gsap.utils.clamp(0, 1, 1 - x / (max * 0.55)));
};
const measure = () => {
max = track.clientWidth - knob.offsetWidth - knob.offsetLeft * 2;
draw(confirmed ? max : Math.min(x, max));
};
// つまみを value まで動かす(途中で離したときの戻り・キーボードで確定するときの送り)
const slide = (value, vars) => {
back?.kill();
const state = { x };
back = gsap.to(state, { x: value, ...vars, duration: vars.duration / SPEED, onUpdate: () => draw(state.x) });
};
const confirm = () => {
confirmed = true;
back?.kill();
draw(max);
root.classList.add("is-done");
knob.setAttribute("aria-disabled", "true");
knob.setAttribute("aria-label", "予約を確定しました");
note.textContent = "予約が確定しました。確認のメールを送りました。";
// ボタン全体を緑に塗り、矢じりをチェックに変え、確定の文字を下から出す
gsap
.timeline()
.to(fill, { backgroundColor: success, duration: 0.3, ease: "none" }, 0)
.to(shaft, { opacity: 0, duration: 0.12, ease: "none" }, 0)
.to(knob, { color: success, duration: 0.25, ease: "none" }, 0)
.to(head, { attr: { d: CHECK }, duration: 0.45, ease: "back.out(2)" }, 0.05)
.fromTo(done, { opacity: 0, y: 10 }, { opacity: 1, y: 0, duration: 0.35, ease: "power2.out" }, 0.15)
.timeScale(SPEED);
root.dispatchEvent(new CustomEvent("slideconfirm", { bubbles: true })); // ここで予約の処理を呼ぶ
};
knob.addEventListener("pointerdown", (event) => {
if (confirmed) return;
knob.setPointerCapture(event.pointerId); // 溝の外へはみ出しても追える
back?.kill();
from = event.clientX - x;
start = event.clientX;
moved = false;
root.classList.add("is-dragging");
});
knob.addEventListener("pointermove", (event) => {
if (confirmed || !knob.hasPointerCapture(event.pointerId)) return;
moved ||= Math.abs(event.clientX - start) > 4;
draw(gsap.utils.clamp(0, max, event.clientX - from));
if (x < max) return;
knob.releasePointerCapture(event.pointerId); // 右端に届いたら、離すのを待たずに確定する
root.classList.remove("is-dragging");
confirm();
});
// 途中で離したら、左の端へ戻って端で弾む
const release = () => {
root.classList.remove("is-dragging");
if (!confirmed && x > 0) slide(0, { duration: 0.8, ease: "bounce.out" });
};
knob.addEventListener("pointerup", release);
knob.addEventListener("pointercancel", release);
knob.addEventListener("click", (event) => {
if (confirmed) return;
// Enter / Space と支援技術の操作は detail が 0。右端まで送ってから確定する
if (event.detail === 0) {
slide(max, { duration: 0.45, ease: "power2.in", onComplete: confirm });
} else if (!moved && x === 0) {
// マウスや指で押しただけでは確定しない。少し動かして戻し、引く操作だと伝える
back?.kill();
const state = { x: 0 };
back = gsap.timeline({ onUpdate: () => draw(state.x) })
.to(state, { x: 24, duration: 0.15, ease: "power2.out" })
.to(state, { x: 0, duration: 0.6, ease: "bounce.out" })
.timeScale(SPEED);
}
});
new ResizeObserver(measure).observe(track);
measure();
});