GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

버튼 로딩 9종 — 스피너부터 성공 체크까지 상태 전환

버튼 로딩 9종 가운데 하나는 스피너가 돌다가 선을 그리듯 체크 표시로 모양을 바꾸고, 하나는 글자 대신 점 세 개가 순서대로 커졌다 작아지며, 하나는 라벨이 일정 시간마다 다음 단계 문구로 위아래 슬라이드해 바뀝니다.

폼·CTA 로딩 버튼 하나에 저장·업로드·재시도 같은 비동기 작업을 걸었을 때 라벨과 아이콘이 어떻게 바뀌는지 모아 둔 9종 갤러리로, 01~08 은 click 리스너 하나로 시작하고 09 는 여기에 실제 OS 설정을 듣는 matchMedia 의 change 리스너를 하나 더 얹습니다. 자동으로 도는 시연 동작은 .is-demo 가 붙어 있는 동안만 돌고, 버튼을 실제로 누르는 순간 human() 이 그 클래스를 떼어내 남은 타이머를 전부 지웁니다.

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

아홉 가지 상태 전환 — 계산 한 겹이 어디서 도는가

아홉 항목 모두 Math.random 을 쓰지 않는 결정적 계산이라 같은 버튼을 눌러도 항상 같은 흐름으로 진행됩니다. 시연 루프와 실제 클릭 반응 모두 requestAnimationFrame 이 구동하고(setTimeout 으로 다음 단계 시점만 예약), @keyframes 는 아홉 항목 어디에도 없습니다 — 전환 폭·속도는 항상 자바스크립트 상수로 남아 값 하나만 바꿔도 체감이 달라집니다.

항목 누적면적 강도_평균 움직인프레임
01 라벨이 스피너 아이콘으로 자연스럽게 바뀌는 버튼 0.50% 98.6 20/23
02 버튼 배경이 진행률만큼 차오르는 미니 진행 표시 5.05% 75.9 14/23
03 스피너가 성공 체크 아이콘으로 모양을 바꾸는 버튼 5.07% 90.8 15/23
04 실패하면 짧게 흔들리고 원래 라벨로 되돌아가는 버튼 0.73% 97.9 16/23
05 로딩 중에는 아이콘만 남기고 좁아지는 버튼 4.39% 168.1 13/23
06 라벨 대신 점 세 개가 번갈아 커지는 버튼 0.21% 91.9 20/23
07 로딩 중 어두워지고 눌리지 않는 잠금 버튼 3.32% 32.0 10/23
08 로딩 중 라벨 문구가 단계별로 바뀌는 버튼 0.79% 161.3 10/23
09 움직임을 줄이면 점 하나로 멈추는 로딩 버튼 0.59% 91.9 10/23

누적면적이 가장 큰 항목은 03 으로 5.07%인데, 02(5.05%)와 0.02%p 밖에 차이가 나지 않아 사실상 나란합니다 — 03 은 링·체크·틴트 세 요소가 동시에 겹쳐 바뀌는 반면 02 는 채움 막대 하나가 넓게 늘어나기 때문입니다. 가장 작은 항목은 06 으로 0.21%인데, 작은 점 세 개만 살짝 커졌다 작아지기 때문입니다. 강도_평균이 가장 높은 항목은 05 하나로 168.1까지 오르는데, 버튼 폭 자체가 짧은 시간 안에 크게 줄었다 늘어나 프레임 사이 픽셀 차가 큽니다. 가장 낮은 항목은 07 로 32.0인데, 배경 opacity 가 0.14~0.30 사이를 느리게 오가는 것뿐이라 프레임 사이 차가 작습니다. 23개 델타 구간 가운데 움직인프레임이 가장 촘촘한 항목은 01·06 으로 나란히 20/23 이고, 가장 뜸한 항목은 07·08·09 로 10/23 입니다.

라벨과 스피너를 크로스페이드하며 폭을 고정한다 — 01

fade 가 라벨 opacity 를 1→0, 스피너 opacity 를 0→1 로 같은 FADE_MS(180ms) 동안 동시에 바꾸고, lockWidth 가 전환 직전 실측한 버튼 폭을 px 로 고정해 스피너 도입으로 버튼이 좁아지지 않게 합니다.

진행률을 easeOutCubic 으로 완화해 scaleX 로 채운다 — 02

drive 는 경과 시간을 FILL_MS(1100ms)로 나눈 진행률 p 를 easeOutCubic 으로 완화한 뒤 그 값을 fill.style.transform = scaleX(p) 와 퍼센트 라벨 숫자 양쪽에 그대로 씁니다.

링의 회전을 멈추고 체크 path 길이만큼 선을 그린다 — 03

getTotalLength() 로 실측한 체크 path 길이를 stroke-dasharray·stroke-dashoffset 초기값으로 삼고, morph 가 진행률 p 에 따라 strokeDashoffset 을 그 길이만큼 줄여 선이 그려지는 것처럼 보이게 합니다.

감쇠 사인파로 흔들림 폭을 계산한다 — 04

shake 는 SHAKE_AMPLITUDE_PX(9)·SHAKE_DECAY_PER_S(9)·SHAKE_FREQ_HZ(8)를 A·e^(-decay·t)·sin(2π·freq·t) 식에 넣어 매 프레임 translateX 값을 계산합니다 — 고정 키프레임이 아니라 잦아드는 물리량입니다.

실측 폭 대비 아이콘 폭 비율로 scaleX 를 건다 — 05

driveScale 은 btn.getBoundingClientRect().width 로 잰 현재 폭과 COLLAPSED_WIDTH_PX(44px)의 비율을 구해 버튼은 그 값으로, 아이콘은 역수(1/scale)로 동시에 scaleX 해 버튼이 좁아지는 동안에도 아이콘 자체는 눌리지 않습니다.

점마다 위상을 어긋내 사인파로 펄스한다 — 06

pulseLoop 은 점 인덱스마다 PHASE_OFFSET_RAD(2π/3)만큼 위상을 어긋낸 1 + AMPLITUDE·sin(2πt/period − i·phaseOffset) 를 계산해 세 점이 순서대로 커지고 작아지게 만듭니다.

남은 시간에 따라 진동수를 끌어올리며 위상을 적분한다 — 07

pulseLoop 은 남은 시간 비율에 따라 BASE_HZ(0.6)에서 MAX_HZ(2.4)로 진동수를 끌어올리고, 진동수 자체가 시간에 따라 바뀌므로 sin(2π·f·t) 를 그대로 쓰는 대신 위상을 프레임 델타로 직접 적분합니다.

offsetTop 실측값으로 라벨 트랙을 슬라이드한다 — 08

measureOffsets 가 각 단계 문구의 offsetTop 을 실측해 쌓인 실제 높이를 구하고, slideTo 가 그 값 사이를 easeInOutQuad 로 보간해 트랙을 translateY 로 밀어 올립니다.

실제 OS 값과 방문자 시뮬레이션 값을 분리해 둔다 — 09

osReduce 는 matchMedia('(prefers-reduced-motion: reduce)') 로 읽은 실제 OS 설정이고, simReduce 는 토글 버튼으로 방문자가 따로 미리 보는 값입니다. 실사용 클릭은 simReduce(초기값은 osReduce)를 따르고, OS 값이 바뀌면 change 이벤트가 시뮬레이션도 같이 갱신합니다.

패턴별 코드

아홉 항목의 실제 계산부만 그대로 옮겨 붙였습니다. 줄 순서와 들여쓰기는 원본과 같습니다.

01라벨이 스피너 아이콘으로 자연스럽게 바뀌는 버튼

버튼을 누르는 순간 글자가 옅어지며 사라지고 그 자리에 작은 원형 스피너가 옅게 나타나 계속 도는 동안 버튼 폭은 그대로 유지됩니다. 폼 제출 버튼이 서버 응답을 기다리는 동안 쓰기 좋습니다.

  function lockWidth() {
    var w = btn.getBoundingClientRect().width;
    btn.style.width = w + 'px';
  }
  function unlockWidth() { btn.style.width = ''; }

  function startLoad(myGen) {
    lockWidth();
    btn.disabled = true;
    btn.setAttribute('aria-busy', 'true');
    status.textContent = '저장하는 중';
    fade(label, 1, 0, FADE_MS, myGen);
    fade(spinnerWrap, 0, 1, FADE_MS, myGen, function () { spinLoop(myGen); });
  }
  function finishLoad(myGen) {
    cancelAnimationFrame(rafId);
    fade(spinnerWrap, 1, 0, FADE_MS, myGen);
    fade(label, 0, 1, FADE_MS, myGen, function () {
      btn.disabled = false;
      btn.removeAttribute('aria-busy');
      status.textContent = '저장 완료';
      unlockWidth();
    });
  }
라벨이 스피너 아이콘으로 자연스럽게 바뀌는 버튼
새 창에서 열기

01 · 라벨이 스피너 아이콘으로 자연스럽게 바뀌는 버튼 — 측정 누적면적 0.503% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

lockWidthspinLooparia-busy

02버튼 배경이 진행률만큼 차오르는 미니 진행 표시

버튼을 누르면 테두리 안쪽 배경이 왼쪽부터 오른쪽으로 옅은 색이 차오르며 진행률을 나타내고, 끝까지 차면 잠깐 멈췄다가 다음 상태로 넘어갑니다. 파일 업로드·긴 처리 작업의 진행 상태를 표시할 때 맞습니다.

  function easeOutCubic(p) { return 1 - Math.pow(1 - p, 3); }

  // 계산 겹: 채움 막대는 scaleX 로만 늘어난다(transform-origin: left — 시작 변에서 자란다는
  // 예외적 origin, 채움 막대이므로 허용). 진행률 p 는 easeOutCubic 으로 매 프레임 계산해
  // 같은 값을 style.transform 과 라벨의 퍼센트 숫자 둘 다에 쓴다.
  function drive(myGen, onDone) {
    var start = null;
    function step(now) {
      if (myGen !== gen) return;
      if (start === null) start = now;
      var raw = Math.min(1, (now - start) / FILL_MS);
      var p = easeOutCubic(raw);
      fill.style.transform = 'scaleX(' + p.toFixed(4) + ')';
      text.textContent = 'Uploading ' + Math.round(p * 100) + '%';
      if (raw < 1) requestAnimationFrame(step); else onDone();
    }
    requestAnimationFrame(step);
  }
버튼 배경이 진행률만큼 차오르는 미니 진행 표시
새 창에서 열기

02 · 버튼 배경이 진행률만큼 차오르는 미니 진행 표시 — 측정 누적면적 5.052% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

easeOutCubictransform-originscaleX

03스피너가 성공 체크 아이콘으로 모양을 바꾸는 버튼

돌고 있던 원형 스피너가 작업이 끝나는 순간 선을 그리듯 체크 표시 모양으로 부드럽게 이어져 바뀌고, 버튼 배경도 함께 옅은 색조로 물듭니다. 저장·결제 완료를 버튼 안에서 바로 알릴 때 맞습니다.

  var checkLen = checkPath.getTotalLength();
  checkPath.style.strokeDasharray = String(checkLen);
  checkPath.style.strokeDashoffset = String(checkLen);

  function human() {
    if (scene.classList.contains('is-demo')) {
      scene.classList.remove('is-demo');
      timers.forEach(function (t) { clearTimeout(t); });
      timers = [];
    }
  }

  function easeOutCubic(p) { return 1 - Math.pow(1 - p, 3); }

  function spinLoop(myGen) {
    var start = null;
    function step(now) {
      if (myGen !== gen) return;
      if (start === null) start = now;
      var angle = ((now - start) * DEG_PER_MS) % 360;
      ring.style.transform = 'rotate(' + angle.toFixed(1) + 'deg)';
      rafId = requestAnimationFrame(step);
    }
    rafId = requestAnimationFrame(step);
  }
스피너가 성공 체크 아이콘으로 모양을 바꾸는 버튼
새 창에서 열기

03 · 스피너가 성공 체크 아이콘으로 모양을 바꾸는 버튼 — 측정 누적면적 5.073% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getTotalLengthstrokeDashoffsetspinLoop

04실패하면 짧게 흔들리고 원래 라벨로 되돌아가는 버튼

돌던 스피너가 오류를 만나면 가위표 아이콘으로 바뀌면서 버튼 전체가 좌우로 짧게 흔들리고, 잠시 뒤 흔들림이 잦아들며 원래 라벨 글자로 되돌아갑니다. 네트워크 오류로 제출이 실패했을 때 재시도를 유도하는 자리에 맞습니다.

  // 계산 겹: 감쇠 사인파 x(t) = A · e^(-decay·t) · sin(2π·freq·t) 를 매 프레임 계산해
  // 버튼 전체의 translateX 에 적용한다 — 고정 키프레임이 아니라 물리량으로 흔든다.
  function shake(myGen, ms, onDone) {
    var start = null;
    function step(now) {
      if (myGen !== gen) return;
      if (start === null) start = now;
      var elapsed = now - start;
      var tSec = elapsed / 1000;
      var x = SHAKE_AMPLITUDE_PX * Math.exp(-SHAKE_DECAY_PER_S * tSec) * Math.sin(2 * Math.PI * SHAKE_FREQ_HZ * tSec);
      btn.style.transform = 'translateX(' + x.toFixed(2) + 'px)';
      if (elapsed < ms) requestAnimationFrame(step);
      else { btn.style.transform = 'translateX(0px)'; if (onDone) onDone(); }
    }
    requestAnimationFrame(step);
  }
실패하면 짧게 흔들리고 원래 라벨로 되돌아가는 버튼
새 창에서 열기

04 · 실패하면 짧게 흔들리고 원래 라벨로 되돌아가는 버튼 — 측정 누적면적 0.726% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

Math.sintranslateXaria-live

05로딩 중에는 아이콘만 남기고 좁아지는 버튼

버튼을 누르면 글자 라벨이 옆으로 사라지며 버튼 폭이 동그란 아이콘 하나가 겨우 들어갈 만큼 줄어들고, 작업이 끝나면 다시 원래 폭으로 늘어나며 라벨이 돌아옵니다. 좁은 툴바·모바일 화면에서 공간을 아끼는 버튼에 맞습니다.

  // 계산 겹: 지금 렌더된 버튼 실측 폭(fullWidth)과 아이콘 하나가 들어갈 목표 폭을 비교해
  // collapsedRatio 를 구하고, 매 프레임 scale 값으로 btn 을 scaleX 하면서 아이콘엔 1/scale
  // 역스케일을 걸어 버튼이 좁아지는 동안에도 아이콘 자체는 눌리지 않게 만든다.
  function driveScale(myGen, fromScale, toScale, ms, onDone) {
    var fullWidth = btn.getBoundingClientRect().width / fromScale;
    var collapsedRatio = COLLAPSED_WIDTH_PX / fullWidth;
    var resolvedFrom = fromScale === 1 ? 1 : collapsedRatio;
    var resolvedTo = toScale === 1 ? 1 : collapsedRatio;
    var start = null;
    function step(now) {
      if (myGen !== gen) return;
      if (start === null) start = now;
      var p = easeInOutQuad(Math.min(1, (now - start) / ms));
      var scale = resolvedFrom + (resolvedTo - resolvedFrom) * p;
      btn.style.transform = 'scaleX(' + scale.toFixed(4) + ')';
      icon.style.transform = 'scaleX(' + (1 / scale).toFixed(4) + ')';
      if (p < 1) requestAnimationFrame(step); else onDone();
    }
    requestAnimationFrame(step);
  }
로딩 중에는 아이콘만 남기고 좁아지는 버튼
새 창에서 열기

05 · 로딩 중에는 아이콘만 남기고 좁아지는 버튼 — 측정 누적면적 4.394% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

driveScalegetBoundingClientRectscaleX

06라벨 대신 점 세 개가 번갈아 커지는 버튼

버튼을 누르면 글자가 사라진 자리에 작은 점 세 개가 나란히 놓이고, 왼쪽부터 순서대로 하나씩 커졌다 작아지기를 반복하며 계속 작업 중임을 보입니다. 채팅 전송·짧은 API 호출처럼 빠른 처리의 대기 표시에 맞습니다.

  // 계산 겹: 점마다 위상을 다르게 준 사인파 scale_i(t) = 1 + A·sin(2π·t/period − i·phaseOffset)
  // 를 매 프레임 계산한다 — animation-delay 로 어긋나게 도는 방식이 아니라 각 점을 매번 직접 계산한다.
  function pulseLoop(myGen) {
    var start = null;
    function step(now) {
      if (myGen !== gen) return;
      if (start === null) start = now;
      var t = now - start;
      for (var i = 0; i < dotEls.length; i++) {
        var s = 1 + AMPLITUDE * Math.sin((2 * Math.PI * t) / PULSE_PERIOD_MS - i * PHASE_OFFSET_RAD);
        dotEls[i].style.transform = 'scale(' + s.toFixed(3) + ')';
      }
      rafId = requestAnimationFrame(step);
    }
    rafId = requestAnimationFrame(step);
  }
라벨 대신 점 세 개가 번갈아 커지는 버튼
새 창에서 열기

06 · 라벨 대신 점 세 개가 번갈아 커지는 버튼 — 측정 누적면적 0.207% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pulseLoopMath.sinPHASE_OFFSET_RAD

07로딩 중 어두워지고 눌리지 않는 잠금 버튼

버튼을 한 번 누르면 배경이 옅게 어두워지고 그 위로 반투명한 막이 씌워져 다시 눌러도 반응하지 않으며, 그 어두운 톤이 아주 느리게 밝아졌다 어두워지기를 반복해 대기 중임을 알립니다. 중복 클릭으로 같은 요청이 두 번 나가는 것을 막을 때 맞습니다.

  // 계산 겹: 남은 시간(remaining)이 줄어들수록 진동수를 BASE_HZ→MAX_HZ 로 끌어올린다.
  // 진동수가 시간에 따라 바뀌므로 sin(2π·f·t) 를 그대로 쓸 수 없어 phase 를 프레임 델타로
  // 직접 적분한다(phase += 2π·f(now)·dt) — 막이 남은 시간이 줄수록 더 빨리 깜빡인다.
  function pulseLoop(myGen) {
    var last = null;
    var phase = 0;
    var loopStart = null;
    function step(now) {
      if (myGen !== gen) return;
      if (last === null) { last = now; loopStart = now; }
      var dt = now - last;
      last = now;
      var elapsed = now - loopStart;
      var remainingFrac = clamp01(1 - elapsed / COOLDOWN_MS);
      var freq = BASE_HZ + (MAX_HZ - BASE_HZ) * (1 - remainingFrac);
      phase += 2 * Math.PI * freq * (dt / 1000);
      var wave = (1 + Math.sin(phase)) / 2;
      film.style.opacity = String(.14 + .16 * wave);
      rafId = requestAnimationFrame(step);
    }
    rafId = requestAnimationFrame(step);
  }
로딩 중 어두워지고 눌리지 않는 잠금 버튼
새 창에서 열기

07 · 로딩 중 어두워지고 눌리지 않는 잠금 버튼 — 측정 누적면적 3.318% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BASE_HZrequestAnimationFramearia-busy

08로딩 중 라벨 문구가 단계별로 바뀌는 버튼

버튼을 누르면 라벨이 첫 문구에서 시작해 일정 시간마다 다음 문구로 위아래 슬라이드하며 여러 단계를 차례로 거쳐, 하나의 스피너보다 지금 어느 단계인지를 글로 알려줍니다. 여러 단계를 거치는 긴 제출 작업의 진행 안내에 맞습니다.

  function slideTo(myGen, fromY, toY, ms, onDone) {
    var start = null;
    function step(now) {
      if (myGen !== gen) return;
      if (start === null) start = now;
      var p = easeInOutQuad(Math.min(1, (now - start) / ms));
      var y = fromY + (toY - fromY) * p;
      track.style.transform = 'translateY(' + (-y).toFixed(2) + 'px)';
      if (p < 1) requestAnimationFrame(step); else onDone();
    }
    requestAnimationFrame(step);
  }
로딩 중 라벨 문구가 단계별로 바뀌는 버튼
새 창에서 열기

08 · 로딩 중 라벨 문구가 단계별로 바뀌는 버튼 — 측정 누적면적 0.785% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

measureOffsetsoffsetTopslideTo

09움직임을 줄이면 점 하나로 멈추는 로딩 버튼

시스템에서 움직임 줄이기가 켜져 있으면 버튼은 돌아가는 스피너 대신 라벨 옆에 흐리지 않은 점 하나만 그대로 두고, 로딩이 끝나는 순간에만 그 점이 사라지며 결과 상태로 바뀝니다. 전정 장애·어지럼 민감 사용자를 위한 대체 로딩 표시에 맞습니다.

  function setToggleUI() {
    toggle.setAttribute('aria-checked', simReduce ? 'true' : 'false');
    knob.style.transform = simReduce ? 'translateX(16px)' : 'translateX(0px)';
  }

  function applyToggle(next) {
    simReduce = next;
    setToggleUI();
  }

  // 시뮬 토글 — 방문자가 실제 OS 설정과 무관하게 두 상태를 미리 볼 수 있다.
  toggle.addEventListener('click', function () {
    human();
    applyToggle(!simReduce);
  });

  if (osReduce) setToggleUI();

  // 실사용 경로 적용 — 진짜 OS 값이 바뀌면(브라우저·OS 설정 변경) 시뮬 토글도 따라간다.
  function onOsChange(e) {
    osReduce = e.matches;
    applyToggle(osReduce);
  }
  if (mql.addEventListener) mql.addEventListener('change', onOsChange);
움직임을 줄이면 점 하나로 멈추는 로딩 버튼
새 창에서 열기

09 · 움직임을 줄이면 점 하나로 멈추는 로딩 버튼 — 측정 누적면적 0.588% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

matchMediarole=switcharia-checked

값이 UI를 바꾸는 곳 — 지속 시간·흔들림 폭·단계 문구 전환 간격

상수 하나만 바꿔도 로딩이 끝나는 체감 속도는 크게 달라집니다. 01 은 DEG_PER_MS(360/650)가 스피너 회전 속도를, FADE_MS(180ms)가 라벨↔스피너 전환 길이를 정합니다. 02 는 FILL_MS(1100ms)가 채움 트윈 길이를, HOLD_AT_FULL_MS(500ms)가 다 찬 뒤 멈춰 있는 시간을 정합니다. 03 은 DRAW_MS(420ms)가 체크 선이 그려지는 속도를, HOLD_MS(450ms)가 체크 표시가 머무는 시간을 정합니다. 04 는 SHAKE_AMPLITUDE_PX(9px)·SHAKE_FREQ_HZ(8Hz)가 흔들리는 폭과 빠르기를, SHAKE_DECAY_PER_S(9)가 얼마나 빨리 잦아드는지를 정합니다. 05 는 COLLAPSED_WIDTH_PX(44px)가 좁아진 최종 폭을 정합니다. 06 은 PULSE_PERIOD_MS(900ms)가 점 하나가 커졌다 작아지는 주기를, PHASE_OFFSET_RAD(2π/3)가 점 사이 위상차를 정합니다. 07 은 BASE_HZ(0.6)·MAX_HZ(2.4)가 대기가 길어질수록 깜빡임이 빨라지는 범위를 정합니다. 08 은 STEP_INTERVAL_MS(450ms)가 문구가 넘어가는 간격을 정합니다. 09 는 지속 시간 상수보다 osReduce·simReduce 두 값을 분리해 둔 구조 자체가 UI를 바꿉니다.

이웃 가지

같은 버튼 상태 축에서도 초점은 갈립니다. 버튼을 누르는 즉시 눌림·리플 같은 순간 반응을 모은 CSS 버튼 애니메이션 9종 — 복붙하면 되는 SCSS + React 와 달리, 이 편은 클릭 이후 로딩·성공·실패로 이어지는 상태 전환에 집중합니다. 돌아가는 원 하나로 대기를 표시하는 CSS 로딩 스피너 9종 — div 하나로 끝나는 과 비교하면, 이 편의 01·03 은 같은 스피너를 라벨·체크와 엮어 버튼 자체의 상태 표시로 확장합니다. hover 시점의 반응을 모은 css 버튼 hover 효과 모음 9종 — 수상작에서 반복되는 기법 과도 이어지지만, 이 편의 09 는 hover 가 아니라 reduced-motion 설정 자체에 반응합니다.

어디서 깨지는가 — 접근성·reduced-motion·중복 클릭

위 격자에 있는 아홉 개 버튼을 실제 프로젝트에 그대로 옮겨 쓸 vanilla 코드로 받으려면 zip 비밀번호 g7cf8ma6 를 입력해 열어보면 됩니다.

실제로 겪은 함정은 reduced-motion 을 켜도 버튼 로딩 중 대기 시간이 모든 항목에서 똑같이 줄어들지는 않는다는 점입니다. 01 스피너 항목은 애니메이션 켜짐(runAnimated)이 SPIN_MS(900ms) 뒤 finishLoad 가 불린 다음에도 라벨·스피너 크로스페이드에 FADE_MS(180ms)가 한 번 더 걸려 완료까지 1080ms 가 걸리고, reduce 켜짐(runReduced)은 트윈 없이 SPIN_MS(900ms) 뒤 곧바로 끝나 두 경로 차이는 180ms 뿐입니다. 반면 02 진행 표시줄 항목은 reduce 에서 FILL_MS(1100ms)의 채움 트윈 구간을 통째로 건너뛰어 HOLD_AT_FULL_MS(500ms)+SUCCESS_HOLD_MS(350ms)인 850ms 만에 끝나, 애니메이션 켜짐일 때의 총 2210ms(FILL_MS+HOLD_AT_FULL_MS+되돌아가는 fadeOutFill(260ms)+SUCCESS_HOLD_MS)보다 실제로 훨씬 빨리 완료됩니다. reduce 를 껐다 켰다 하며 재생 시간까지 같이 줄었다고 가정하고 프로젝트에 붙이면 항목마다 실제 동작이 다릅니다.

reduce 상태에서 각자 무엇을 멈추는가

01~08 은 클릭 시점에 reduce 값을 확인해 참이면 runReduced() 로 트윈 없이 상태만 즉시 바꾸고, 거짓이면 runAnimated() 로 rAF 트윈을 돌립니다. 자동 시연 루프(cycle)는 !reduce 일 때만 시작해, reduce 가 켜져 있으면 데모 화면조차 저절로 움직이지 않습니다. 09 는 예외로 자동 루프가 reduce 값과 무관하게 계속 돕니다 — 정지 표시 자체가 이 항목의 주제라 자동 루프를 끄면 데모가 항상 빈 화면이기 때문이고, 실제 클릭 경로는 simReduce(OS 값을 물려받은 방문자 시뮬레이션)를 따릅니다.

키보드·스크린리더로 확인할 것

아홉 항목 모두 로딩이 시작되면 btn.disabled = true·btn.setAttribute('aria-busy', 'true') 로 중복 클릭을 막고, 화면 밖 status 요소가 role="status" aria-live="polite" 로 '저장하는 중'·'저장 완료' 같은 문구를 스크린리더에 읽어줍니다. 09 는 추가로 토글 버튼에 role="switch"·aria-checked 를 붙여 지금 시뮬레이션 중인 reduce 상태를 알립니다. 버튼 자체의 disabled 규격은 MDN button 요소 문서를, aria-busy 규격은 MDN aria-busy 문서를, prefers-reduced-motion 은 MDN prefers-reduced-motion 문서를 참고하면 됩니다.

변형 세 가지와 접근성

변형 값 체감
흔들림 지속 시간 늘리기 04 SHAKE_MS 520 → 900 오류 흔들림 지속 시간을 늘리면(520ms→900ms) 버튼이 더 오래 좌우로 흔들려 실패를 더 강하게 체감하게 만든다.
단계 문구 전환 간격 좁히기 08 STEP_INTERVAL_MS 450 → 260 단계 문구가 바뀌는 간격을 좁히면(450ms→260ms) 전체 시퀀스가 더 빠르게 지나가 단계를 읽기보다 진행감만 남는다.
진행률 채움 완료 후 정지 시간 늘리기 02 HOLD_AT_FULL_MS 500 → 1400 진행률이 끝까지 찬 뒤 멈춰 있는 시간을 늘리면(500ms→1400ms) 완료를 눈으로 확인할 여유가 늘지만 전체 흐름은 그만큼 느리게 느껴진다.

세 변형 모두 지속 시간 상수 하나만 바뀔 뿐, click 리스너 구성이나 disabled·aria-busy·aria-live 흐름은 손대지 않습니다. 스크린리더가 읽는 문구도 수정 전후로 달라지지 않으므로 어떤 값을 골라도 접근성 지원 범위는 그대로 유지됩니다.

css 버튼 디자인 예제는 어디서 보나?

이 편 위쪽 3×3 격자가 바로 그 예제이고, 버튼마다 아래 코드 펜스에서 계산부를 그대로 확인할 수 있습니다. hover 시점의 반응까지 폭넓게 보려면 236, 스피너만 따로 깊게 보려면 009 을 함께 열어보면 됩니다.

FAQ

로딩 중 버튼 문구를 여러 단계로 나눠서 보여주려면 어떻게 하나요?

08 의 STEP_LABELS 배열에 문구를 순서대로 넣고 STEP_INTERVAL_MS(450ms) 값으로 전환 간격을 조절하면 됩니다. measureOffsets 가 각 문구의 실제 높이를 다시 재기 때문에 문구를 늘려도 슬라이드 위치가 어긋나지 않습니다.

이 데모들은 실제 서버로 요청을 보내나요?

아니요. 아홉 항목 전부 fetch 호출이나 실제 날짜 계산 없이 setTimeout 으로 완료 시점만 흉내 낸 시뮬레이션입니다. 실제 서비스에 붙일 때는 각 항목의 runAnimated/runReduced 안 setTimeout 자리를 서버 응답을 기다리는 콜백으로 바꿔 끼우면 됩니다.

reduced-motion을 켠 화면에서도 아홉 가지 상태 전환이 전부 되나요?

네, 됩니다. reduce 가 막는 건 회전·채움·흔들림 같은 트윈뿐이고, 상태 자체(로딩 시작·완료·실패)는 01~08 의 runReduced() 가 같은 시점에 그대로 바꿔 줍니다. 09 는 처음부터 정지 표시를 다루는 항목이라 reduce 켜짐이 오히려 이 항목의 기본 동작입니다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.