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

ボタンを長押しすると周りの輪が満ちて確定し、途中で離すと戻る

デモ

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

仕様

用途
ボタン
動作
DOM
種類
レシピ
使うもの
DrawSVGPlugin
動作確認
three 0.186.0・gsap 3.15.0
公開日

しくみと調整

押している間だけ、ボタンの周りの輪を DrawSVGPlugin で 12 時から時計回りに描き、1.5 秒で満ちきったら確定します。途中で離すと、輪はその長さから 0 へ戻り、下の案内が「もう少し長く押してください」に変わります。確定すると、ボタンがエラーの色で塗られ、ゴミ箱がチェックに替わって軽く弾みます。削除のように取り消せない操作を、うっかりのクリックで実行させないための部品です。

輪は一定の速さ(ease は none)で描きます。押し直したときは、残りの長さに比例した時間で満たすので、戻りきる前に押し直しても、同じ速さで続きから満ちます。戻りは 0.45 秒の power2.out で、押している間はボタンを 0.94 倍に縮めます。輪は circle を −90° 回して、12 時から始めています。

つまずきやすいのは、丸い線端(stroke-linecap の round)です。長さが 0 になる直前に、12 時の位置へ点が残ります。戻りきる 0.15 秒前に輪を透明にし、押し直したときに戻します。満たす動きと戻す動きは同じ輪を取り合うので、前の動きを止めてから次を作ります。

キーボードでは、Space(と Enter)を押している間だけ満ち、離すと戻ります。押しっぱなしで繰り返し届く keydown は repeat を見て無視し、キーで起きる click は止めます。キーを経ない支援技術の「押す」(click の detail が 0)は長押しできないので、輪を短く満たして確定します。指で長押ししてもメニューや文字の選択が出ないよう、user-select なども止めます。動きを減らす設定でも、満ちる 1.5 秒は確かめるための時間なので変えず、戻りと確定の動きだけをほぼ一瞬にします。

押し続ける時間を長くすると慎重に、短くすると軽やかになります。輪の太さと隙間、塗りの色でも印象が変わります。スライドで確定する形と比べて、場所をとらず、指を動かさずに済みます。削除・退会・送金の確定・取り消せない公開など、うっかり実行させたくない操作に向いています。

コード

HTML
<!-- 取り消せない削除の確かめ。ボタンを押している間だけ周りの輪が満ち、満ちきると確定する -->
<section class="hold-confirm" aria-labelledby="hold-confirm-title">
  <!-- 和文は語の途中でも折り返すので、狭い画面で切れてほしい所ごとに span で区切る -->
  <h2 class="hold-confirm__title" id="hold-confirm-title"><span>「春の新作特集」を</span><span>削除しますか?</span></h2>
  <p class="hold-confirm__text"><span>プロジェクトと48件のファイル、</span><span>変更履歴を</span><br><span>すべて削除します。</span><span>この操作は取り消せません。</span></p>
  <div class="hold-confirm__control">
    <svg class="hold-confirm__ring" viewBox="0 0 146 146" aria-hidden="true" focusable="false">
      <circle class="hold-confirm__track" cx="73" cy="73" r="67" />
      <!-- 進み具合の輪。12 時から時計回りに満ちるよう、-90° 回しておく -->
      <circle class="hold-confirm__progress" cx="73" cy="73" r="67" transform="rotate(-90 73 73)" />
    </svg>
    <!-- 押し方の案内は、下の文(aria-describedby)で伝える -->
    <button class="hold-confirm__button" type="button" aria-label="プロジェクトを削除" aria-describedby="hold-confirm-caption">
      <svg class="hold-confirm__trash" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
        <path d="M5 7h14M10 11v6M14 11v6M6.5 7l.8 12a2 2 0 0 0 2 1.8h5.4a2 2 0 0 0 2-1.8L17.5 7M9.5 7V4.8a.8.8 0 0 1 .8-.8h3.4a.8.8 0 0 1 .8.8V7" />
      </svg>
      <svg class="hold-confirm__check" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
        <path d="M5.5 12.5l4.2 4.2L18.5 7.8" />
      </svg>
    </button>
  </div>
  <!-- 案内の文。押している間・途中で離したとき・確定したときの文は data-* に置き、JS が差し替える -->
  <p class="hold-confirm__caption" id="hold-confirm-caption" role="status" data-holding="そのまま押し続けてください…" data-early="もう少し長く押してください" data-done="プロジェクトを削除しました">長押しで削除します</p>
</section>

CSS
.hold-confirm {
  --ink: #262626;                  /* 見出しと、押している間の案内 */
  --mute: #6b6b6b;                 /* 説明と休みの案内(小さい文字でも読める濃さ) */
  --line: rgba(38, 38, 38, 0.12);  /* 輪の下地 */
  --surface: #ffffff;
  --danger: #c4553d;               /* 輪・ゴミ箱・確定の塗り(取り消せない操作の色) */
  --danger-text: #a8432d;          /* 注意の案内(小さい文字でも読める濃さ) */
  --tint: #f8ebe8;                 /* 確定前のボタンの地(面の白にエラーの色を 12% 混ぜた色) */
  --on-danger: #ffffff;            /* 確定後のチェック */
  max-width: 512px;
  box-sizing: border-box;
  padding: 56px 40px;
  border-radius: 4px;
  background: var(--surface);
  color: var(--ink);
  text-align: center;
}

.hold-confirm__title { margin: 0; font-size: 22px; font-weight: 700; line-height: 1.3; letter-spacing: 0.04em; }
.hold-confirm__text { margin: 16px 0 0; color: var(--mute); font-size: 14px; line-height: 1.8; letter-spacing: 0.04em; }
.hold-confirm__title > span,
.hold-confirm__text > span { display: inline-block; }

/* 輪(直径 146px)の中央に、直径 112px のボタンを置く。輪とボタンの間は 8px */
.hold-confirm__control { position: relative; width: 146px; height: 146px; margin: 56px auto 0; }
.hold-confirm__ring { position: absolute; inset: 0; overflow: visible; fill: none; stroke-width: 6; }
.hold-confirm__track { stroke: var(--line); }

/* 進み具合の輪。stroke-dasharray は CSS に書かない(DrawSVGPlugin が要素の style に書くのを打ち消す) */
.hold-confirm__progress { stroke: var(--danger); stroke-linecap: round; opacity: 0; }

.hold-confirm__button {
  position: absolute;
  left: 17px;
  top: 17px;
  display: grid;
  place-items: center;
  width: 112px;
  height: 112px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background-color: var(--tint);
  cursor: pointer;
  touch-action: none; /* 押している間に指が少し動いても、スクロールが始まって途切れないように */
  user-select: none;  /* 長押しで文字が選ばれたり、メニューが出たりしないように */
  -webkit-user-select: none;
  -webkit-touch-callout: none;
  -webkit-tap-highlight-color: transparent;
}

.hold-confirm__button:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
.hold-confirm__button[aria-disabled="true"] { cursor: default; }

/* ゴミ箱とチェックを同じ位置に重ねる。チェックは確定したときに描く */
.hold-confirm__button > svg { grid-area: 1 / 1; overflow: visible; fill: none; stroke-linecap: round; stroke-linejoin: round; }
.hold-confirm__trash { width: 45px; height: 45px; stroke: var(--danger); stroke-width: 1.9; }
.hold-confirm__check { width: 52px; height: 52px; stroke: var(--on-danger); stroke-width: 2.8; opacity: 0; }

.hold-confirm__caption { margin: 24px 0 0; color: var(--mute); font-size: 15px; font-weight: 600; line-height: 1.5; letter-spacing: 0.04em; }
.hold-confirm[data-state="holding"] .hold-confirm__caption { color: var(--ink); }
.hold-confirm[data-state="early"] .hold-confirm__caption,
.hold-confirm[data-state="done"] .hold-confirm__caption { color: var(--danger-text); }

@media (max-width: 479px) {
  .hold-confirm { padding: 48px 24px; }
}

JavaScript
import { gsap } from "gsap";
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";

gsap.registerPlugin(DrawSVGPlugin);

const HOLD = 1.5; // 満ちるまで押し続ける時間(秒)
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 動きを減らす設定では、戻りや確定の動きをほぼ一瞬にする(満ちる時間は確かめるための時間なので変えない)
const SPEED = reduceMotion ? 20 : 1;
const isKey = (event) => event.key === " " || event.key === "Enter";

document.querySelectorAll(".hold-confirm").forEach((root) => {
  const [button, ring, trash, check, caption] = ["button", "progress", "trash", "check", "caption"].map((part) => root.querySelector(`.hold-confirm__${part}`));
  const texts = { idle: caption.textContent, ...caption.dataset }; // 案内の文は HTML から読む
  const danger = getComputedStyle(root).getPropertyValue("--danger").trim(); // GSAP は var() のままでは補間できない
  let fill, back, squash, talk;
  let holding = false;
  let done = false;

  gsap.set(ring, { drawSVG: "0% 0%" });
  gsap.set(check.firstElementChild, { drawSVG: "0%" });

  // 案内の文を差し替える。前の文を消しきってから、次の文を下から出す(重ねると 2 つの文が重なって読めない)
  const say = (state) => {
    talk?.kill();
    talk = gsap.timeline()
      .to(caption, { opacity: 0, y: 6, duration: 0.15, ease: "none" })
      .call(() => {
        caption.textContent = texts[state];
        root.dataset.state = state;
      })
      .to(caption, { opacity: 1, y: 0, duration: 0.15, ease: "none" })
      .timeScale(SPEED);
  };

  // 満ちきったら確定: エラーの色で塗り、ゴミ箱をチェックに替えて、軽く弾ませる
  const confirm = () => {
    holding = false;
    done = true;
    button.setAttribute("aria-disabled", "true");
    squash.kill();
    gsap.timeline()
      .to(button, { scale: 1.06, duration: 0.16, ease: "power2.out" }, 0)
      .to(button, { scale: 1, duration: 0.45, ease: "back.out(2.5)" }, 0.16)
      .to(button, { backgroundColor: danger, duration: 0.25, ease: "none" }, 0)
      .to(trash, { autoAlpha: 0, duration: 0.15, ease: "none" }, 0)
      .set(check, { opacity: 1 }, 0.1)
      .to(check.firstElementChild, { drawSVG: "100%", duration: 0.35, ease: "power2.out" }, 0.1)
      .timeScale(SPEED);
    say("done");
    root.dispatchEvent(new CustomEvent("holdconfirm", { bubbles: true })); // ここで削除の処理を呼ぶ
  };

  // 押している間: 輪を、残りの長さに比例した時間で満たす(戻りきる前に押し直しても、同じ速さで続きから満ちる)
  const hold = (duration = HOLD) => {
    if (done || holding) return;
    holding = true;
    back?.kill();
    const [, end] = DrawSVGPlugin.getPosition(ring);
    gsap.set(ring, { opacity: 1 });
    fill = gsap.to(ring, { drawSVG: "0% 100%", duration: duration * Math.max(0, 1 - end / DrawSVGPlugin.getLength(ring)), ease: "none", onComplete: confirm });
    squash?.kill();
    squash = gsap.to(button, { scale: 0.94, duration: 0.12 / SPEED, ease: "power2.out" });
    say("holding");
  };

  // 途中で離したら、満たすのをやめて、その長さから 0 へ戻す
  const release = () => {
    if (!holding) return;
    holding = false;
    fill.kill();
    back = gsap.timeline()
      .to(ring, { drawSVG: "0% 0%", duration: 0.45, ease: "power2.out" }, 0)
      .to(ring, { opacity: 0, duration: 0.08, ease: "none" }, 0.3) // 丸い線端は、長さ 0 の直前に点が残るので先に消す
      .timeScale(SPEED);
    squash.kill();
    squash = gsap.to(button, { scale: 1, duration: 0.3 / SPEED, ease: "back.out(3)" });
    say("early");
  };

  button.addEventListener("pointerdown", (event) => event.button === 0 && hold());
  ["pointerup", "pointerleave", "pointercancel", "blur"].forEach((type) => button.addEventListener(type, release));
  button.addEventListener("contextmenu", (event) => event.preventDefault()); // 指で長押ししたときのメニューを出さない
  // キーボードでは、Space(と Enter)を押している間だけ満ちる。押しっぱなしで繰り返し届く keydown は無視する
  button.addEventListener("keydown", (event) => {
    if (!isKey(event)) return;
    event.preventDefault(); // キーで起きる click を止める(下の click は支援技術だけのものにする)
    if (!event.repeat) hold();
  });
  button.addEventListener("keyup", (event) => {
    if (!isKey(event)) return;
    event.preventDefault();
    release();
  });
  // キーを経ない支援技術の「押す」(click の detail が 0)は長押しできないので、輪を短く満たして確定する
  button.addEventListener("click", (event) => event.detail === 0 && hold(0.4));
});

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

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