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

버퍼링 표시 애니메이션 9종 — 스피너부터 화질 배지까지

버퍼링 표시 아홉 가지 중 하나는 재생 위치와 버퍼링 위치를 두 겹의 링으로 함께 그리고, 하나는 오디오 파형이 버퍼된 만큼만 밝게 채워지며, 하나는 버퍼가 부족해지면 화질이 자동으로 낮아졌다는 배지가 뜹니다.

이벤트만 놓고 보면 아홉 가지 버퍼링 애니메이션 중 일곱 개(01~06·08)는 버튼 하나를 클릭할 때만 반응하는 단순한 구조를 공유하고, 07은 pointerdown·pointermove·pointerup에 화살표 키(keydown)까지 더해 아홉 개 중 가장 많은 입력을 듣는 드래그형 탐색바이며, 09는 클릭 버튼과 별개로 matchMedia의 change 리스너를 열어 두어 OS의 동작 줄이기 설정이 바뀌는 순간 자체를 이 편에서 유일하게 실시간으로 반영합니다. 입력의 많고 적음보다 계산 한 겹이 어디서 도는지(합성파·히스테리시스·베지어 이징·실측 좌표)가 이 편에서 항목을 늘어놓은 기준입니다.

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

아홉 개의 해부 — 계산 한 겹이 어디서 도는가

보통 css 로딩 애니메이션은 opacity나 transform 트윈 하나로 끝나지만, 아홉 개 모두 그 위에 계산 한 겹을 얹습니다 — 사인파를 합성해 신호를 흉내 내거나, 히스테리시스로 순간적인 튐을 거르거나, 포인터 좌표를 실측해 되먹입니다. 애니메이션 루프도 requestAnimationFrame 대신 setInterval(loop, TICK_MS)로 간격을 고정해, 측정 캡처 창에서 프레임 타이밍이 흔들리지 않게 했습니다.

번호 이름 누적면적 강도_평균 움직인프레임
01 정지 화면 위 버퍼링 오버레이 4.02% 71.5 20/23
02 재생·버퍼 이중 링 1.78% 140.2 23/23
03 신호 세기 막대 버퍼 표시 2.56% 141.2 13/23
04 버퍼된 만큼 채워지는 파형 4.61% 168.3 23/23
05 정지 감지 후 흔들리는 재시도 아이콘 10.05% 47.2 17/23
06 남은 용량·속도로 다시 계산하는 예상 시간 1.79% 106.0 23/23
07 탐색 막대의 버퍼 유령 미리보기 3.03% 143.0 23/23
08 대역폭 판정으로 바뀌는 화질 배지 1.53% 67.9 16/23
09 동작 줄이기 켜짐·꺼짐 나란히 비교 0.96% 110.3 23/23

누적면적이 가장 큰 항목은 05로, 재시도 아이콘이 흔들리는 동안 10.05%까지 오릅니다. 가장 작은 항목은 09로, 두 판이 좁은 링 하나만 움직여 0.96%에 그칩니다. 강도_평균이 가장 높은 항목은 04 하나뿐으로 168.3까지 오르고, 05는 47.2로 가장 낮은데 — 05는 흔들림 폭(SHAKE_AMP_PX)이 9px로 작게 잡혀 있는 반면 04는 파형 막대 하나가 채워지는 순간 높이와 밝기가 한꺼번에 바뀌기 때문입니다. 23개 틱 구간 가운데 움직인프레임이 23/23으로 가장 촘촘한 항목은 02·04·06·07·09이고, 03은 히스테리시스가 순간 변화를 걸러내려고 틱을 늘려 13/23으로 가장 뜸합니다.

합성 사인파 하나로 신호값을 흉내낸다 — 01·03·06·08

01은 두 항의 사인파(Math.sin(elapsedMs/190), Math.sin(elapsedMs/61))를 더해 만든 jitter 값으로 오버레이의 흐림·불투명도와 스피너 회전 속도를 한꺼번에 계산하고, 03은 세 항의 사인파를 더한 rawSpeed(ms)를 BAR_COUNT(5)로 늘린 뒤 히스테리시스로 걸러 막대 레벨을 정합니다. 06은 두 항의 사인파로 만든 instSpeed(ms)를 남은 용량과 나눠 매 틱 예상 시간을 다시 계산하고, 08은 같은 방식의 bandwidth(ms)를 HIGH_MBPS(6)·LOW_MBPS(2.5) 기준으로 고·중·저 세 단계로 판정한 뒤 유지 시간만큼 지나야 라벨을 바꿉니다. 네 항목 모두 Math.random을 쓰지 않는 결정적 함수라 같은 경과 시각엔 늘 같은 값이 나옵니다.

감쇠하는 사인파로 흔들림 폭을 줄인다 — 05

05의 shakeOffset(stallOverMs)은 SHAKE_AMP_PX(9px)에 지수 감쇠(Math.exp(-SHAKE_DECAY × stallOverMs), SHAKE_DECAY=0.0018)를 곱한 뒤 사인파로 방향을 뒤집어, 정지가 길어질수록 처음엔 크게 흔들리다 진폭이 점점 줄어드는 움직임을 만듭니다. STALL_THRESHOLD_MS(300ms)를 넘긴 뒤부터만 이 함수가 호출되므로 아주 짧은 끊김에는 흔들리지 않습니다.

원의 둘레와 3차 베지어로 값을 그림으로 바꾼다 — 02·04

02는 원의 둘레(CIRC)를 기준으로 재생 진행률만큼 stroke-dashoffset을 빼 안쪽 링을 채우고, 바깥 링은 --buffer-pct 값을 conic-gradient 정지점으로 받아 원 둘레를 색으로 나눕니다. 04는 easeFn()이 WAVEFORM_FILL_EASE 문자열을 읽어 linear거나 3차 베지어 곡선(bezier())으로 진행률을 다시 매기고, 그 결과를 mask-image로 막을 걷어내는 폭에 그대로 반영합니다.

실측 좌표와 실시간 리스너로 반응한다 — 07·09

07의 fracFromPointer(clientX)는 getBoundingClientRect로 트랙의 실제 위치와 폭을 매 pointermove마다 다시 재 포인터 비율을 계산하는 유일한 항목이고, 나머지 여덟 개는 합성파나 진행률 같은 값을 스스로 만들어 씁니다. 09는 tickMotion()이 각도를 연속으로 계산해 왼쪽 링을 돌리고 tickReduced()가 같은 시간을 REDUCED_STEP_MS(900ms) 단위로 끊어 오른쪽 링을 계단식으로 바꾸며, mq의 change 리스너로 OS 설정이 바뀌는 순간까지 즉시 반영해 둘을 나란히 비교합니다.

패턴별 코드

01정지 화면 위 버퍼링 오버레이

스트리밍 중 데이터가 끊기면 화면 위에 반투명 오버레이가 깔리고 그 위에서 스피너가 돕니다 — tick(elapsedMs)가 정지 경과 위상만으로 스피너 속도(SPIN_MS_STALL_START 1500ms → SPIN_MS_STALL_END 550ms)와 오버레이 흐림 정도를 산술로 계산합니다. 두 개의 사인 항을 더한 합성파가 매 순간 다른 흔들림 값을 만들어, Math.random 없이도 네트워크가 미세하게 떨리는 인상을 냅니다. 동영상·라이브 스트림 재생 화면에서 정지된 프레임 위에 상태를 얹을 때 씁니다.

  // 정지 경과 시간과 사이클 위상만으로 스피너 속도·오버레이 흐림 정도를 계산한다 — 값 자체는 CSS 트윈이 아니라 산술이다
  function tick(elapsedMs) {
    var phase = elapsedMs % STALL_CYCLE_MS;
    var stallSpan = STALL_CYCLE_MS * STALL_PORTION;
    var stalling = phase < stallSpan;
    if (stalling) {
      var stallFrac = clamp(phase / stallSpan, 0, 1);
      var spinMs = SPIN_MS_STALL_START - (SPIN_MS_STALL_START - SPIN_MS_STALL_END) * stallFrac;
      // 합성파 두 개로 네트워크 흔들림을 흉내낸다 — Math.random 은 렌더마다 프레임이 달라져 측정을 어지럽힌다
      var jitter = 0.5 + 0.28 * Math.sin(elapsedMs / 190) + 0.14 * Math.sin(elapsedMs / 61);
      jitter = clamp(jitter, 0, 1);
      ring.style.setProperty('--spin-duration', spinMs.toFixed(0) + 'ms');
      overlay.style.setProperty('--blur', (4 + jitter * 7).toFixed(2) + 'px');
      overlay.style.opacity = (0.5 + jitter * 0.28).toFixed(2);
      if (status.textContent !== '버퍼링 중') status.textContent = '버퍼링 중';
    } else {
      overlay.style.opacity = '0';
      if (status.textContent !== '재생 재개') status.textContent = '재생 재개';
    }
  }
정지 화면 위 버퍼링 오버레이
새 창에서 열기

01 · 정지 화면 위 버퍼링 오버레이 — 측정 누적면적 4.024% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

backdrop-filter@keyframesaria-live

02재생·버퍼 이중 링

원형 링을 두 겹으로 겹쳐 바깥 링은 지금까지 받아 둔 버퍼 양을, 안쪽 링은 실제 재생 위치를 함께 그립니다 — 두 링 사이의 간격 자체가 버퍼가 재생 위치를 얼마나 앞서는지를 보여주는 시연입니다. stroke-dashoffset이 원의 둘레(CIRC)를 기준으로 안쪽 링의 진행 길이를 정하고, 바깥 링은 --buffer-pct 값을 conic-gradient 정지점으로 받습니다. 동영상 재생바나 오디오 스트리밍 플레이어의 탐색 UI에 맞습니다.

  wrap.style.setProperty('--ring-w', BUFFER_RING_STROKE_WIDTH + 'px');
  playValue.style.strokeWidth = '4';
  playValue.setAttribute('stroke-dasharray', CIRC.toFixed(2));

  function easeInOut(x) { return x < 0.5 ? 2 * x * x : 1 - Math.pow(-2 * x + 2, 2) / 2; }
  function lerp(a, b, t) { return a + (b - a) * t; }

  var manualPlay = null;

  // 버퍼 퍼센트·재생 퍼센트를 각도(그라디언트 정지점)·둘레 길이(stroke-dashoffset)로 바꾸는 산술
  function apply(bufferFrac, playFrac) {
    var bp = Math.round(bufferFrac * 100);
    bufferRing.style.setProperty('--buffer-pct', bp + '%');
    var offset = CIRC * (1 - playFrac);
    playValue.style.strokeDashoffset = offset.toFixed(2);
    bufferPctEl.textContent = bp + '%';
    wrap.setAttribute('aria-valuenow', String(bp));
  }

  function tick(elapsedMs) {
    var t = (elapsedMs % LOOP_MS) / LOOP_MS;
    var bufferFrac = lerp(0.32, 0.95, t);
    var playFrac = manualPlay !== null ? manualPlay : lerp(0.04, 0.8, easeInOut(t));
    apply(bufferFrac, Math.min(playFrac, bufferFrac));
  }
재생·버퍼 이중 링
새 창에서 열기

02 · 재생·버퍼 이중 링 — 측정 누적면적 1.778% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

conic-gradientstroke-dashoffset--buffer-pct

03신호 세기 막대 버퍼 표시

신호 세기를 나타내는 막대 다섯 개가 버퍼링 속도에 맞춰 순서대로 밝아지고 어두워지길 반복해, 지금 받는 속도가 빠른지 느린지를 막대 개수로 보여줍니다. rawSpeed(ms)가 세 항의 사인파를 더해 만든 값을 BAR_COUNT로 늘린 뒤, 목표 레벨이 CHANGE_MARGIN(0.6) 이상 벌어진 채 HOLD_TICKS(3)만큼 유지될 때만 실제로 표시 레벨을 바꿔 순간적인 흔들림을 걸러냅니다. 통화·화상회의 연결 상태나 스트리밍 앱 상단바에 맞습니다.

  function tick(ms) {
    if (pinned !== null) return;
    var raw = rawSpeed(ms) * BAR_COUNT;
    var targetLevel = clamp(Math.round(raw), 0, BAR_COUNT);
    if (targetLevel !== displayLevel && Math.abs(raw - displayLevel) > CHANGE_MARGIN) {
      if (targetLevel === pendingLevel) {
        pendingCount++;
      } else {
        pendingLevel = targetLevel;
        pendingCount = 1;
      }
      if (pendingCount >= HOLD_TICKS) {
        displayLevel = pendingLevel;
        pendingCount = 0;
      }
    } else {
      pendingCount = 0;
      pendingLevel = displayLevel;
    }
    paint(displayLevel);
  }
신호 세기 막대 버퍼 표시
새 창에서 열기

03 · 신호 세기 막대 버퍼 표시 — 측정 누적면적 2.558% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

--bar-leveltransform: scaleYsetInterval

04버퍼된 만큼 채워지는 파형

오디오 파형 막대 위에 반투명 막을 겹쳐 두고 버퍼된 구간만큼만 그 막을 걷어 원래 색이 드러나게 합니다 — 오디오 분석 없이 버퍼 진행률 값 하나만으로 막이 걷히는 폭을 계산하는 시연입니다. easeFn()이 WAVEFORM_FILL_EASE 값을 3차 베지어 곡선으로 풀어 진행 속도에 곡선을 입히고, 경계에 걸친 막대 하나는 사인 함수로 짧게 솟아올라(bump) 채워지는 순간을 강조합니다. 팟캐스트·음악 플레이어 재생바에 맞습니다.

  function easeFn() {
    if (WAVEFORM_FILL_EASE === 'linear') return function (x) { return x; };
    var nums = WAVEFORM_FILL_EASE.replace('cubic-bezier(', '').replace(')', '').split(',').map(Number);
    return bezier(nums[0], nums[1], nums[2], nums[3]);
  }
  var ease = easeFn();

  function paint(pct) {
    var pctStr = (pct * 100).toFixed(1) + '%';
    film.style.setProperty('--buffer-pct', pctStr);
    wrap.setAttribute('aria-valuenow', String(Math.round(pct * 100)));
    var raw = pct * BAR_COUNT;
    var idx = Math.min(BAR_COUNT - 1, Math.floor(raw));
    var frac = raw - idx;
    bars.forEach(function (b, i) {
      var bump = i === idx ? Math.sin(frac * Math.PI) * BUMP_PX : 0;
      b.style.setProperty('--bump', bump.toFixed(1) + 'px');
    });
  }
버퍼된 만큼 채워지는 파형
새 창에서 열기

04 · 버퍼된 만큼 채워지는 파형 — 측정 누적면적 4.609% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

mask-image--buffer-pctlinear-gradient

05정지 감지 후 흔들리는 재시도 아이콘

재생이 멈춘 채로 STALL_THRESHOLD_MS(300ms)가 지나면 상태 문구가 '정지됨'에서 '연결이 끊겨 다시 시도하는 중'으로 바뀌며 재시도 아이콘이 흔들립니다. shakeOffset()이 감쇠율(SHAKE_DECAY)만큼 진폭을 지수적으로 줄여가는 사인파로 흔들림 폭을 계산해, 처음엔 크게 흔들리다 짧게 잦아들고 정지 구간이 끝나면 바로 재생 재개로 돌아갑니다. 불안정한 네트워크 환경의 영상·오디오 플레이어에 맞습니다.

    if (manualRecovered !== null) return;
    var loopPos = elapsedMs % LOOP_MS;
    var normalEnd = LOOP_MS * NORMAL_FRAC;
    var stallEnd = LOOP_MS * STALL_FRAC;
    if (loopPos < normalEnd) {
      setStatus('정상 재생');
      iconWrap.style.transform = 'translateX(0px)';
      card.classList.remove('is-stalled');
    } else if (loopPos < stallEnd) {
      var stallElapsed = loopPos - normalEnd;
      card.classList.add('is-stalled');
      if (stallElapsed > STALL_THRESHOLD_MS) {
        setStatus('연결이 끊겨 다시 시도하는 중');
        var offset = shakeOffset(stallElapsed - STALL_THRESHOLD_MS);
        iconWrap.style.transform = 'translateX(' + offset.toFixed(2) + 'px)';
      } else {
        setStatus('정지됨');
        iconWrap.style.transform = 'translateX(0px)';
      }
    } else {
      setStatus('재생 재개');
      iconWrap.style.transform = 'translateX(0px)';
      card.classList.remove('is-stalled');
    }
정지 감지 후 흔들리는 재시도 아이콘
새 창에서 열기

05 · 정지 감지 후 흔들리는 재시도 아이콘 — 측정 누적면적 10.052% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translateXsetIntervalis-stalled

06남은 용량·속도로 다시 계산하는 예상 시간

지금까지 받은 용량과 매 틱 다시 계산한 순간 속도로 '남은 용량 ÷ 순간 속도'를 다시 나눠 예상 시간을 갱신합니다 — 대기 카운트다운을 저장해 두는 방식이 아니라, 지금 채워지는 속도로 매 순간 남은 초를 다시 계산하는 방식입니다. instSpeed(ms)가 두 항의 사인파를 더해 순간 전송 속도를 흉내 내고, 일시정지하면 문구가 멈추며 다 받으면 '받기 완료'로 바뀝니다. 대용량 스트리밍 로딩이나 다운로드 예상 완료 표시에 맞습니다.

  // 결정론적 합성파로 순간 전송 속도를 흉내낸다 — 매 틱 다른 속도라 ETA 가 매번 다시 계산된다
  function instSpeed(ms) {
    var w = 1 + SPEED_VARIANCE * Math.sin(ms / 480) + 0.2 * Math.sin(ms / 133);
    return Math.max(0.15, BASE_SPEED_MBPS * w);
  }

  function paint(speed) {
    var pct = Math.min(100, (loadedMb / TOTAL_MB) * 100);
    fill.style.setProperty('--fill-pct', pct.toFixed(1) + '%');
    track.setAttribute('aria-valuenow', String(Math.round(pct)));
    detail.textContent = loadedMb.toFixed(1) + 'MB / ' + TOTAL_MB.toFixed(1) + 'MB · ' + speed.toFixed(1) + 'MB/s';
    if (paused) {
      etaEl.textContent = '일시정지됨';
    } else if (loadedMb >= TOTAL_MB) {
      etaEl.textContent = '받기 완료';
    } else {
      // 남은 용량 ÷ 지금 속도 = 예상 시간 — 저장해 둔 카운트다운이 아니라 매 틱 새로 나눈 값
      var remaining = TOTAL_MB - loadedMb;
      var etaSec = remaining / speed;
      etaEl.textContent = '약 ' + Math.ceil(etaSec) + '초 남음';
    }
  }
남은 용량·속도로 다시 계산하는 예상 시간
새 창에서 열기

06 · 남은 용량·속도로 다시 계산하는 예상 시간 — 측정 누적면적 1.794% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

performance.now--fill-pctsetInterval

07탐색 막대의 버퍼 유령 미리보기

탐색 막대를 드래그하면 getBoundingClientRect로 실측한 트랙 폭을 기준으로 포인터 비율을 구해 유령 마커 위치와 시간 라벨을 계산합니다. fracFromPointer(clientX)가 매 pointermove마다 트랙 위치를 다시 재기 때문에 화면 크기가 바뀌어도 유령 위치가 좌표와 어긋나지 않고, 재생·버퍼 시간을 함께 보여줍니다. 동영상 탐색바나 팟캐스트 구간 이동에 맞습니다.

  // 실제 포인터 좌표 → 트랙 안 비율로 바꾸는 계산 (getBoundingClientRect 실측)
  function fracFromPointer(clientX) {
    var rect = track.getBoundingClientRect();
    return clamp((clientX - rect.left) / rect.width, 0, 1);
  }

  var dragging = false;

  track.addEventListener('pointermove', function (ev) {
    human();
    var f = fracFromPointer(ev.clientX);
    setGhost(f);
    if (dragging) {
      manualPlayFrac = f;
      paint(f, f);
    }
  });

  track.addEventListener('pointerdown', function (ev) {
    human();
    dragging = true;
    var f = fracFromPointer(ev.clientX);
    manualPlayFrac = f;
    paint(f, f);
  });
탐색 막대의 버퍼 유령 미리보기
새 창에서 열기

07 · 탐색 막대의 버퍼 유령 미리보기 — 측정 누적면적 3.027% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

pointermove--ghost-pctgetBoundingClientRect

08대역폭 판정으로 바뀌는 화질 배지

합성파로 흉내낸 대역폭 값을 매 틱 고·중·저로 판정하되, 새 판정이 QUALITY_BADGE_HOLD_MS(1200ms) 동안 유지될 때만 배지 글자를 바꿔 순간적인 튐을 막습니다. 배지 바탕색은 그 사이에도 liveColor()가 원시 대역폭 값을 매 틱 계속 섞어 실시간 신호를 따로 보여주므로, 글자와 색이 서로 다른 갱신 규칙을 따릅니다. 적응형 스트리밍 플레이어의 상태 표시에 맞습니다.

  // 대역폭이 임계값을 넘었다고 즉시 바꾸지 않고, QUALITY_BADGE_HOLD_MS 만큼 그 판정이
  // 계속 유지될 때만 라벨·아이콘을 바꾼다 — 순간 튐으로 배지 글자가 깜빡이는 것을 막는 판정 계산.
  // (바탕색은 위 liveColor() 로 매 틱 계속 움직여 실시간 신호 자체는 감추지 않는다)
  function tick(ms) {
    var mbps = bandwidth(ms);
    if (forced !== null) { paint(forced, mbps); return; }
    var target = judge(mbps);
    if (target !== displayLevel) {
      if (target === pendingLevel) {
        if (ms - pendingSinceMs >= QUALITY_BADGE_HOLD_MS) {
          displayLevel = target;
        }
      } else {
        pendingLevel = target;
        pendingSinceMs = ms;
      }
    } else {
      pendingLevel = displayLevel;
    }
    paint(displayLevel, mbps);
  }
대역폭 판정으로 바뀌는 화질 배지
새 창에서 열기

08 · 대역폭 판정으로 바뀌는 화질 배지 — 측정 누적면적 1.535% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

data-levelbackgroundColorQUALITY_BADGE_HOLD_MS

09동작 줄이기 켜짐·꺼짐 나란히 비교

왼쪽 판은 tickMotion()으로 각도를 연속 계산해 링을 계속 회전시키고, 오른쪽 판은 같은 경과 시간을 REDUCED_STEP_MS(900ms) 단위로 끊은 tickReduced()로 계단식으로만 바꿔 두 판을 나란히 비교합니다. 시스템 설정 확인 문구는 matchMedia의 change 리스너로 실시간 반영되어, 다른 여덟 개처럼 로드 시점에 한 번만 확인하는 것과 달리 OS 설정이 바뀌는 순간 그대로 따라갑니다. 접근성 대응이 필요한 모든 버퍼링 화면에 맞습니다.

  // 실사용 경로 적용 — 실제 OS 환경설정이 바뀌면 이 리스너가 즉시 문구를 다시 쓴다(새로고침 불필요)
  function updateSystemStatus() {
    sysStatus.textContent = '시스템 설정: 동작 줄이기 ' + (mq.matches ? '켜짐' : '꺼짐');
  }
  mq.addEventListener('change', updateSystemStatus);
  updateSystemStatus();

  // 왼쪽(동작 유지)은 각도를 연속으로 계산해 회전시키고, 오른쪽(동작 줄임)은 같은 시간 입력을
  // REDUCED_STEP_MS 단위로 끊어(정수 나눗셈) 계단식으로만 바꾼다 — 두 분기를 같은 시계로 비교하는 계산.
  function tickMotion(ms) {
    var deg = (ms % SPIN_MS) / SPIN_MS * 360;
    ringMotion.style.transform = 'rotate(' + deg.toFixed(1) + 'deg)';
  }

  function tickReduced(ms) {
    var step = Math.floor(ms / REDUCED_STEP_MS) % 2;
    ringReduced.style.setProperty('--dim', step === 0 ? '1' : '.45');
  }
동작 줄이기 켜짐·꺼짐 나란히 비교
새 창에서 열기

09 · 동작 줄이기 켜짐·꺼짐 나란히 비교 — 측정 누적면적 0.961% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

prefers-reduced-motionaria-livetextContent

값이 UI를 바꾸는 곳 — 속도·임계치·유지시간·순서

값 하나만 바뀌어도 인상이 통째로 달라집니다. 01은 STALL_PORTION(0.72)이 한 사이클 중 정지 상태가 차지하는 비율을, 02는 BUFFER_RING_STROKE_WIDTH가 두 링 사이 띠의 두께를, 03은 HOLD_TICKS·CHANGE_MARGIN이 표시 레벨이 바뀌기까지 버텨야 하는 틱 수와 폭을, 04는 WAVEFORM_FILL_EASE가 막이 걷히는 속도 곡선의 모양을, 05는 STALL_THRESHOLD_MS·SHAKE_AMP_PX가 흔들림이 시작되는 시점과 최대 진폭을, 06은 SPEED_VARIANCE(0.55)가 순간 속도가 기준값 대비 얼마나 출렁이는지를, 07은 BUFFER_LEAD(0.28)가 유령 마커가 재생 위치보다 얼마나 앞서 표시되는지를, 08은 QUALITY_BADGE_HOLD_MS·HIGH_MBPS·LOW_MBPS가 배지 글자가 바뀌기까지 걸리는 시간과 고·중·저를 가르는 경계를, 09는 REDUCED_STEP_MS가 계단식 판의 갱신 간격을 그대로 결정합니다.

이웃 가지

같은 로딩바 애니메이션 축에서도 초점은 갈립니다. 값 하나가 채워지는 과정 자체를 다루는 파일 업로드 진행률 로더 9종 — 드래그부터 체크까지 계산은 업로드 진행률 숫자 하나가 바뀌는 데 집중하지만, 이 편의 02·04는 그 진행률을 재생 위치와 겹쳐 두 값의 관계(버퍼가 재생을 얼마나 앞서는지)를 보여준다는 점이 다릅니다. 속도 값으로 화면을 바꾸는 축은 다운로드 진행 상황 표시 9종 — 값이 퍼센트 단위로 갱신되는 아홉 가지 방식과도 이어지지만, 이 편의 06·08은 속도를 퍼센트가 아니라 예상 시간이나 화질 등급으로 옮겨 계산한다는 점에서 갈립니다. 값 자체가 아니라 소리 신호를 시각화하는 오디오 비주얼라이저 만들기 9종 — 막대부터 마이크까지는 실시간 오디오 분석이 입력인 반면, 이 편의 04는 오디오 분석 없이 버퍼 진행률 값 하나로 막대의 막을 걷어낼 뿐입니다.

어디서 깨지는가 — 접근성·reduced-motion·저속 네트워크

아홉 개를 실제 프로젝트에 그대로 붙여 확인하고 싶다면 다운로드 zip 비밀번호 nj7m3t5r로 vanilla·react 코드를 함께 풀어볼 수 있습니다.

08의 tick()은 배지 글자(라벨)만 QUALITY_BADGE_HOLD_MS(1200ms) 동안 판정이 유지될 때 바꾸고, 배지 바탕색은 이 홀드 로직과 별개로 liveColor()가 원시 대역폭 값을 매 틱 계속 섞습니다. 이 둘을 하나의 판정 함수로 합쳐 텍스트가 바뀔 때만 배경색도 같이 바꾸면, 대역폭이 잠깐씩 튈 때마다 배경색까지 같이 멈춰버려 배지 전체가 죽은 것처럼 보입니다 — 글자는 안정시키되 색은 계속 살아 있게 두는 두 갈래 갱신이 이 항목의 핵심입니다.

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

01~08은 로드 시점에 window.matchMedia('(prefers-reduced-motion: reduce)').matches를 한 번만 읽어 그 값을 reduce 변수에 고정합니다. reduce가 참이면 01은 회전·흐림 변화를 멈추고 정지된 오버레이 상태 하나만 고정해서 보여주고, 02는 링 자체는 계속 진행률을 나타내되 전환만 끄고 값을 정적으로 한 번 그리며, 03은 막대 높이 전환을 멈추고 마지막 측정 레벨 하나로 고정합니다. 04는 채워지는 전환을 멈추고 버퍼 진행률을 고정된 한 장면으로만 보여주고, 05는 흔들림(진동)만 멈추고 정지 상태·문구는 그대로 전달하며, 06은 채움 막대 전환만 멈추고 남은 시간 문구를 한 값으로 고정하고, 07은 자동 이동을 멈추고 재생·버퍼·유령 위치를 한 장면으로 고정하며, 08은 판정 갱신을 멈추고 한 등급을 고정해서 보여줍니다. 09만 다릅니다 — 로드 시점 판정 대신 mq.addEventListener('change', updateSystemStatus)로 실제 OS 설정이 바뀌는 순간을 실시간으로 반영하고, tickReduced()가 원래부터 계단식 갱신 자체를 보여주는 시연이라 reduce 여부와 무관하게 두 판을 나란히 켜 둡니다.

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

02·04·06·07은 role="progressbar"(07은 slider)와 aria-valuenow를 매 틱 갱신해 스크린리더가 지금 값을 수치로 읽게 하고, 그중 06·07은 상태 문구에 aria-live="polite"까지 겹쳐 달아 두 방식을 함께 씁니다. 01·05·08·09는 role 없이 상태 문구 노드에 aria-live="polite"만 달아 텍스트가 바뀔 때마다 자동으로 안내되고, 03은 막대 대신 role="img"와 aria-label로 신호 세기를 문장으로 매 틱 갱신해 전달합니다. 07은 pointerdown·pointermove·pointerup 드래그와 별개로 화살표 키(keydown)가 같은 유령 위치를 직접 옮겨, 마우스 없이도 탐색이 됩니다. aria-live 속성과 prefers-reduced-motion 미디어 쿼리의 정확한 동작은 MDN 문서에 정리돼 있습니다.

변형 세 가지와 접근성

변형 값 느낌
버퍼 링 두께 키우기 02 BUFFER_RING_STROKE_WIDTH 6px → 14px 두 링 사이 간격이 훨씬 두꺼운 띠로 보여, 버퍼가 재생 위치를 얼마나 앞서는지가 숫자 없이도 한눈에 굵게 드러난다
파형 채움 이징 부드럽게 04 WAVEFORM_FILL_EASE linear → cubic-bezier(.22,1,.36,1) 막이 걷히는 속도가 끝으로 갈수록 느려져, 버퍼가 막 따라잡은 순간을 뚝뚝 끊기지 않고 부드럽게 멈춰서는 느낌으로 보여준다
화질 하향 배지 유지 시간 늘리기 08 QUALITY_BADGE_HOLD_MS 1200 → 3000 배지가 화면에 머무는 시간이 두 배 넘게 늘어, 버퍼가 금방 회복돼도 방금 화질이 낮아졌었다는 사실을 놓치지 않고 읽을 여유가 생긴다

세 변형 모두 화면 뼈대(마크업)나 리스너 구조는 그대로 두고 상수 하나만 옮긴 값입니다 — 접근성 속성(role·aria-valuenow·aria-live)과 reduce 분기는 변형과 무관하게 그대로 동작해, 두께나 이징, 유지 시간을 바꿔도 스크린리더가 읽는 값이나 동작 줄이기 대응은 깨지지 않습니다.

FAQ

아홉 개 다 실제 네트워크 상태를 읽나요?

아니요. 아홉 개 모두 완전히 시뮬레이션입니다 — fetch나 미디어 API, 실제 Date 시각, 실제 URL을 쓰지 않고 사인파 합성이나 pointermove 좌표만으로 값을 계산합니다. 서비스 이름이나 코덱·해상도 같은 상표성 표기도 없어, 어떤 실제 스트리밍 서비스와도 무관한 순수 데모입니다.

화면 문구를 다른 언어나 다른 서비스 이름으로 바꿔서 로딩바 애니메이션으로 옮겨 써도 되나요?

네, 상태 문구는 각 항목의 setStatus·textContent 대입부 문자열만 바꾸면 됩니다. 다만 03의 HOLD_TICKS·CHANGE_MARGIN이나 08의 QUALITY_BADGE_HOLD_MS처럼 판정을 지연시키는 상수는 문구와 별개로 남아 있으니, 반응 속도를 더 빠르게 원하면 이 값도 함께 낮춰야 합니다.

setInterval 대신 requestAnimationFrame으로 바꿔도 되나요?

가능합니다. 지금은 캡처 측정 창에서 프레임 타이밍을 고정하려고 setInterval(loop, TICK_MS)를 썼는데, 실제 서비스에 붙일 때는 tick(ms) 계산 함수는 그대로 두고 호출부만 requestAnimationFrame 루프로 바꾸면 동일하게 동작합니다 — 값을 만드는 함수와 값을 언제 부르는지는 이 아홉 개에서 항상 분리돼 있습니다.

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

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