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

무한 스크롤 로딩 UI 패턴 9종 — 스피너부터 가상 윈도우까지

무한 스크롤은 스크롤이 끝에 닿을 때마다 다음 목록을 자동으로 불러와 이어 붙이는 방식인데, 아홉 개 중 하나는 남은 스크롤 거리 값으로 로딩 표시의 투명도를 다시 계산하고, 하나는 이전에 늘어난 높이 값만큼 스크롤 위치를 보정해 화면이 밀리지 않게 하며, 또 하나는 보이는 인덱스

아홉 화면 모두 실제 서버 통신이 아니라 시드 고정 로컬 생성기와 setTimeout 지연으로 데이터 도착을 흉내 낸 것이고, 스크롤도 페이지 전체가 아니라 데모 프레임 안 자체 스크롤 영역에서 일어납니다. 04 는 클릭 한 번으로 값이 바뀌고 나머지 여덟 개는 스크롤·터치스타트·휠 중 하나가 값을 다시 계산하게 하는데, 아홉 개 전부 시드 고정 시퀀스로 자동 재생되는 동시에 실제 사용자 입력 리스너로도 똑같이 동작합니다 — 자동 재생 전용은 하나도 없습니다.

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

버튼·로딩 같은 낱개 컴포넌트와 달리 이 아홉 개는 화면 어딘가에서 이미 흔히 보이는 조각인데도, 목록 UI를 처음 만들 때 참고할 실측 값은 잘 안 보입니다. 아래 표는 아홉 패턴이 화면을 실제로 얼마나 흔들었는지 잰 값이고, 값이 거리·속도로 다음 요청 시점을 정하는 것(01·03·05) → 범위·위치 값을 다시 구해 화면을 고쳐 그리는 것(02·07·08) → 남은 개수 값이 0에 닿아 상태가 확정되는 것(04·06·09) 순으로 묶었습니다.

무한 스크롤 로딩 패턴 아홉 개의 해부 — 값이 언제 다시 계산되는가

실측 한눈에

항목 초점 누적면적 % 강도 평균 움직인 프레임
01 하단 스피너 페이드 남은 스크롤 거리 값 → 투명도 재계산 33.37 33.9 23/23
02 스켈레톤 행 이어붙이기 뷰포트 높이 값 → 미리보기 행 개수 계산 34.22 42.1 21/23
03 로딩 시작 기준선 스크롤 속도 값 → 임계 거리 재계산 33.02 48.8 23/23
04 더 보기 버튼 모핑 남은 항목 값 → 버튼 너비 재계산 2.63 70.7 10/23
05 중복 호출 방지 마지막 요청 시각 값 → 다음 호출 차단 27.36 46.1 23/23
06 끝 도달 안내 남은 항목 값 0 확정 → 안내 등장 33.58 39.6 21/23
07 스크롤 위치 고정 늘어난 높이 값 → 스크롤 위치 보정 23.70 86.3 21/23
08 가상 윈도우 재계산 스크롤 위치 값 → 보이는 인덱스 범위 재계산 33.79 43.9 23/23
09 실패 재시도 카운트다운 대기 시간 값 → 재시도 트리거 31.31 72.1 23/23

04 는 버튼 하나만 모양이 바뀌는 화면이라 누적면적이 9종 중 가장 작은 2.63%이고, 02 는 행 전체가 스켈레톤으로 깜빡이며 늘어나 34.22%로 가장 큽니다 — 둘 다 계산 대상 자체의 크기 차이일 뿐, 04 가 덜 계산된 게 아닙니다. 강도 평균은 07 이 86.3 으로 가장 높아 채팅 말풍선이 위로 밀리며 배경과 뚜렷하게 갈리는 픽셀 값을 만든다는 뜻이고, 01 은 33.9 로 가장 낮아 반투명 스피너의 인접 색 차이가 완만하다는 뜻입니다. 움직인 프레임이 04 만 10/23 으로 낮은 건 버튼 너비가 목표 값에 lerp 로 빠르게 수렴한 뒤 정지해서이지, 나머지 여덟 개처럼 계속 값을 흘리는 구조가 아니기 때문입니다.

거리·속도 값을 계산해 언제 부를지 정하는 축 — 01·03·05

이 셋은 클릭이 아니라 스크롤 위치·속도·시각 값을 계산해 다음 요청을 부를 시점을 정합니다. 01 은 recomputeFade()가 스피너와 스크롤 뷰 하단 사이의 실측 거리를 THRESHOLD_PX로 나눠 0~1 진행값을 만들고 그 값을 그대로 불투명도로 씁니다. 03 은 recomputeThreshold()가 이번 스크롤과 지난 스크롤 사이의 이동 거리를 걸린 시간으로 나눠 속도를 구한 뒤, 그 속도에 비례해 기준선 거리(threshold)를 다시 계산합니다. 05 는 tryRequest()가 마지막 요청 시각과 지금 시각의 차이(since)를 계산해 COOLDOWN_MS보다 작은 동안은 요청을 걸지 않고 막은 횟수만 셉니다.

범위·위치 값을 다시 구해 화면을 고쳐 그리는 축 — 02·07·08

이 셋은 값이 바뀔 때마다 이미 그려진 화면 자체를 다시 고칩니다. 02 는 skeletonCountFor()가 남은 화면 높이 값을 행 하나의 높이(ROW_H)로 나눠 미리 보여줄 스켈레톤 개수를 정합니다. 07 은 loadOlder()가 위쪽 묶음이 붙기 직전과 직후의 scrollHeight 차이(added)를 구해 그만큼을 scrollTop에 더해 보던 화면이 밀려 보이지 않게 합니다. 08 은 renderWindow()가 scrollTop 값으로 지금 보이는 인덱스 범위(start~end)를 다시 구해 그 범위 밖의 행은 DOM에서 아예 지웁니다.

남은 개수 값이 0에 닿아 상태가 확정되는 축 — 04·06·09

이 셋은 남은 값이 줄다가 특정 지점에 닿는 순간 상태가 바뀝니다. 04 는 press()가 remaining을 깎다가 0이 되면 done을 확정해 버튼을 비활성으로 바꾸고, 그 전까지는 morphTo()가 남은 개수 값에 맞춰 라벨과 목표 너비를 다시 구합니다. 06 은 recompute()가 exhausted 값이 한 번 참이 되면 화면 끝에 닿아도 다시 계산을 실행하지 않습니다. 09 는 runCountdown()이 실패 뒤 대기 시간 값을 매 프레임 다시 계산하다가 그 값이 0이 되면 같은 요청을 자동으로 다시 보냅니다.

패턴별 코드

01하단 스피너 페이드 — 남은 스크롤 거리 값으로 투명도를 계산하는 로딩 표시

목록 끝에 가까워진 상태가 되면 남은 스크롤 거리 값을 기준으로 투명도 값을 다시 계산해 로딩 표시가 점점 짙어지고, 새 항목이 도착한 상태가 되면 그 값이 다시 0으로 줄어듭니다. SNS 피드 하단, 상품 목록 페이지에 붙이기 좋은 형태입니다.

남은 거리 값이 임계 거리 안에 들어오는 순간 다시 도는 계산

  function recomputeFade() {
    var viewBottom = view.getBoundingClientRect().bottom;
    var sentinelTop = sentinel.getBoundingClientRect().top;
    var remaining = sentinelTop - viewBottom;
    var progress = 1 - Math.min(Math.max(remaining / THRESHOLD_PX, 0), 1);
    ring.style.opacity = String(progress);
    if (remaining <= 0 && !loading && rowCount < MAX_ROWS) {
      startLoad();
    }
    hint.textContent = (remaining <= 0 && rowCount >= MAX_ROWS)
      ? '더 내리면 처음으로 되돌아갑니다'
      : '끝까지 내리면 다음 묶음을 불러옵니다';
  }
하단 스피너 페이드 — 남은 스크롤 거리 값으로 투명도를 계산하는 로딩 표시
새 창에서 열기

01 · 하단 스피너 페이드 — 남은 스크롤 거리 값으로 투명도를 계산하는 로딩 표시 — 측정 누적면적 33.365% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

THRESHOLD_PXrecomputeFadegetBoundingClientRect

02스켈레톤 행 이어붙이기 — 뷰포트 높이 값으로 미리보기 행 개수를 계산하는 목록

다음 목록을 불러오는 상태가 되면 남은 화면 높이 값을 한 행의 높이 값으로 나눠 미리 보여줄 행 개수를 계산하고, 실제 데이터가 도착한 상태가 되면 그 개수만큼의 자리가 내용으로 바뀝니다. 뉴스 피드, 이미지 갤러리 그리드에 무한 스크롤 구현을 넣을 때 자주 보이는 형태입니다.

남은 화면 높이 값을 행 높이 값으로 나눠 미리보기 개수를 정하는 계산

  // 계산 겹: 지금 남아 있는 화면 높이 값을 행 하나의 높이 값으로 나눠 미리 보여줄
  // 스켈레톤 행 개수를 구한다 — 화면이 클수록, 스크롤 여유가 클수록 더 많이 깜빡인다.
  function skeletonCountFor() {
    var remainingViewport = view.getBoundingClientRect().bottom - sentinel.getBoundingClientRect().top;
    var visible = Math.max(remainingViewport, view.clientHeight);
    return Math.max(2, Math.ceil(visible / ROW_H));
  }
스켈레톤 행 이어붙이기 — 뷰포트 높이 값으로 미리보기 행 개수를 계산하는 목록
새 창에서 열기

02 · 스켈레톤 행 이어붙이기 — 뷰포트 높이 값으로 미리보기 행 개수를 계산하는 목록 — 측정 누적면적 34.218% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

skeletonCountFor--staggerROW_H

03로딩 시작 기준선 — 스크롤 속도 값으로 미리 당겨 시작하는 지점을 계산하는 목록

스크롤 속도가 빨라진 상태가 되면 화면 끝에서 시작 지점을 얼마나 당길지 값을 다시 계산해 그만큼 이르게 다음 목록을 부르고, 속도가 느려진 상태가 되면 그 값이 다시 줄어듭니다. 빠르게 훑어보는 목록형 앱, 사진 스트림에 맞는 형태입니다.

스크롤 속도 값이 커질 때마다 기준선 거리 값을 다시 늘리는 계산

  function recomputeThreshold() {
    var now = performance.now();
    var dt = Math.max(now - lastTime, 1);
    var speed = Math.abs(view.scrollTop - lastTop) / dt;
    lastTop = view.scrollTop;
    lastTime = now;
    threshold = Math.min(BASE_THRESHOLD_PX + speed * SPEED_GAIN, MAX_THRESHOLD_PX);
    badge.textContent = '기준선 ' + Math.round(threshold) + 'px';
  }
로딩 시작 기준선 — 스크롤 속도 값으로 미리 당겨 시작하는 지점을 계산하는 목록
새 창에서 열기

03 · 로딩 시작 기준선 — 스크롤 속도 값으로 미리 당겨 시작하는 지점을 계산하는 목록 — 측정 누적면적 33.015% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

recomputeThresholdSPEED_GAINperformance.now

04더 보기 버튼 모핑 — 남은 항목 값에 따라 너비가 바뀌는 버튼

더 보기 요청이 시작된 상태가 되면 버튼의 너비 값이 대기 중 표시에 맞춰 다시 계산되고, 다음 항목이 도착한 상태가 되면 남은 개수 값에 따라 원래 너비나 완료 표시로 되돌아갑니다. 댓글 더 보기, 검색 결과 하단에 쓰기 좋습니다.

남은 개수 값에 맞춰 라벨과 목표 너비를 다시 구하는 계산

  function morphTo(text) {
    label.textContent = text;
    var natural = label.getBoundingClientRect().width;
    targetWidth = Math.round(natural + PADDING_X);
    if (widthRaf) { return; }
    var current = btn.getBoundingClientRect().width;
    function tick() {
      current += (targetWidth - current) * WIDTH_LERP;
      btn.style.width = current + 'px';
      if (Math.abs(targetWidth - current) > 0.5) {
        widthRaf = requestAnimationFrame(tick);
      } else {
        btn.style.width = targetWidth + 'px';
        widthRaf = null;
      }
    }
    widthRaf = requestAnimationFrame(tick);
  }
더 보기 버튼 모핑 — 남은 항목 값에 따라 너비가 바뀌는 버튼
새 창에서 열기

04 · 더 보기 버튼 모핑 — 남은 항목 값에 따라 너비가 바뀌는 버튼 — 측정 누적면적 2.625% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

morphToWIDTH_LERPrequestAnimationFrame

05중복 호출 방지 — 마지막 요청 시각 값으로 다음 호출을 막는 목록

짧은 시간 안에 목록 끝에 여러 번 닿은 상태가 되면 마지막 요청 시각 값과의 차이를 계산해 그 값이 기준보다 작은 동안은 새 요청을 걸지 않고, 값이 기준을 넘은 상태가 되면 그제서야 다음 요청이 나갑니다. 빠른 마우스 휠 스크롤 환경, 저사양 기기에 필요한 형태입니다.

마지막 요청 시각 값과의 차이가 기준보다 작은 동안 호출을 막는 계산

  function tryRequest() {
    var now = performance.now();
    var since = now - lastRequestAt;
    if (since < COOLDOWN_MS) {
      blockedCount += 1;
      blockedEl.textContent = blockedCount + '회';
      sentinel.classList.add('dgd__sentinel--blocked');
      setTimeout(function () { sentinel.classList.remove('dgd__sentinel--blocked'); }, 180);
      return false;
    }
    lastRequestAt = now;
    loading = true;
    sentinel.classList.add('dgd__sentinel--loading');
    setTimeout(function () {
      for (var n = 0; n < 3; n += 1) { addRow(); }
      loading = false;
      batches += 1;
      sentinel.classList.remove('dgd__sentinel--loading');
    }, LOAD_DELAY_MS);
    return true;
  }
중복 호출 방지 — 마지막 요청 시각 값으로 다음 호출을 막는 목록
새 창에서 열기

05 · 중복 호출 방지 — 마지막 요청 시각 값으로 다음 호출을 막는 목록 — 측정 누적면적 27.358% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

tryRequestCOOLDOWN_MSperformance.now

06끝 도달 안내 — 남은 항목 값이 0이 된 순간 나타나는 메시지

다음 목록을 부른 결과로 남은 항목 값이 0이 된 상태가 되면 더 부를 목록이 없다는 계산이 확정되고, 그 상태에서는 화면 끝에 닿아도 값이 다시 계산되지 않습니다. 짧은 알림 목록, 완결된 게시글 모음에 어울리는 형태입니다.

남은 항목 값이 0으로 확정된 뒤에는 다시 계산되지 않는 조건

  function revealEnd() {
    endBox.hidden = false;
    var y = REVEAL_PX;
    function tick() {
      y += (0 - y) * 0.18;
      endBox.style.transform = 'translateY(' + y.toFixed(2) + 'px)';
      if (y > 0.4) { revealRaf = requestAnimationFrame(tick); }
      else { endBox.style.transform = 'translateY(0px)'; revealRaf = null; }
    }
    revealRaf = requestAnimationFrame(tick);
  }
끝 도달 안내 — 남은 항목 값이 0이 된 순간 나타나는 메시지
새 창에서 열기

06 · 끝 도달 안내 — 남은 항목 값이 0이 된 순간 나타나는 메시지 — 측정 누적면적 33.577% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

revealEndREVEAL_PXrequestAnimationFrame

07스크롤 위치 고정 — 위로 이어붙인 높이 값만큼 보정하는 목록

화면 위쪽으로 지난 항목을 불러온 상태가 되면 새로 늘어난 높이 값을 계산해 스크롤 위치 값에 그만큼을 더해, 보던 화면이 밀려 보이지 않게 위치가 그대로 유지됩니다. 채팅방 과거 대화 불러오기, 댓글 스레드 위로 펼치기에 쓰이는 형태입니다.

늘어나기 전후 높이 값의 차이를 스크롤 위치에 더하는 계산

  function loadOlder() {
    if (loading || batches >= MAX_BATCHES) { return; }
    loading = true;
    var before = view.scrollHeight;
    anchor.classList.add('asp__anchor--loading');
    setTimeout(function () {
      var frag = document.createDocumentFragment();
      for (var n = 0; n < OLDER_BATCH; n += 1) {
        seq += 1;
        frag.appendChild(bubble(seq, seq % 3 === 0));
      }
      track.insertBefore(frag, anchor);
      var added = view.scrollHeight - before;
      view.scrollTop += added;   // 위에 붙은 높이 값만큼 보정
      valueEl.textContent = '보정 ' + added + 'px';
      batches += 1;
      loading = false;
      anchor.classList.remove('asp__anchor--loading');
    }, LOAD_DELAY_MS);
  }
스크롤 위치 고정 — 위로 이어붙인 높이 값만큼 보정하는 목록
새 창에서 열기

07 · 스크롤 위치 고정 — 위로 이어붙인 높이 값만큼 보정하는 목록 — 측정 누적면적 23.703% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

loadOlderOLDER_BATCHscrollHeight

08가상 윈도우 재계산 — 보이는 범위 인덱스 값만 그리는 목록

목록이 계속 늘어난 상태가 되면 스크롤 위치 값으로 지금 보이는 인덱스 범위 값을 다시 구해 그 범위 밖의 항목은 그리지 않고, 범위가 바뀐 상태가 되면 위아래 여백 값도 그만큼 다시 계산됩니다. 수천 행짜리 데이터 표, 대용량 채팅 로그에 맞는 형태입니다.

스크롤 위치 값으로 보이는 인덱스 범위를 다시 구해 그 밖은 그리지 않는 계산

  function renderWindow() {
    var scrollTop = view.scrollTop;
    var first = Math.floor(scrollTop / ROW_H) - OVERSCAN;
    var visible = Math.ceil(view.clientHeight / ROW_H) + OVERSCAN * 2;
    var start = Math.max(0, Math.min(first, available - visible));
    var end = Math.min(available, start + visible);
    if (rendered === start + ':' + end) { return; }
    rendered = start + ':' + end;
    while (win.firstChild) { win.removeChild(win.firstChild); }
    for (var i = start; i < end; i += 1) {
      var li = document.createElement('li');
      li.className = 'vwr__row';
      li.textContent = '행 ' + String(i + 1).padStart(3, '0');
      win.appendChild(li);
    }
    topSpacer.style.height = (start * ROW_H) + 'px';
    bottomSpacer.style.height = Math.max(0, (available - end) * ROW_H) + 'px';
    rangeEl.textContent = '행 ' + start + ' - ' + (end - 1) + ' / ' + available;
    domEl.textContent = '그린 행 ' + (end - start);
  }
가상 윈도우 재계산 — 보이는 범위 인덱스 값만 그리는 목록
새 창에서 열기

08 · 가상 윈도우 재계산 — 보이는 범위 인덱스 값만 그리는 목록 — 측정 누적면적 33.788% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

renderWindowOVERSCANscrollTop

09실패 재시도 카운트다운 — 대기 시간 값이 줄어드는 만큼 다시 시도하는 목록

다음 목록 요청이 실패한 상태가 되면 대기 시간 값이 정해진 수에서 매 순간 줄어들고, 그 값이 0이 된 상태가 되면 같은 요청이 자동으로 다시 나갑니다. 불안정한 네트워크 환경, 오프라인 대비 목록에 필요한 형태입니다.

경과 시간 값을 감쇠 곡선에 넣어 남은 초와 링 길이를 다시 구하는 계산

  function runCountdown(done) {
    counting = true;
    retryBox.hidden = false;
    var t0 = performance.now();
    function tick(now) {
      var elapsed = (now - t0) / 1000;
      var k = Math.min(elapsed / WAIT_S, 1);
      var eased = 1 - Math.pow(1 - k, 2);   // ease-out — 처음에 빨리 줄고 끝에서 느리게 멈춘다
      var remainS = WAIT_S * (1 - eased);
      secondsEl.textContent = remainS.toFixed(1);
      arc.style.strokeDashoffset = String(CIRC * eased);
      if (k < 1) { countdownRaf = requestAnimationFrame(tick); }
      else { counting = false; retryBox.hidden = true; done(); }
    }
    countdownRaf = requestAnimationFrame(tick);
  }
실패 재시도 카운트다운 — 대기 시간 값이 줄어드는 만큼 다시 시도하는 목록
새 창에서 열기

09 · 실패 재시도 카운트다운 — 대기 시간 값이 줄어드는 만큼 다시 시도하는 목록 — 측정 누적면적 31.313% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

runCountdownCIRCperformance.now

값이 패턴을 바꾸는 곳 — 거리 값·인덱스 범위 값·대기 시간 값

아홉 개는 결국 값 하나가 어디서 흘러 들어오는지로 갈립니다. 무한 스크롤 페이지네이션을 직접 구현할 때는 이 세 갈래 중 화면에 맞는 값의 성격을 먼저 고르는 편이 코드를 줄입니다.

거리 값 — 스크롤이 멈추기 전에 미리 계산되는 곳

01 은 스피너와 뷰 하단 사이의 실측 거리를 임계 거리로 나눠 불투명도를 만들고, 03 은 그 임계 거리 자체를 스크롤 속도에 비례해 다시 늘립니다. 05 는 거리가 아니라 시간 차이를 같은 방식으로 재서 호출을 막는다는 점에서, 셋 다 "얼마나 남았나"를 다음 계산의 재료로 씁니다.

인덱스 범위 값 — 이미 그려진 화면을 고쳐 그리는 곳

02 는 남은 화면 높이를 행 높이로 나눈 값만큼 스켈레톤을 채우고, 08 은 스크롤 위치로 구한 인덱스 범위 밖의 행을 DOM에서 지웁니다. 07 은 범위가 아니라 높이 차이 값을 스크롤 위치에 더한다는 점에서, 셋 다 값이 바뀔 때마다 이미 있는 DOM을 다시 손댑니다.

대기 시간 값 — 타이머가 처음부터 다시 세는 곳

09 는 실패마다 대기 시간 값을 처음부터 다시 세고, 04 는 시간이 아니라 남은 개수 값이 0에 닿는 순간 상태를 확정합니다. 06 도 남은 항목 값이 0이 되는 순간을 기준으로 삼는다는 점에서 04 와 같은 축이지만, 06 은 그 뒤로 다시 계산 자체가 일어나지 않는다는 차이가 있습니다.

어디서 깨지는가 — 빠른 스크롤·느린 네트워크·목록이 비어 있을 때

빠른 스크롤 — 값이 채 자리 잡기 전에 다시 들어오는 지점

01 은 MAX_ROWS에 닿기 전까지 스크롤할 때마다 recomputeFade()가 다시 실행되는데, 휠을 아주 빠르게 굴리면 remaining이 음수로 떨어진 프레임과 로딩이 걸리는 프레임이 겹쳐 스피너가 짧게 두 번 깜빡일 수 있습니다. 05 는 이런 상황을 막으려고 COOLDOWN_MS 안에서는 아예 새 계산을 걸지 않지만, 그 값을 너무 크게 잡으면 정상 속도로 스크롤해도 다음 묶음이 늦게 나타나는 것처럼 느껴집니다.

느린 네트워크 — 대기 시간 값이 길어질수록 드러나는 지점

09 는 LOAD_DELAY_MS와 WAIT_S가 둘 다 늘어나는 상황을 가정한 형태인데, qp334h28 안 원본에서 실제 API 응답이 느려지면 runCountdown()이 매 프레임 다시 계산하는 링이 사용자 눈에 오래 머무르게 되므로 WAIT_S 상한을 두지 않으면 화면이 멈춘 것처럼 보일 수 있습니다. 07 은 느린 네트워크에서 과거 묶음이 늦게 도착하면 before 값을 잰 시점과 실제로 scrollTop을 보정하는 시점 사이가 벌어져, 그사이 사용자가 계속 위로 스크롤하면 보정 값이 한 번 더 필요해집니다.

목록이 비어 있을 때 — 계산의 재료 자체가 없는 지점

06 은 TOTAL_PAGES만큼 다 부르고 나면 exhausted를 켜 계산을 멈추는데, 애초에 목록이 한 페이지도 없는 상태로 시작하면 sentinel이 곧바로 제거돼 안내 문구가 뜨는 시점을 가늠하기 어렵습니다. 08 은 available이 0에 가까우면 renderWindow()가 구하는 start~end 범위가 아주 좁아져, 스크롤 가능한 영역 자체가 거의 없는 상태가 됩니다.

변형 세 가지와 접근성

변형 바꾸는 값 바뀌는 느낌
하단 스피너 임계 거리 01 THRESHOLD_PX 96px → 220px 임계 거리를 96px 로 두면 목록 끝에 아주 가까워져서야 스피너가 옅게 보이기 시작해 알아채기 늦고, 220px 로 늘리면 훨씬 이른 시점부터 서서히 짙어져 로딩이 다가온다는 예고가 길어지지만 그만큼 스피너가 화면에 떠 있는 시간도 길어집니다
버튼 모핑 보간 계수 04 WIDTH_LERP 0.24 → 0.65 계수를 0.24 로 두면 버튼 너비가 목표값까지 여러 프레임에 걸쳐 부드럽게 따라붙어 무게감이 느껴지고, 0.65 로 올리면 한두 프레임 만에 목표 너비에 닿아 즉각적이지만 탄성 없이 뚝 끊기는 느낌을 줍니다
재시도 대기 시간 09 WAIT_S 3 → 8 대기 시간을 3초로 두면 실패 뒤 재시도까지 금방 이어져 카운트다운 링이 스쳐 지나가듯 짧게 보이고, 8초로 늘리면 링이 천천히 도는 구간이 길어져 재시도를 기다리고 있다는 인상은 분명해지지만 사용자는 그만큼 오래 멈춰 있는 목록을 봐야 합니다

접근성

아홉 개 모두 @media (prefers-reduced-motion: reduce)에서 트랜지션·rAF 루프를 껐고, 이 값이 켜진 상태가 되면 06 은 안내 문구가 밀려 올라오는 애니메이션 없이 최종 위치로 곧바로 나타나며 09 는 카운트다운 링이 도는 과정 없이 최종 상태만 보여줍니다. 06 은 안내가 뜬 상태를 role="status"로, 09 는 실패 문구를 aria-live="polite"로 스크린 리더에 전달하고, 05 는 aria-busy 값으로 지금 요청이 진행 중인지 알립니다. scrollHeight·prefers-reduced-motion 같은 표준 API 동작은 MDN 문서와 reduced-motion 문서에 정리돼 있습니다.

FAQ

무한 스크롤 뜻이 정확히 무엇이고, 이 아홉 개는 그중 무엇을 다루나요?

무한 스크롤은 사용자가 스크롤 끝에 닿을 때마다 다음 데이터를 이어 붙여 페이지 이동 없이 목록을 계속 보여주는 방식을 뜻합니다. 이 아홉 개는 그 방식 자체가 아니라 "언제 부를지"·"화면을 어떻게 고쳐 그릴지"·"실패하면 어떻게 되돌릴지"를 계산하는 로딩 표시 아홉 종류를 다룹니다.

아홉 개 중 실제 서버와 통신하는 건 몇 개인가요?

없습니다. 아홉 개 전부 시드 고정 로컬 생성기와 setTimeout 지연으로 데이터 도착을 흉내 낸 것이고, fetch나 XHR을 호출하지 않습니다. 실제 서비스에 붙이려면 setTimeout 자리에 실제 요청 코드를 넣고 응답이 오는 시점에 같은 계산 함수를 부르면 됩니다.

무한 스크롤 목록 하나에 아홉 개를 전부 붙여도 되나요?

계산 방식끼리 충돌하지는 않지만, 02·07·08 처럼 스크롤마다 DOM을 다시 그리는 항목을 여러 개 겹치면 저사양 기기에서 프레임이 함께 떨어질 수 있습니다. 실제 화면에는 목록 하나에 필요한 패턴 한 개만 골라 붙이는 편이 안전합니다.

더 읽을거리

이 아홉 개는 스크롤이 이어 붙이는 시점과 방식을 계산하는 데 집중한다는 점에서, 아래 이웃 편들과는 값이 붙는 자리 자체가 다릅니다.

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

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