08認証コード再送信タイマーボタン
再送信ボタンを押すと60秒間無効になり、残り秒数がボタンの上でリアルタイムに減っていきます。
使いどころ携帯電話認証の再送信・メール認証の再送
60초 카운트다운중복 클릭 방지aria-live 안내

<main class="stage">
<div class="otp is-demo" id="otp">
<label class="otp__label" for="otp-input">認証コード6桁</label>
<div class="otp__row">
<input class="otp__input" id="otp-input" type="text" inputmode="numeric" maxlength="6" placeholder="000000" autocomplete="one-time-code">
<button class="otp__resend" id="resend" type="button">再送信</button>
</div>
<p class="otp__status" id="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-instant;
$easing: $ease-out;
$color: $subject-blue; // 버튼 활성 색
// ── 데모 무대 ──────────────────────────────────────────
@include stage($stage-paper);
.otp {
box-sizing: border-box;
width: min(280px, 86vw);
background: $subject-cream;
border-radius: $r-card;
padding: $sp-5;
box-shadow: $shadow-raised;
&__label {
display: block;
margin: 0 0 $sp-2;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: $stage-ink;
}
&__row {
display: flex;
gap: $sp-2;
}
&__input {
box-sizing: border-box;
flex: 1 1 auto;
min-width: 0;
border: 2px solid transparent;
border-radius: $r-control;
padding: $sp-3 $sp-3;
font-weight: 600;
font-size: clamp(14px, 4.2vw, 16px);
letter-spacing: .12em;
font-family: inherit;
color: $stage-ink;
background: #fff;
&:focus { outline: none; border-color: $color; }
}
&__resend {
box-sizing: border-box;
flex: 0 0 auto;
border: 0;
border-radius: $r-control;
padding: 0 $sp-3;
font-weight: 700;
font-size: 12px;
font-family: inherit;
color: $stage-paper;
background: $color;
cursor: pointer;
transition: background $duration $easing, opacity $duration $easing;
&:disabled { background: rgba(23, 20, 26, .3); color: $subject-ink; cursor: not-allowed; } // 반투명 먹 위 흰 글자 2.10:1 → 잉크 8.76:1
&:focus-visible { outline: 2px solid $stage-ink; outline-offset: 2px; }
}
&__status {
margin: $sp-2 0 0;
min-height: 14px;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba(23, 20, 26, .6);
}
}
@media (prefers-reduced-motion: reduce) {
.otp__resend { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.otp {
box-sizing: border-box;
width: min(280px, 86vw);
background: #fff7e6;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.otp__label {
display: block;
margin: 0 0 8px;
font-weight: 700;
font-size: 13px;
line-height: 1;
font-family: inherit;
color: #17141a;
}
.otp__row {
display: flex;
gap: 8px;
}
.otp__input {
box-sizing: border-box;
flex: 1 1 auto;
min-width: 0;
border: 2px solid transparent;
border-radius: 12px;
padding: 12px 12px;
font-weight: 600;
font-size: clamp(14px, 4.2vw, 16px);
letter-spacing: 0.12em;
font-family: inherit;
color: #17141a;
background: #fff;
}
.otp__input:focus {
outline: none;
border-color: #2f6df6;
}
.otp__resend {
box-sizing: border-box;
flex: 0 0 auto;
border: 0;
border-radius: 12px;
padding: 0 12px;
font-weight: 700;
font-size: 12px;
font-family: inherit;
color: #ffffff;
background: #2f6df6;
cursor: pointer;
transition: background 140ms ease-out, opacity 140ms ease-out;
}
.otp__resend:disabled {
background: rgba(23, 20, 26, 0.3);
color: #17141a;
cursor: not-allowed;
}
.otp__resend:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.otp__status {
margin: 8px 0 0;
min-height: 14px;
font-size: 11px;
line-height: 1.4;
font-family: inherit;
color: rgba(23, 20, 26, 0.6);
}
@media (prefers-reduced-motion: reduce) {
.otp__resend {
transition: none;
}
}(function () {
var wrap = document.getElementById('otp');
var input = document.getElementById('otp-input');
var btn = document.getElementById('resend');
var status = document.getElementById('status');
var tickTimer = null;
function cooldown(seconds, tickMs, onDone) {
clearInterval(tickTimer);
var left = seconds;
btn.disabled = true;
btn.textContent = left + '秒後に再送信';
status.textContent = '認証コードを再送信しました';
tickTimer = setInterval(function () {
left--;
if (left <= 0) {
clearInterval(tickTimer);
btn.disabled = false;
btn.textContent = '再送信';
if (onDone) onDone();
} else {
btn.textContent = left + '秒後に再送信';
}
}, tickMs);
}
function demoLoop() {
if (!wrap.classList.contains('is-demo')) return;
cooldown(8, 190, function () { setTimeout(demoLoop, 260); });
}
btn.addEventListener('click', function () {
stopDemo();
cooldown(60, 1000);
});
input.addEventListener('pointerdown', stopDemo);
function stopDemo() {
if (!wrap.classList.contains('is-demo')) return;
wrap.classList.remove('is-demo');
clearInterval(tickTimer);
btn.disabled = false;
btn.textContent = '再送信';
status.textContent = '';
}
demoLoop();
})();import { useEffect, useRef, useState } from "react";
import styles from "./OtpResend.module.scss";
export type OtpResendProps = {
seconds?: number;
onResend?: () => void;
};
export default function OtpResend({ seconds = 60, onResend }: OtpResendProps) {
const [code, setCode] = useState("");
const [left, setLeft] = useState(0);
const [status, setStatus] = useState("");
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
return () => { if (timer.current) clearInterval(timer.current); };
}, []);
const resend = () => {
onResend?.();
setStatus("인증번호를 다시 보냈습니다");
setLeft(seconds);
if (timer.current) clearInterval(timer.current);
timer.current = setInterval(() => {
setLeft((prev) => {
if (prev <= 1) {
if (timer.current) clearInterval(timer.current);
return 0;
}
return prev - 1;
});
}, 1000);
};
return (
<div className={styles.otp}>
<label className={styles.label} htmlFor="otp-code">인증번호 6자리</label>
<div className={styles.row}>
<input
className={styles.input}
id="otp-code"
type="text"
inputMode="numeric"
maxLength={6}
placeholder="000000"
autoComplete="one-time-code"
value={code}
onChange={(e: any) => setCode(e.target.value.replace(/\D/g, ""))}
/>
<button
type="button"
className={styles.resend}
disabled={left > 0}
onClick={resend}
>
{left > 0 ? `${left}초 후 재전송` : "재전송"}
</button>
</div>
<p className={styles.status} aria-live="polite">{status}</p>
</div>
);
}08 認証コード再送信タイマーボタン — 計測 累積面積 3.449% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · 入力欄 デザイン 9選 — コピペで動くフォーム部品
このデモが属する記事
入力欄 デザイン 9選 — コピペで動くフォーム部品入力欄 デザインとは、電話番号やカード番号、数量、タグのように会員登録や購入フォームでよく使うのに、素のHTMLだけでは思うように動かない入力ウィジェットのことです。9個のうち5個(タグ・自動伸長・ドラッグ&ドロップ・再送信タイマー・リアル この記事のデモをすべて見る


