Button Loading State

버튼 로딩 상태

Click a button and its label swaps for a spinner; once the request finishes, a checkmark confirms success before it settles back.

Also known as: 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();

Keep the button's own size fixed (`min-width`) and cross-fade only the inner content — label, then spinner, then checkmark. If the button resizes per state, surrounding layout jitters distractingly, so lock the width to the longest state (the label).

Represent the three states (idle, loading, done) as classes (`.loading`, `.done`), and respect a minimum loading duration (say 400ms+) alongside the real async request — if the response comes back too fast, the spinner flashes and disappears, which reads as jittery rather than reassuring.

Disable the button while loading (via `disabled` or by ignoring clicks) to prevent double submission. Plan for the failure case too — typically a brief error state (red outline, a shake) before returning to idle.

When to use

Make this the default for any button action with latency — form submits, payments, API calls. Not needed for instant local toggles.