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

スライドボタンのつまみを右端まで引くと確定し、途中で離すと弾んで戻る

デモ

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

仕様

用途
ボタン
動作
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 を付け、指で引くときにページが一緒にスクロールしないようにします。

弾みの強さ、つまみの後ろの塗りの色、確定の色で印象が変わります。長押しで確定する形と比べて、進み具合が目に見え、やめたいときは指を離すだけで戻れます。予約・送金・購入の確定、アカウントの削除のように、取り消しにくい操作に向いています。

コード

HTML
<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>

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

JavaScript
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();
});

インタラクションデザインについて、
お気軽にご相談ください。

ここに並べているのは、Web サイトの中で動く UI のサンプルです。新しく作るサイトに組み込むことも、いま公開中のサイトに、ボタンやヒーロー、スクロールの演出だけを足すこともできます。設計から実装までの進め方はWeb サイト制作、端末ごとの見え方や表示速度の考え方はレスポンシブウェブサイトのページにまとめています。映像として作る動きをお探しの場合はモーショングラフィックスをご覧ください。