デモ
デモを読み込めませんでした。
仕様
- 用途
- ボタン
- 動作
- 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 の送り直しに向いています。
コード
<!-- 待ち時間は 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>
.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;
}
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);
});
});