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

「コードを再送信」のボタンを押すと、待ち時間の秒数が縦に送られて減り、0 になると押せる状態に戻る

デモ

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

仕様

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

しくみと調整

押すと、ボタンの地が主ボタンの墨色から押せないボタンの灰色へ移り、文言が「コードを再送信」から「再送信まで30秒」に替わります。数字は桁ごとの窓に入れてあり、1 秒ごとに、変わった桁だけ今の数字を上へ抜いて次の数字を下から入れます。ボタンの下の細い線は残りの時間の割合で右端から縮み、0 になると色と文言が元に戻ります。

数字を送るのは 0.28 秒の power3.out で、1 秒の間の初めのうちに止まり、読める時間を長く残します。文言の入れ替えは、前の文言を 0.1 秒で消してから次の文言を 0.18 秒で出すので、2 つが重なって読めなくなる時間がありません。上の桁が 0 になるとき(10 → 9 など)は、その窓の幅を 1 字分から 0 へ畳み、「09秒」ではなく「9秒」と詰めます。ボタンの幅は全部の桁がある幅で止めておくので、畳んでもボタンと線の長さは変わりません。

待っている間に disabled を付けると、押した直後に焦点がボタンから外れ、キーボードで操作している人はページの先頭へ戻されます。押せないことは aria-disabled で伝え、待っている間の押下は click の受け口で無視します。時計は GSAP の ticker で進めますが、残りは実際の時刻から計算するので、裏のタブで描画が止まっても、戻ったときに残りの秒数が合います。

1 秒ごとの数字は読み上げに届けず(aria-hidden)、押したときに role="status" の領域へ「新しいコードを送りました」と待ち時間を、押せるようになったときにもひとこと書きます。動きを減らす設定では、数字を送らずに置き換え、色と文言も一瞬で切り替えます。待ち時間そのものは変えません。

長押しで確定するボタンや、認証コードの入力欄、桁を回して止めるカウンターとは違い、押したあとの「待ち」を見せる部品です。数字は 1 段ずつ送るだけで、行き過ぎや回転は付けていません。待ち時間はボタンの data-wait で決めます(このデモでは 10 秒に縮めています)。確認コードや確認メールの再送信、パスワードの再設定のメール、SMS の送り直しに向いています。

コード

HTML
<!-- 待ち時間は data-wait(秒)。数字の窓は JS が桁の数だけ作る -->
<div class="resend">
  <div class="resend__main">
    <button class="resend__button" type="button" data-wait="30">
      <span class="resend__label">コードを再送信</span>
      <span class="resend__label resend__label--wait" aria-hidden="true">再送信まで<span class="resend__count"></span>秒</span>
    </button>
    <span class="resend__bar" aria-hidden="true"></span>
  </div>
  <p class="resend__status" role="status" data-sent="新しいコードを送りました" data-note="{n}秒後に、もう一度送れます。" data-ready="もう一度コードを送れるようになりました。"></p>
</div>

CSS
.resend {
  --ink: #262626;      /* 押せるときの地・残りの線 */
  --disabled: #c8c7c3; /* 待っている間の地 */
  --label: #ffffff;    /* ボタンの文字 */
  --note: #6b6b6b;     /* 下の知らせ */
  --lock: 0;           /* 0 = 押せる色、1 = 待っている色。JS が動かす */
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}

/* 線の幅をボタンにそろえる(下の知らせの長さに引っぱられない) */
.resend__main { display: grid; gap: 12px; }

.resend__button {
  display: inline-grid;
  height: 48px;
  padding: 0 32px;
  border: 0;
  border-radius: 999px;
  overflow: hidden; /* 送る数字を、ボタンの高さの窓で切る */
  background-color: color-mix(in srgb, var(--disabled) calc(var(--lock) * 100%), var(--ink));
  color: var(--label);
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.04em;
  white-space: nowrap;
  cursor: pointer;
}

.resend__button[aria-disabled="true"] { cursor: default; }

.resend__button:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 4px;
}

/* 2 つの文言を同じ升に重ねる。ボタンの幅は長いほうで決まり、入れ替わっても変わらない */
.resend__label {
  grid-area: 1 / 1;
  display: flex;
  justify-content: center;
  align-items: center;
  line-height: 48px;
}

.resend__label--wait {
  opacity: 0;
  font-variant-numeric: tabular-nums;
}

.resend__count { display: flex; margin: 0 0.06em; }

/* 桁ごとの窓。中の数字を重ねて置き、縦に送る */
.resend__digit {
  display: grid;
  width: 1ch;
  height: 48px;
  overflow: hidden;
}

.resend__digit > span { grid-area: 1 / 1; text-align: center; }

/* 残りの時間の線。ボタンと同じ幅から、右端が左へ縮む */
.resend__bar {
  height: 3px;
  background: var(--ink);
  transform: scaleX(0);
  transform-origin: left center;
}

.resend__status {
  min-height: 1.5em;
  margin: 0;
  color: var(--note);
  font-size: 14px;
  letter-spacing: 0.04em;
}

.resend__hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

JavaScript
import { gsap } from "gsap";

const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const SPEED = reduceMotion ? 0 : 1; // 動きを減らす設定では、数字を送らずに置き換える(待ち時間そのものは変えない)

document.querySelectorAll(".resend").forEach((root) => {
  const button = root.querySelector(".resend__button");
  const [ready, waiting] = button.querySelectorAll(".resend__label");
  const count = button.querySelector(".resend__count");
  const bar = root.querySelector(".resend__bar");
  const status = root.querySelector(".resend__status");
  const wait = Number(button.dataset.wait) || 30;
  const places = String(wait).length;

  // 桁ごとに窓を作る。上の桁が 0 になったら、窓の幅を畳んで「9秒」のように詰める
  const cells = Array.from({ length: places }, () => {
    const cell = document.createElement("span");
    cell.className = "resend__digit";
    cell.append(document.createElement("span"));
    count.append(cell);
    return cell;
  });

  // 変わった桁だけ、今の数字を上へ抜き、次の数字を下から入れる
  const show = (value, roll) => {
    [...String(value).padStart(places, " ")].forEach((ch, i) => {
      const cell = cells[i];
      const digit = ch.trim();
      const old = cell.lastElementChild;
      if (!roll) {
        cell.replaceChildren(Object.assign(document.createElement("span"), { textContent: digit }));
        gsap.set(cell, { width: digit ? "1ch" : 0 });
        return;
      }
      if (old.textContent === digit) return;
      const next = Object.assign(document.createElement("span"), { textContent: digit });
      cell.append(next);
      gsap.fromTo(next, { yPercent: 100 }, { yPercent: 0, duration: 0.28 * SPEED, ease: "power3.out" });
      gsap.to(old, { yPercent: -100, duration: 0.28 * SPEED, ease: "power3.out", onComplete: () => old.remove() });
      if (!digit) gsap.to(cell, { width: 0, duration: 0.28 * SPEED, ease: "power3.out" });
    });
  };

  const swap = (from, to) => {
    gsap.to(from, { opacity: 0, duration: 0.1 * SPEED, ease: "none" });
    gsap.to(to, { opacity: 1, duration: 0.18 * SPEED, delay: 0.08 * SPEED, ease: "none" });
  };

  const release = () => {
    button.removeAttribute("aria-disabled");
    swap(waiting, ready);
    gsap.to(button, { "--lock": 0, duration: 0.3 * SPEED, ease: "power1.inOut" });
    gsap.to(status, { opacity: 0, duration: 0.2 * SPEED });
    status.textContent = status.dataset.ready; // 見た目は消し、読み上げには届ける
  };

  let shown = wait;
  let end = 0;
  // 時計は実際の時刻から計算する(裏のタブで描画が止まっても、戻ったときに残りが合う)
  const tick = () => {
    const left = Math.max(0, (end - performance.now()) / 1000);
    gsap.set(bar, { scaleX: left / wait });
    const value = Math.ceil(left);
    if (value !== shown && value > 0) show((shown = value), true);
    if (left > 0) return;
    gsap.ticker.remove(tick);
    release();
  };

  button.addEventListener("click", () => {
    // 待っている間は受けない。disabled にすると焦点が外れるので、aria-disabled で伝える
    if (button.getAttribute("aria-disabled") === "true") return;
    button.dispatchEvent(new CustomEvent("resend", { bubbles: true })); // ここで受けて、コードを送る
    button.setAttribute("aria-disabled", "true");
    show((shown = wait), false);
    // 上の桁を畳んでもボタンと線の幅が変わらないよう、全部の桁がある幅で止める
    button.style.minWidth = "";
    button.style.minWidth = `${button.getBoundingClientRect().width}px`;
    swap(ready, waiting);
    gsap.to(button, { "--lock": 1, duration: 0.25 * SPEED, ease: "power1.out" });
    const note = Object.assign(document.createElement("span"), { className: "resend__hidden" });
    note.textContent = status.dataset.note.replace("{n}", wait);
    status.replaceChildren(status.dataset.sent, note);
    gsap.fromTo(status, { opacity: 0 }, { opacity: 1, duration: 0.25 * SPEED, delay: 0.1 * SPEED });
    end = performance.now() + wait * 1000;
    gsap.ticker.add(tick);
  });
});

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

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