07Status icon that waits, then rules
When typing pauses, a spinner spins; if the username passes, a check badge pops in, and if it fails, an X badge shakes its way in.
Best forBoxes that wait on a server, like username checks
rotatescalesetTimeout
<main class="stage">
<div class="dm is-demo" id="dm" data-state="idle">
<label class="dm__label" for="dm-input">Username</label>
<div class="dm__field">
<input class="dm__input" id="dm-input" type="text" autocomplete="off" aria-describedby="dm-say" placeholder="Type more than four characters">
<span class="dm__ghost" aria-hidden="true">bitna shop</span>
<span class="dm__icons" aria-hidden="true">
<span class="dm__ic dm__ic--wait"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M232 128a104 104 0 0 1-208 0c0-41 23.81-78.36 60.66-95.27a8 8 0 0 1 6.68 14.54C60.15 61.59 40 93.27 40 128a88 88 0 0 0 176 0c0-34.73-20.15-66.41-51.34-80.73a8 8 0 0 1 6.68-14.54C208.19 49.64 232 87 232 128"/></g></svg></span>
<span class="dm__ic dm__ic--ok"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
<span class="dm__ic dm__ic--no"><svg viewBox="0 0 256 256" width="20" height="20"><g fill="currentColor"><path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2"/><path d="M165.66 101.66L139.31 128l26.35 26.34a8 8 0 0 1-11.32 11.32L128 139.31l-26.34 26.35a8 8 0 0 1-11.32-11.32L116.69 128l-26.35-26.34a8 8 0 0 1 11.32-11.32L128 116.69l26.34-26.35a8 8 0 0 1 11.32 11.32M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88"/></g></svg></span>
</span>
</div>
<p class="dm__out">
<span class="dm__msgs" aria-hidden="true">
<span class="dm__m" data-m="wait">Checking</span><span class="dm__m" data-m="ok">This username is available</span><span class="dm__m" data-m="no">This username does not fit the rule</span>
</span>
<span class="dm__say" id="dm-say" role="status" aria-live="polite">Enter a username</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-cream;
@include stage($stage-orange);
$ok: #479075;
$no: #4a0f04;
.dm {
box-sizing: border-box;
width: min(300px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.dm__label {
display: block;
margin-bottom: $sp-1;
color: $subject-ink; // 주황 무대 위 크림 2.51:1 → 잉크 5.50:1
font-size: 11px; line-height: 14px; font-weight: 700;
}
.dm__field { position: relative; }
.dm__input {
box-sizing: border-box;
width: 100%; height: 44px;
padding: 0 $sp-10 0 $sp-3;
border: 2px solid rgba(255, 247, 230, .38);
border-radius: $r-control;
background: rgba(23, 20, 26, .18);
color: $color;
font-family: inherit;
font-size: 14px; line-height: 40px; font-weight: 700;
}
.dm__input::placeholder { color: rgba(255, 247, 230, .5); font-weight: 600; }
.dm__input:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
// 자동 루프에서만 보이는 "쳐 넣는 글자". 진짜 placeholder 와 겹치지 않게 지운다
.dm.is-demo .dm__input::placeholder { color: transparent; }
.dm__ghost {
position: absolute;
left: 14px; top: 0;
color: $color;
font-size: 14px; line-height: 44px; font-weight: 700;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
// ── 상태 아이콘 세 겹 — 같은 자리에 포개 두고 하나만 켠다 ──
.dm__icons {
position: absolute;
right: 12px; top: 50%;
width: 20px; height: 20px;
transform: translateY(-50%);
}
.dm__ic {
position: absolute;
left: 0; top: 0;
display: block;
transform: scale(.4);
opacity: 0;
}
.dm__ic--wait { color: rgba(255, 247, 230, .82); }
.dm__ic--ok { color: $ok; }
.dm__ic--no { color: $no; }
.dm[data-state="wait"] .dm__ic--wait,
.dm[data-state="ok"] .dm__ic--ok,
.dm[data-state="no"] .dm__ic--no { transform: scale(1); opacity: 1; }
.dm[data-state="wait"] .dm__ic--wait {
animation-name: dm-spin;
animation-duration: $dur-enter;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.dm[data-state="ok"] .dm__input { border-color: $ok; }
.dm[data-state="no"] .dm__input { border-color: $no; }
// ── 안내 한 줄 — 문구 셋을 겹쳐 두고 하나만 켠다 ─────────
.dm__out {
position: relative;
height: 16px;
margin: $sp-2 0 0;
font-size: 11px; line-height: 16px; font-weight: 700;
}
.dm__msgs { position: relative; display: block; height: 16px; }
.dm__m {
position: absolute;
left: 0; top: 0;
white-space: nowrap;
opacity: 0;
}
.dm__m[data-m="wait"] { color: rgba(255, 247, 230, .72); }
.dm__m[data-m="ok"] { color: $ok; }
.dm__m[data-m="no"] { color: $no; }
.dm[data-state="wait"] .dm__m[data-m="wait"],
.dm[data-state="ok"] .dm__m[data-m="ok"],
.dm[data-state="no"] .dm__m[data-m="no"] { opacity: 1; }
.dm__say {
// 아이콘이 보여 주는 것을 낭독기에도 같은 뜻으로 보낸다
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
// ── 자동 루프 (2s) ────────────────────────────────────
// 0~14% 빈 칸 → 18~34% 타이핑 → 36% 회전 시작(기다리는 구간) → 54% 체크가 튀어나옴
// → 70% 뒤에 빈칸 섞인 값으로 이어 쳐 엑스가 흔들리며 들어옴 → 82·86% 흩어서 복귀.
// 아이콘 교체는 steps(1, end) 컷 — opacity 로 겹치면 체크와 엑스가 반투명하게 포개진 프레임이 남는다.
.dm.is-demo .dm__ghost {
opacity: 1;
animation-name: dm-type;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dm.is-demo .dm__input {
animation-name: dm-input;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dm.is-demo .dm__ic--wait {
animation-name: dm-wait, dm-spin;
animation-duration: $dur-loop, $dur-enter;
animation-timing-function: steps(1, end), linear;
animation-iteration-count: infinite, infinite;
}
.dm.is-demo .dm__ic--ok {
animation-name: dm-ok;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.dm.is-demo .dm__ic--no {
animation-name: dm-no;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.dm.is-demo .dm__m {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dm.is-demo .dm__m[data-m="wait"] { animation-name: dm-m-wait; }
.dm.is-demo .dm__m[data-m="ok"] { animation-name: dm-m-ok; }
.dm.is-demo .dm__m[data-m="no"] { animation-name: dm-m-no; }
@keyframes dm-spin {
0% { rotate: 0deg; }
100% { rotate: 360deg; }
}
@keyframes dm-type {
0% { clip-path: inset(0 100% 0 0); }
18% { clip-path: inset(0 100% 0 0); }
26% { clip-path: inset(0 70% 0 0); }
34% { clip-path: inset(0 50% 0 0); }
66% { clip-path: inset(0 50% 0 0); }
70% { clip-path: inset(0 0 0 0); }
86% { clip-path: inset(0 100% 0 0); }
100% { clip-path: inset(0 100% 0 0); }
}
@keyframes dm-input {
0% { border-color: rgba(255, 247, 230, .38); }
54% { border-color: $ok; }
70% { border-color: $no; }
82% { border-color: rgba(255, 247, 230, .38); }
100% { border-color: rgba(255, 247, 230, .38); }
}
@keyframes dm-wait {
0% { opacity: 0; transform: scale(.4); }
36% { opacity: 1; transform: scale(1); }
54% { opacity: 0; transform: scale(.4); }
100% { opacity: 0; transform: scale(.4); }
}
@keyframes dm-ok {
0% { opacity: 0; transform: scale(.4); }
53.9% { opacity: 0; transform: scale(.4); }
54% { opacity: 1; transform: scale(1.35); }
60% { opacity: 1; transform: scale(1); }
69.9% { opacity: 1; transform: scale(1); }
70% { opacity: 0; transform: scale(.4); }
100% { opacity: 0; transform: scale(.4); }
}
@keyframes dm-no {
0% { opacity: 0; transform: scale(.4) translateX(0); }
69.9% { opacity: 0; transform: scale(.4) translateX(0); }
70% { opacity: 1; transform: scale(1) translateX(5px); }
74% { opacity: 1; transform: scale(1) translateX(-5px); }
78% { opacity: 1; transform: scale(1) translateX(3px); }
81.9% { opacity: 1; transform: scale(1) translateX(0); }
82% { opacity: 0; transform: scale(.4) translateX(0); }
100% { opacity: 0; transform: scale(.4) translateX(0); }
}
@keyframes dm-m-wait { 0% { opacity: 0; } 36% { opacity: 1; } 54% { opacity: 0; } 100% { opacity: 0; } }
@keyframes dm-m-ok { 0% { opacity: 0; } 54% { opacity: 1; } 70% { opacity: 0; } 100% { opacity: 0; } }
@keyframes dm-m-no { 0% { opacity: 0; } 70% { opacity: 1; } 86% { opacity: 0; } 100% { opacity: 0; } }
// 320×200 — 무대 여백을 뺀 174px 안에 들어가도록 간격을 내린다(올리지 않는다)
@media (max-width: 360px) {
.dm__input { height: 40px; line-height: 36px; }
.dm__ghost { line-height: 40px; }
.dm__out { margin-top: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.dm.is-demo .dm__ghost,
.dm.is-demo .dm__input,
.dm.is-demo .dm__ic,
.dm.is-demo .dm__m,
.dm[data-state="wait"] .dm__ic--wait { animation: none !important; }
.dm.is-demo .dm__ghost { opacity: 1; clip-path: inset(0 50% 0 0); }
.dm.is-demo .dm__ic--ok { opacity: 1; transform: scale(1); }
.dm.is-demo .dm__input { border-color: $ok; }
.dm.is-demo .dm__m[data-m="ok"] { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dm {
box-sizing: border-box;
width: min(300px, 86vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.dm__label {
display: block;
margin-bottom: 4px;
color: #17141a;
font-size: 11px;
line-height: 14px;
font-weight: 700;
}
.dm__field {
position: relative;
}
.dm__input {
box-sizing: border-box;
width: 100%;
height: 44px;
padding: 0 40px 0 12px;
border: 2px solid rgba(255, 247, 230, 0.38);
border-radius: 12px;
background: rgba(23, 20, 26, 0.18);
color: #fff7e6;
font-family: inherit;
font-size: 14px;
line-height: 40px;
font-weight: 700;
}
.dm__input::placeholder {
color: rgba(255, 247, 230, 0.5);
font-weight: 600;
}
.dm__input:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.dm.is-demo .dm__input::placeholder {
color: transparent;
}
.dm__ghost {
position: absolute;
left: 14px;
top: 0;
color: #fff7e6;
font-size: 14px;
line-height: 44px;
font-weight: 700;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
.dm__icons {
position: absolute;
right: 12px;
top: 50%;
width: 20px;
height: 20px;
transform: translateY(-50%);
}
.dm__ic {
position: absolute;
left: 0;
top: 0;
display: block;
transform: scale(0.4);
opacity: 0;
}
.dm__ic--wait {
color: rgba(255, 247, 230, 0.82);
}
.dm__ic--ok {
color: #479075;
}
.dm__ic--no {
color: #4a0f04;
}
.dm[data-state=wait] .dm__ic--wait,
.dm[data-state=ok] .dm__ic--ok,
.dm[data-state=no] .dm__ic--no {
transform: scale(1);
opacity: 1;
}
.dm[data-state=wait] .dm__ic--wait {
animation-name: dm-spin;
animation-duration: 600ms;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.dm[data-state=ok] .dm__input {
border-color: #479075;
}
.dm[data-state=no] .dm__input {
border-color: #4a0f04;
}
.dm__out {
position: relative;
height: 16px;
margin: 8px 0 0;
font-size: 11px;
line-height: 16px;
font-weight: 700;
}
.dm__msgs {
position: relative;
display: block;
height: 16px;
}
.dm__m {
position: absolute;
left: 0;
top: 0;
white-space: nowrap;
opacity: 0;
}
.dm__m[data-m=wait] {
color: rgba(255, 247, 230, 0.72);
}
.dm__m[data-m=ok] {
color: #479075;
}
.dm__m[data-m=no] {
color: #4a0f04;
}
.dm[data-state=wait] .dm__m[data-m=wait],
.dm[data-state=ok] .dm__m[data-m=ok],
.dm[data-state=no] .dm__m[data-m=no] {
opacity: 1;
}
.dm__say {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.dm.is-demo .dm__ghost {
opacity: 1;
animation-name: dm-type;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dm.is-demo .dm__input {
animation-name: dm-input;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dm.is-demo .dm__ic--wait {
animation-name: dm-wait, dm-spin;
animation-duration: 2s, 600ms;
animation-timing-function: steps(1, end), linear;
animation-iteration-count: infinite, infinite;
}
.dm.is-demo .dm__ic--ok {
animation-name: dm-ok;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.dm.is-demo .dm__ic--no {
animation-name: dm-no;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.dm.is-demo .dm__m {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.dm.is-demo .dm__m[data-m=wait] {
animation-name: dm-m-wait;
}
.dm.is-demo .dm__m[data-m=ok] {
animation-name: dm-m-ok;
}
.dm.is-demo .dm__m[data-m=no] {
animation-name: dm-m-no;
}
@keyframes dm-spin {
0% {
rotate: 0deg;
}
100% {
rotate: 360deg;
}
}
@keyframes dm-type {
0% {
clip-path: inset(0 100% 0 0);
}
18% {
clip-path: inset(0 100% 0 0);
}
26% {
clip-path: inset(0 70% 0 0);
}
34% {
clip-path: inset(0 50% 0 0);
}
66% {
clip-path: inset(0 50% 0 0);
}
70% {
clip-path: inset(0 0 0 0);
}
86% {
clip-path: inset(0 100% 0 0);
}
100% {
clip-path: inset(0 100% 0 0);
}
}
@keyframes dm-input {
0% {
border-color: rgba(255, 247, 230, 0.38);
}
54% {
border-color: #479075;
}
70% {
border-color: #4a0f04;
}
82% {
border-color: rgba(255, 247, 230, 0.38);
}
100% {
border-color: rgba(255, 247, 230, 0.38);
}
}
@keyframes dm-wait {
0% {
opacity: 0;
transform: scale(0.4);
}
36% {
opacity: 1;
transform: scale(1);
}
54% {
opacity: 0;
transform: scale(0.4);
}
100% {
opacity: 0;
transform: scale(0.4);
}
}
@keyframes dm-ok {
0% {
opacity: 0;
transform: scale(0.4);
}
53.9% {
opacity: 0;
transform: scale(0.4);
}
54% {
opacity: 1;
transform: scale(1.35);
}
60% {
opacity: 1;
transform: scale(1);
}
69.9% {
opacity: 1;
transform: scale(1);
}
70% {
opacity: 0;
transform: scale(0.4);
}
100% {
opacity: 0;
transform: scale(0.4);
}
}
@keyframes dm-no {
0% {
opacity: 0;
transform: scale(0.4) translateX(0);
}
69.9% {
opacity: 0;
transform: scale(0.4) translateX(0);
}
70% {
opacity: 1;
transform: scale(1) translateX(5px);
}
74% {
opacity: 1;
transform: scale(1) translateX(-5px);
}
78% {
opacity: 1;
transform: scale(1) translateX(3px);
}
81.9% {
opacity: 1;
transform: scale(1) translateX(0);
}
82% {
opacity: 0;
transform: scale(0.4) translateX(0);
}
100% {
opacity: 0;
transform: scale(0.4) translateX(0);
}
}
@keyframes dm-m-wait {
0% {
opacity: 0;
}
36% {
opacity: 1;
}
54% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes dm-m-ok {
0% {
opacity: 0;
}
54% {
opacity: 1;
}
70% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes dm-m-no {
0% {
opacity: 0;
}
70% {
opacity: 1;
}
86% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 360px) {
.dm__input {
height: 40px;
line-height: 36px;
}
.dm__ghost {
line-height: 40px;
}
.dm__out {
margin-top: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.dm.is-demo .dm__ghost,
.dm.is-demo .dm__input,
.dm.is-demo .dm__ic,
.dm.is-demo .dm__m,
.dm[data-state=wait] .dm__ic--wait {
animation: none !important;
}
.dm.is-demo .dm__ghost {
opacity: 1;
clip-path: inset(0 50% 0 0);
}
.dm.is-demo .dm__ic--ok {
opacity: 1;
transform: scale(1);
}
.dm.is-demo .dm__input {
border-color: #479075;
}
.dm.is-demo .dm__m[data-m=ok] {
opacity: 1;
}
}// 실제 동작: 타이핑이 멈춘 뒤 400ms 를 기다렸다가(setTimeout) 규칙을 재고, 통과면 체크·어기면 엑스로 넘어간다.
var dm = document.getElementById('dm');
var dmInput = document.getElementById('dm-input');
var dmSay = document.getElementById('dm-say');
var DM_RULE = /^[a-z][a-z0-9_]{4,}$/;
var dmTimer = 0;
function dmSet(state, say) {
dm.setAttribute('data-state', state);
dmInput.setAttribute('aria-invalid', state === 'no' ? 'true' : 'false');
dmSay.textContent = say;
}
dmInput.addEventListener('input', function () {
dm.classList.remove('is-demo'); // 사람이 손대면 자동 루프를 멈춘다
clearTimeout(dmTimer);
if (!dmInput.value) { dmSet('idle', 'Enter a username'); return; }
dmSet('wait', 'Checking it now');
dmTimer = setTimeout(function () {
var ok = DM_RULE.test(dmInput.value);
dmSet(ok ? 'ok' : 'no', ok ? 'This username is available' : 'This username does not fit the rule');
}, 400);
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DebounceStatusIcon.module.scss";
export type DebounceStatusIconProps = {
label?: string;
placeholder?: string;
waitMessage?: string;
okMessage?: string;
noMessage?: string;
/** 타이핑이 멎고 나서 판정까지 기다리는 시간 (ms) */
debounce?: number;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type ChangeEvt = { target: { value: string } };
type State = "idle" | "wait" | "ok" | "no";
const RULE = /^[a-z][a-z0-9_]{4,}$/;
function Ring() {
return (
<svg viewBox="0 0 256 256" width="20" height="20" aria-hidden="true">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M232 128a104 104 0 0 1-208 0c0-41 23.81-78.36 60.66-95.27a8 8 0 0 1 6.68 14.54C60.15 61.59 40 93.27 40 128a88 88 0 0 0 176 0c0-34.73-20.15-66.41-51.34-80.73a8 8 0 0 1 6.68-14.54C208.19 49.64 232 87 232 128" />
</g>
</svg>
);
}
function CheckCircle() {
return (
<svg viewBox="0 0 256 256" width="20" height="20" aria-hidden="true">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M173.66 98.34a8 8 0 0 1 0 11.32l-56 56a8 8 0 0 1-11.32 0l-24-24a8 8 0 0 1 11.32-11.32L112 148.69l50.34-50.35a8 8 0 0 1 11.32 0M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</g>
</svg>
);
}
function XCircle() {
return (
<svg viewBox="0 0 256 256" width="20" height="20" aria-hidden="true">
<g fill="currentColor">
<path d="M224 128a96 96 0 1 1-96-96a96 96 0 0 1 96 96" opacity=".2" />
<path d="M165.66 101.66L139.31 128l26.35 26.34a8 8 0 0 1-11.32 11.32L128 139.31l-26.34 26.35a8 8 0 0 1-11.32-11.32L116.69 128l-26.35-26.34a8 8 0 0 1 11.32-11.32L128 116.69l26.34-26.35a8 8 0 0 1 11.32 11.32M232 128A104 104 0 1 1 128 24a104.11 104.11 0 0 1 104 104m-16 0a88 88 0 1 0-88 88a88.1 88.1 0 0 0 88-88" />
</g>
</svg>
);
}
export default function DebounceStatusIcon({
label = "Username",
placeholder = "Type more than four characters",
waitMessage = "Checking",
okMessage = "This username is available",
noMessage = "This username does not fit the rule",
debounce = 400,
demo = true,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#fff7e6",
}: DebounceStatusIconProps) {
const [value, setValue] = useState("");
const [state, setState] = useState<State>("idle");
const [live, setLive] = useState(demo);
const timer = useRef<number>(0);
useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
function onType(next: string) {
setLive(false);
setValue(next);
if (timer.current) clearTimeout(timer.current);
if (!next) { setState("idle"); return; }
setState("wait");
timer.current = setTimeout(() => setState(RULE.test(next) ? "ok" : "no"), debounce) as unknown as number;
}
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
return (
<div className={`${styles.root} ${live ? styles.isDemo : ""}`} style={vars} data-state={state}>
<label className={styles.label} htmlFor="dm-input">{label}</label>
<div className={styles.field}>
<input
className={styles.input}
id="dm-input"
type="text"
autoComplete="off"
placeholder={placeholder}
aria-describedby="dm-say"
aria-invalid={state === "no"}
value={value}
onChange={(e: ChangeEvt) => onType(e.target.value)}
/>
<span className={styles.ghost} aria-hidden="true">bitna shop</span>
<span className={styles.icons} aria-hidden="true">
<span className={`${styles.ic} ${styles.icWait}`}><Ring /></span>
<span className={`${styles.ic} ${styles.icOk}`}><CheckCircle /></span>
<span className={`${styles.ic} ${styles.icNo}`}><XCircle /></span>
</span>
</div>
<p className={styles.out}>
<span className={styles.msgs} aria-hidden="true">
<span className={styles.m} data-m="wait">{waitMessage}</span>
<span className={styles.m} data-m="ok">{okMessage}</span>
<span className={styles.m} data-m="no">{noMessage}</span>
</span>
<span className={styles.say} id="dm-say" role="status" aria-live="polite">
{state === "ok" ? okMessage : state === "no" ? noMessage : state === "wait" ? waitMessage : "Enter a username"}
</span>
</p>
</div>
);
}07 Status icon that waits, then rules — Measured area 2.357% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · 9 Form Validation UI Patterns — As You Type
The post this demo lives in
9 Form Validation UI Patterns — As You TypeForm validation UI is the layer that checks a typed value against a rule and reports the verdict on screen. All nine run See all demos in this post
If you like this effect - similar demos
Inputs06 Vertical wizard that folds finished steps into one line9 Multi Step Form UI Patterns — Branch and Resume
Inputs02 Debounce shimmer — gathering input for 300ms while the list shimmers as a waiting stateAutocomplete Input: 9 Search Dropdowns
Inputs07 Swatch chips — a color picker that matches check-icon contrast to each swatch's brightness9 Color Picker Design Patterns (Copy-Paste)