버튼 로딩 상태

Button Loading State

버튼을 누르면 라벨이 스피너로 바뀌고, 요청이 끝나면 체크마크로 성공을 알린 뒤 원래 상태로 돌아오는 패턴.

다른 이름: Async buttonLoading button
···
html
<button class="ldbtn" id="ldbtn"><span class="lbl">Submit</span><span class="spin2"></span><span class="check">&#10003;</span></button>
css
.fx-dot{position:absolute;left:0;top:0;width:12px;height:12px;margin:-6px 0 0 -6px;border-radius:50%;
  background:var(--accent);box-shadow:0 0 0 5px color-mix(in srgb,var(--accent) 18%,transparent),0 2px 6px rgba(0,0,0,.3);
  pointer-events:none;z-index:9999;transition:opacity .25s}
.ldbtn{position:relative;padding:14px 30px;border:none;border-radius:10px;background:var(--accent);color:#fff;
  font-weight:700;font-size:clamp(12px,3.6vmin,15px);cursor:pointer;min-width:130px;overflow:hidden}
.ldbtn .lbl,.ldbtn .check{transition:opacity .2s,transform .2s}
.ldbtn .spin2{position:absolute;left:50%;top:50%;width:16px;height:16px;margin:-8px 0 0 -8px;border-radius:50%;
  border:2px solid rgba(255,255,255,.35);border-top-color:#fff;opacity:0}
.ldbtn.loading .lbl{opacity:0;transform:scale(.8)}
.ldbtn.loading .spin2{opacity:1;animation:spin2 .7s linear infinite}
.ldbtn .check{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%) scale(.5);opacity:0}
.ldbtn.done .check{opacity:1;transform:translate(-50%,-50%) scale(1)}
.ldbtn.done .lbl,.ldbtn.done .spin2{opacity:0}
.ldbtn.done{background:var(--accent-3)}
@keyframes spin2{to{transform:rotate(360deg)}}
js
const fxDot=document.createElement('div');fxDot.className='fx-dot';document.body.appendChild(fxDot);
let fxAuto=true,fxX=innerWidth/2,fxY=innerHeight/2;
function fxHide(){fxDot.style.opacity='0';}
addEventListener('pointerdown',()=>{fxAuto=false;fxHide();},{capture:true});
addEventListener('pointermove',e=>{fxAuto=false;fxHide();fxX=e.clientX;fxY=e.clientY;},{capture:true});
function fxWalk(x,y,ms){
  return new Promise(res=>{
    const sx=fxX,sy=fxY,start=performance.now();
    function step(now){
      if(!fxAuto)return res();
      const p=Math.min(1,(now-start)/ms),e=1-Math.pow(1-p,3);
      fxX=sx+(x-sx)*e;fxY=sy+(y-sy)*e;
      fxDot.style.transform='translate('+fxX+'px,'+fxY+'px)';
      if(p<1)requestAnimationFrame(step);else res();
    }
    requestAnimationFrame(step);
  });
}
function fxWait(ms){
  return new Promise(res=>{
    const start=performance.now();
    function step(now){ if(!fxAuto)return res(); if(now-start<ms)requestAnimationFrame(step);else res(); }
    requestAnimationFrame(step);
  });
}

const btn=document.getElementById('ldbtn');
let busy=false;
function runCycle(){
  if(busy) return; busy=true;
  btn.classList.add('loading');
  setTimeout(()=>{
    btn.classList.remove('loading'); btn.classList.add('done');
    setTimeout(()=>{ btn.classList.remove('done'); busy=false; },900);
  },1100);
}
btn.addEventListener('pointerdown',()=>{ fxAuto=false; fxHide(); runCycle(); });
async function autoSeq(){
  while(fxAuto){
    const r=btn.getBoundingClientRect();
    await fxWalk(r.left+r.width/2, r.top+r.height/2, 500); if(!fxAuto) break;
    runCycle();
    await fxWait(2400); if(!fxAuto) break;
  }
}
autoSeq();

버튼 자체는 크기를 유지한 채(`min-width`), 내부 콘텐츠만 라벨 → 스피너 → 체크 순으로 `opacity`를 교차시킨다. 버튼 크기가 상태마다 바뀌면 주변 레이아웃이 들썩여 산만하므로, 폭은 가장 긴 상태(라벨)에 맞춰 고정한다.

3가지 상태(대기·로딩·완료)를 클래스(`.loading`, `.done`)로 나타내고, 실제 비동기 요청과 최소 로딩 시간(예: 400ms 이상)을 함께 지켜야 한다 — 응답이 너무 빨리 오면 스피너가 깜빡였다 사라져 오히려 불안정해 보인다.

로딩 중에는 반드시 버튼을 비활성화(`disabled` 또는 클릭 무시)해서 중복 제출을 막는다. 실패 케이스도 마련해야 한다 — 보통 에러 상태(빨간 테두리·흔들림)로 짧게 보여준 뒤 원래 상태로 돌아온다.

언제 쓰나

폼 제출, 결제, API 호출처럼 지연이 있는 모든 버튼 액션에 기본으로 둡니다. 즉시 반응하는 로컬 토글에는 필요 없습니다.