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

무한 스크롤 구현 9종 — 리스트·테이블 UI 기본 패턴

무한 스크롤 구현 아홉 가지 중 하나는 새로 붙는 행이 로딩 스피너 없이 순서대로 미끄러져 들어오고, 하나는 정렬 버튼을 누르면 행들이 실제로 자리를 맞바꾸며 이동하고, 하나는 가로로 스크롤할 때 얼어붙은 첫 칸에만 그림자가 따라붙습니다.

이벤트만 놓고 보면 02·05·07은 클릭(click) 하나로만 반응하는 단순한 구조를 공유하고, 03은 pointerdown·pointermove·pointerup만으로 당김을 추적하는 순수 드래그형이며, 06은 같은 포인터 세 종에 click(삭제 버튼)까지 더해 두 계열의 입력을 함께 듣습니다. 무한 스크롤 사이트에서 흔한 01과 08은 touchstart·wheel로 스크롤 의도를 함께 듣는다는 공통점이 있는데, 01은 여기에 IntersectionObserver라는 표준 관찰자까지 더해 센티널이 뷰포트에 걸친 비율을 직접 재고, 08은 scroll·pointerdown까지 얹어 touchstart·wheel·scroll·pointerdown 네 가지 서로 다른 리스너를 겹쳐 듣습니다. 04는 click과 별개로 CSS 애니메이션이 실제로 끝나는 순간을 animationend로 감지하는 유일한 항목이고, 09는 click·keydown과 별개로 resize를 열어 두어 뷰포트 크기가 바뀔 때도 점 위치를 다시 잽니다. 입력 종류의 많고 적음보다 어느 항목이 어떤 산술을 한 겹 더 얹는지가 이 편의 늘어놓은 순서를 정합니다.

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

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

누구나 리스트·테이블에서 스크롤·클릭·드래그에 반응하는 동작을 기대하지만, 그 반응이 어떤 계산에서 나오는지는 아홉 가지가 저마다 다릅니다. Math.random은 아홉 개 전부 쓰지 않는 결정적 계산이라 같은 조작에는 항상 같은 결과가 나오고, 데모 화면의 자동 시연은 .is-demo && !reduce 조건으로만 돌아 실제 페이지에 심었을 때는 절대 저절로 움직이지 않습니다.

번호 이름 누적면적 강도_평균 움직인프레임
01 스피너 없이 이어붙는 무한 스크롤 35.55% 44.6 23/23
02 첫 로딩용 리스트 스켈레톤 8.96% 42.7 12/23
03 새로고침 뒤 바뀐 행만 훑는 풀 투 리프레시 29.58% 43.5 12/23
04 행이 전부 사라지면 나타나는 빈 리스트 문구 46.35% 47.9 21/23
05 툴바 없이 배지로만 세는 다중 선택 1.73% 45.1 9/23
06 한 방향으로만 밀리는 기본 스와이프 액션 2.77% 83.9 16/23
07 행이 실제로 자리를 바꾸며 정렬되는 표 2.23% 121.3 9/23
08 가로 스크롤에서 그림자를 데리고 다니는 고정 칼럼 4.62% 139.7 23/23
09 숫자 없이 점 하나가 미끄러지는 페이지네이션 1.67% 96.7 16/23

누적면적이 가장 큰 항목은 04로 46.35%에 이르는데, 목록에서 마지막 행까지 지워지며 영역 전체가 빈 상태 문구로 바뀌는 넓은 전환이라 그렇습니다. 가장 작은 항목은 09로, 점 하나가 필 위치까지 옮겨가는 좁은 전환이라 1.67%에 그칩니다. 강도_평균이 가장 높은 항목은 08 하나로 139.7까지 오르는데, 그림자 알파는 옅어도 스크롤이 나는 매 순간 갱신되는 픽셀 변화량 자체가 크기 때문입니다. 강도_평균이 가장 낮은 항목은 02로 42.7에 그치는데, 스켈레톤 막대가 회색조 한 번의 페이드로만 바뀌기 때문입니다. 23개 델타 구간 가운데 움직인프레임이 가장 촘촘한 항목은 01·08로 나란히 23/23이고, 가장 뜸한 항목은 05·07로 나란히 9/23입니다.

관찰자가 재는 비율을 입력값으로 쓴다 — 01

01의 IntersectionObserver 콜백은 entry.intersectionRatio — 센티널이 뷰포트에 실제로 얼마나 겹쳤는지를 관찰자가 직접 재는 값 — 를 그대로 batch 크기 계산에 넣습니다. 비율을 clamp한 뒤 MIN_BATCH·MAX_BATCH 사이로 곱해 한 번에 붙일 행 수를 정하므로, 센티널이 화면 끝에 살짝 걸리면 조금만, 깊이 들어오면 한 번에 크게 이어붙입니다.

자기 자신의 실제 치수를 실측해 값을 만든다 — 02·08·09

02의 measureBarWidths()는 실제로 렌더링하지 않고도 canvas의 measureText로 각 행이 보여줄 글자의 실제 폭을 재서 스켈레톤 막대의 폭 비율을 만들고, 08의 updateShadow()는 view.scrollLeft를 SHADOW_RAMP_PX로 정규화해 고정 칼럼 그림자의 알파를 산술로 만들며, 09의 measureAndMove()는 dot.offsetLeft를 실측해 필이 옮겨갈 정확한 좌표를 구합니다. 셋 다 값을 미리 정해두지 않고 화면에 실제로 그려진 요소의 치수를 매번 다시 재는 공통점이 있습니다.

지수 감쇠로 저항 곡선을 만든다 — 03

03의 applyPull()은 손가락이 당긴 원시 거리(raw)를 MAX_PULL_PX * (1 - Math.exp(-raw / DAMPING))라는 지수 감쇠 곡선에 통과시켜 표시량을 만듭니다. 당기기 시작할 땐 손가락을 거의 그대로 따라오다가, 많이 당길수록 늘어나는 양이 점점 줄어드는 저항감이 이 한 줄에서 나옵니다.

rAF 루프로 값을 보간한다 — 04

04의 step()은 아홉 개 가운데 유일하게 requestAnimationFrame 루프를 직접 돕니다. 목표 높이(targetHeight)와 현재 높이(currentHeight)의 차이에 LERP_RATE(0.18)를 곱한 값만큼씩 매 프레임 다가가다가, 차이가 0.5 미만으로 줄면 루프를 멈추고 정확한 최종값으로 스냅합니다.

산술 범위로 상태를 한 번에 맞춘다 — 05

05의 applyRange()는 Shift를 누른 채 클릭했을 때 마지막으로 다룬 인덱스와 이번 인덱스 사이의 최솟값·최댓값을 구해 그 구간 전체를 한 번의 반복으로 같은 체크 상태로 맞춥니다. 행 하나씩 누르지 않아도 범위 선택이 되는 것은 이 산술 때문입니다.

손을 뗄 때의 속도로 판정한다 — 06

06의 pointerup 핸들러는 손을 뗄 때의 velocity가 VELOCITY_OPEN_THRESHOLD를 넘는지로 먼저 판정하고, 임계값 안쪽일 때만 currentX()가 -ACTION_WIDTH / 2보다 작은지로 결정합니다. 멈춘 위치보다 손을 뗄 때의 속도가 우선하는 판정입니다.

실제 좌표를 재서 이동 거리를 만든다(FLIP) — 07

07의 sortBy()는 정렬 전 각 행의 getBoundingClientRect().top을 먼저 기록해 두고, DOM을 실제로 재정렬한 뒤 같은 값을 다시 재 그 차이(delta)만큼 transform으로 되돌렸다가 0으로 애니메이션하는 FLIP 기법을 씁니다. 값만 다시 나열되는 게 아니라 행이 이전 자리에서 새 자리까지 이동하는 것처럼 보이는 이유입니다.

패턴별 코드

01스피너 없이 이어붙는 무한 스크롤

스크롤이 바닥 근처에 닿으면 다음 묶음의 행이 로딩 스피너나 스켈레톤 없이 이미 있는 목록 끝에 순서대로 미끄러져 붙습니다. IntersectionObserver로 관찰하는 센티널이 뷰포트에 걸친 비율에 따라 한 번에 붙는 행 수가 달라지며, 349의 로딩 피드백 중심 무한 스크롤과 달리 다음 행이 들어오는 등장 동작 자체만 다룹니다. 피드·상품 목록의 다음 페이지 자동 로드에 맞습니다.

  // 계산 겹: 센티널이 뷰포트와 겹친 비율(intersectionRatio)이 클수록 한 번에 더 많은 행을 붙인다 —
  // 화면 끝에 살짝 걸리면 조금만, 깊이 들어오면 한 번에 크게 이어붙인다.
  var io = new IntersectionObserver(function (entries) {
    entries.forEach(function (entry) {
      if (entry.isIntersecting && !loading && rowSeq < MAX_ROWS) {
        loading = true;
        var batch = Math.round(MIN_BATCH + (MAX_BATCH - MIN_BATCH) * clamp(entry.intersectionRatio, 0, 1));
        for (var j = 0; j < batch; j += 1) { addRow(j * SCROLL_APPEND_STAGGER_MS); }
        window.setTimeout(function () { loading = false; }, LOAD_COOLDOWN_MS);
      }
    });
  }, { root: view, threshold: [0, 0.2, 0.4, 0.6, 0.8, 1] });
  io.observe(sentinel);
스피너 없이 이어붙는 무한 스크롤
새 창에서 열기

01 · 스피너 없이 이어붙는 무한 스크롤 — 측정 누적면적 35.554% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

IntersectionObserveraddRowscrollTop

02첫 로딩용 리스트 스켈레톤

데이터가 오기 전 화면 전체가 행 모양의 회색 막대 스켈레톤으로 채워져 있다가 실제 데이터가 도착하면 한 번에 통째로 교체됩니다. 004의 개별 스켈레톤 부품 모음·349의 스크롤 중 추가 스켈레톤과 달리, 여긴 첫 진입 시 전체 목록이 교체되는 한 번뿐인 전환만 다룹니다. 첫 접속 시 리스트·피드의 로딩 대기 화면에 맞습니다.

  // 계산 겹: 각 행이 로딩 완료 후 실제로 보여줄 글자의 캔버스 실측 폭을 재서
  // 스켈레톤 막대 폭을 그 비율로 정한다 — 고정폭이 아니라 내용 길이를 미리 반영한다.
  function measureBarWidths() {
    var canvas = document.createElement('canvas');
    var ctx = canvas.getContext('2d');
    ctx.font = '600 13px -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
    var widths = ROWS.map(function (text) { return ctx.measureText(text).width; });
    var maxW = Math.max.apply(null, widths);
    return widths.map(function (w) {
      var pct = (w / maxW) * 100;
      return Math.max(MIN_BAR_PCT, Math.round(pct));
    });
  }
첫 로딩용 리스트 스켈레톤
새 창에서 열기

02 · 첫 로딩용 리스트 스켈레톤 — 측정 누적면적 8.956% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

measureBarWidthssetLoadedanimation-fill-mode

03새로고침 뒤 바뀐 행만 훑는 풀 투 리프레시

당겨서 새로고침을 마치면 목록 전체가 다시 그려지는 대신, 값이 바뀐 행에만 짧은 하이라이트 스윕이 지나가 무엇이 갱신됐는지 보여줍니다. 당김 동작·아이콘·탄성 자체를 다루는 353과 달리 여긴 놓은 뒤의 결과 표시만 다룹니다. 실시간 시세·재고처럼 값만 바뀌는 목록에 맞습니다.

  // 계산 겹: 원시 당김량(raw)을 지수 감쇠 저항 곡선으로 눌러 표시량을 만든다 —
  // 처음엔 손가락을 따라오다가 당길수록 점점 덜 늘어난다.
  function applyPull(raw) {
    var displayed = MAX_PULL_PX * (1 - Math.exp(-raw / DAMPING));
    zone.style.setProperty('--pull-px', displayed.toFixed(1) + 'px');
    spinner.style.setProperty('--spin-deg', (displayed / MAX_PULL_PX * 320).toFixed(1) + 'deg');
    indicator.style.opacity = Math.min(1, displayed / (RELEASE_THRESHOLD_PX * 0.7)).toFixed(2);
    lastDisplayed = displayed;
    return displayed;
  }
새로고침 뒤 바뀐 행만 훑는 풀 투 리프레시
새 창에서 열기

03 · 새로고침 뒤 바뀐 행만 훑는 풀 투 리프레시 — 측정 누적면적 29.576% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

applyPullsweepRowcomputeChanged

04행이 전부 사라지면 나타나는 빈 리스트 문구

마지막 남은 행까지 지워지는 순간 목록 영역에 작은 아이콘과 안내 문구 한 줄이 옅게 나타납니다. 134의 대시보드 위젯 안 추가 모프 일러스트·243의 아홉 가지 일러스트 애니메이션과 달리, 일러스트 없이 목록이 스스로 비었다고 알리는 가장 단순한 판입니다. 장바구니·할 일 목록이 다 비었을 때에 맞습니다.

  function step() {
    var diff = targetHeight - currentHeight;
    if (Math.abs(diff) < 0.5) {
      currentHeight = targetHeight;
      empty.style.height = currentHeight + 'px';
      empty.style.opacity = (currentHeight / EMPTY_HEIGHT).toFixed(2);
      rafId = null;
      return;
    }
    currentHeight += diff * LERP_RATE;
    empty.style.height = currentHeight + 'px';
    empty.style.opacity = Math.min(1, currentHeight / EMPTY_HEIGHT).toFixed(2);
    rafId = window.requestAnimationFrame(step);
  }
행이 전부 사라지면 나타나는 빈 리스트 문구
새 창에서 열기

04 · 행이 전부 사라지면 나타나는 빈 리스트 문구 — 측정 누적면적 46.348% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

computeTargetremoveRowtransform-origin

05툴바 없이 배지로만 세는 다중 선택

체크박스를 하나씩 켤 때마다 해당 행 배경만 옅게 물들고, 목록 위 헤더의 작은 배지 숫자가 선택된 개수로 바뀝니다. 화면 아래에서 툴바가 밀려 올라오는 207의 bulk-select-checkbox-toolbar와 달리, 여긴 툴바 없이 헤더 배지 하나로만 알립니다. 메일함·파일 목록의 여러 개 선택에 맞습니다.

  // 계산 겹: 마지막으로 다룬 인덱스와 이번 인덱스 사이 범위를 산술로 구해
  // 그 구간 전체를 한 번에 같은 상태로 맞춘다 — 하나씩 누르지 않아도 된다.
  function applyRange(fromIdx, toIdx, checked) {
    var lo = Math.min(fromIdx, toIdx);
    var hi = Math.max(fromIdx, toIdx);
    for (var i = lo; i <= hi; i += 1) {
      setChecked(checkboxes[i], checked);
    }
  }
툴바 없이 배지로만 세는 다중 선택
새 창에서 열기

05 · 툴바 없이 배지로만 세는 다중 선택 — 측정 누적면적 1.725% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

applyRangeupdateBadgearia-checked

06한 방향으로만 밀리는 기본 스와이프 액션

행을 옆으로 밀면 뒤에 숨어 있던 아이콘 버튼 하나가 정해진 폭만큼만 드러나고, 손을 떼면 그 폭에 맞춰 붙거나 되돌아갑니다. 삭제 전용 색·여러 버튼 겹쌓기를 다루는 207의 swipe-actions-delete-reveal·swipe-actions-multi-action-stack과 달리, 여긴 버튼 하나·왕복 스냅만 있는 가장 기본형입니다. 메시지함·할 일 목록의 한 가지 빠른 동작에 맞습니다.

    content.addEventListener('pointerup', function () {
      if (!dragging) { return; }
      dragging = false;
      // 계산 겹: 손을 뗄 때의 속도가 임계값을 넘으면, 멈춘 위치와 무관하게
      // 속도의 방향이 열림/닫힘을 결정한다. 느리면 위치 절반 기준으로 결정한다.
      var openDecision;
      if (velocity < -VELOCITY_OPEN_THRESHOLD) {
        openDecision = true;
      } else if (velocity > VELOCITY_OPEN_THRESHOLD) {
        openDecision = false;
      } else {
        openDecision = currentX() < -ACTION_WIDTH / 2;
      }
      if (openDecision) { openRow(); } else { closeRow(); }
    });
한 방향으로만 밀리는 기본 스와이프 액션
새 창에서 열기

06 · 한 방향으로만 밀리는 기본 스와이프 액션 — 측정 누적면적 2.771% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

openRowcloseRowcurrentX

07행이 실제로 자리를 바꾸며 정렬되는 표

열 머리글을 클릭하면 값만 다시 나열되는 게 아니라, 각 행이 새 순서의 자리까지 실제 좌표로 이동하는 애니메이션을 그리며 자리를 맞바꿉니다. 여러 열을 동시에 다루는 257의 다중 열 정렬·362의 행 삽입·삭제 전환과 달리, 여긴 단일 열 클릭 정렬에서 행 위치 이동 그 자체만 계산합니다. 가격·이름순 정렬이 필요한 관리자 표에 맞습니다.


    var rows = getRows();
    var first = {};
    rows.forEach(function (row) { first[row.dataset.id] = row.getBoundingClientRect().top; });

    rows.sort(comparator(key, dir));
    rows.forEach(function (row) { tbody.appendChild(row); });

    rows.forEach(function (row) {
      var last = row.getBoundingClientRect().top;
      var delta = first[row.dataset.id] - last;
      if (delta) {
        row.style.transition = 'none';
        row.style.transform = 'translateY(' + delta + 'px)';
        void row.offsetHeight;
        row.style.transition = '';
        row.style.transform = 'translateY(0)';
      }
    });
행이 실제로 자리를 바꾸며 정렬되는 표
새 창에서 열기

07 · 행이 실제로 자리를 바꾸며 정렬되는 표 — 측정 누적면적 2.227% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

sortByupdateHeadersaria-sort

08가로 스크롤에서 그림자를 데리고 다니는 고정 칼럼

표를 옆으로 스크롤하면 맨 왼쪽 칼럼은 자리에 그대로 얼어붙어 있고, 나머지 칼럼이 그 밑을 지나가는 동안 얼어붙은 칼럼 오른쪽 가장자리에만 옅은 그림자가 스크롤 양에 비례해 짙어집니다. 세로 스크롤에서 위쪽 머리글을 고정하는 207의 sticky-columns-header-freeze와 달리, 여긴 가로 스크롤에서 왼쪽 칼럼을 고정하는 다른 축의 시연입니다. 칼럼이 많은 표에서 이름 칼럼 고정에 맞습니다.

  // 계산 겹: 가로 스크롤량(scrollLeft)을 SHADOW_RAMP_PX 로 정규화해 그림자 알파를 산술로 만든다 —
  // 스크롤이 없으면 그림자가 없고, 조금만 밀려도 곧바로 상한 근처까지 진해진다.
  function updateShadow() {
    var raw = clamp(view.scrollLeft / SHADOW_RAMP_PX, 0, 1);
    var alpha = raw * STICKY_COL_SHADOW_ALPHA;
    view.style.setProperty('--shadow-alpha', alpha.toFixed(3));
  }
  view.addEventListener('scroll', updateShadow, { passive: true });
  updateShadow();
가로 스크롤에서 그림자를 데리고 다니는 고정 칼럼
새 창에서 열기

08 · 가로 스크롤에서 그림자를 데리고 다니는 고정 칼럼 — 측정 누적면적 4.625% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

updateShadowscrollLeftposition:sticky

09숫자 없이 점 하나가 미끄러지는 페이지네이션

이전·다음 화살표 사이에 늘어선 점들 뒤로, 점과 같은 크기·모양의 색칠된 필이 지금 페이지의 점 자리까지 미끄러져 가 닿습니다. 숫자와 말줄임표로 페이지가 많을 때를 다루는 207의 pagination-numbered-ellipsis와 달리, 여긴 페이지 수가 적을 때의 점 표시·화살표만으로 끝나는 가장 단순한 판입니다. 3~7페이지 정도의 짧은 목록 이동에 맞습니다.

  function goTo(index) {
    index = clamp(index, 0, total - 1);
    if (index === current) { return; }
    current = index;
    dots.forEach(function (dot, i) {
      if (i === current) { dot.setAttribute('aria-current', 'page'); } else { dot.removeAttribute('aria-current'); }
    });
    measureAndMove(current);
    panel.textContent = PANEL_TEXTS[current];
    prevBtn.disabled = current === 0;
    nextBtn.disabled = current === total - 1;
  }
숫자 없이 점 하나가 미끄러지는 페이지네이션
새 창에서 열기

09 · 숫자 없이 점 하나가 미끄러지는 페이지네이션 — 측정 누적면적 1.672% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

measureAndMovegoToaria-current

값이 UI를 바꾸는 곳 — 스태거 지연·정렬 이동 시간·고정 칼럼 그림자

숫자 하나만 옮겨도 화면이 주는 첫인상은 곧바로 달라집니다. 01은 SCROLL_APPEND_STAGGER_MS(40ms)가 새로 붙는 행 사이의 등장 간격을, 02는 ENTER_STAGGER_MS(70ms)가 스켈레톤이 실데이터로 바뀔 때 행이 차례로 나타나는 간격을 정합니다. 03은 DAMPING(90)이 당김 저항 곡선의 기울기를, 04는 LERP_RATE(0.18)가 빈 상태 영역이 목표 높이에 다가가는 속도를, 05는 AUTO_STEP_MS(550ms)가 자동 시연에서 다음 행을 선택할 때까지 쉬는 간격을 정합니다. 06은 VELOCITY_OPEN_THRESHOLD(0.5)가 스와이프를 열림으로 볼 속도 기준을, 07은 SORT_REORDER_MS(250ms)가 정렬 클릭 뒤 행이 옛 자리에서 새 자리까지 이동하는 시간을, 08은 STICKY_COL_SHADOW_ALPHA(0.35)가 그림자의 최대 진하기를, 09는 AUTO_STEP_MS(460ms)가 점 사이를 자동으로 넘어가는 간격을 결정합니다. 리스트·테이블 UI를 무한 스크롤링 방식으로 처음 붙이는 자리라면 01이나 04처럼 상수 하나로 등장·소멸의 체감 속도를 조절하는 항목이 가장 손대기 쉽습니다.

이웃 가지

같은 리스트·테이블 축에서도 초점은 갈립니다. 스크롤 중 로딩 피드백 자체에 집중한 무한 스크롤 로딩 UI 패턴 9종 — 스피너부터 가상 윈도우까지와 달리, 이 편의 01은 로딩 표시 없이 다음 행이 들어오는 등장 동작만 다룬다는 점이 다릅니다. 스와이프 삭제·다중 액션·번호 페이지네이션을 다루는 스와이프 삭제 리스트 UI 9가지 — 페이지네이션까지와도 이어지지만, 이 편의 06·09는 버튼 하나짜리 기본형 스와이프와 점 표시 페이지네이션만 다뤄 무한 스크롤 페이징이 아닌 짧은 목록 안에서의 기본 조작에 머무릅니다. 당김 동작·아이콘·탄성 자체가 주제인 당겨서 새로고침 9종 — 당긴 거리 값이 새로고침을 만드는 방식과 비교하면, 이 편의 03은 당기는 동작이 아니라 새로고침이 끝난 뒤 바뀐 행만 훑는 결과 표시에 초점이 좁혀져 있습니다.

어디서 깨지는가 — 접근성·reduced-motion·키보드 포커스

그리드에 지금 붙어 있는 가상 데이터 대신 실제 목록으로 이 아홉 가지 리스트·테이블 패턴을 확인하려면, zip 비밀번호 kps8kwrz를 입력해 vanilla·react 코드를 각각 열어보면 됩니다.

09에서 실제로 겪은 함정은 점 사이를 넘어가는 CSS transition이 걸린 요소를 렌더러가 "재생 중인 애니메이션"으로 잘못 인식하면서 나옵니다 — 초기 진입 시 transition이 한 번 걸리는 순간을 실시간 클럭이 아니라 결정론적 클럭으로 오판해, setInterval 기반의 자동 넘김이 첫 몇 초 동안 아예 돌지 않는 현상이 있었습니다. 그래서 09는 초기 진입 시 transition을 강제로 끈 상태에서 값을 먼저 맞추고, 한 프레임 뒤에야 transition을 되살리는 순서로 이 오판을 피합니다.

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

01은 reduce가 참이면 새 행이 스태거 지연 없이 한 번에 나타나고, 02는 스켈레톤에서 실데이터로 바뀌는 페이드·스태거가 사라져 바로 최종 모습으로 바뀝니다. 03은 당김에 따라 스피너가 회전·확대되는 트윈만 멈추고 새로고침 자체와 하이라이트 스윕 색 전환은 즉시 스냅으로 바뀌며, 04는 rAF lerp 루프가 아예 돌지 않고 목표 높이로 바로 스냅합니다. 05는 배지 숫자·행 배경이 트윈 없이 즉시 바뀌고, 06은 손을 떼기 전 실시간 드래그 위치는 그대로 두되(포인터 좌표를 직접 대입하는 부분이라 애초에 트윈이 아닙니다) 열림·닫힘 스냅 전환만 즉시 이동으로 바뀝니다. 07은 SORT_REORDER_MS의 FLIP 이동이 사라져 행이 이동 과정 없이 새 자리에 바로 나타나고, 08은 그림자 알파가 스크롤 값에 따라 즉시 전환되되 알파 자체를 계산하는 값 갱신은 그대로 살아 있습니다. 09는 점과 필 사이의 미끄러지는 이동이 사라지고 필이 곧바로 현재 페이지 점 위로 옮겨갑니다.

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

07은 정렬 가능한 <th>마다 aria-sort 값을 none·ascending·descending으로 실시간으로 바꿔 스크린리더가 지금 어떤 열이 어떤 방향으로 정렬됐는지 읽게 합니다. 05는 각 행에 시각만 흉내 낸 요소가 아니라 실제 <input type="checkbox">를 두고 aria-checked까지 함께 갱신해 스페이스바로도 선택할 수 있고, 09는 지금 페이지의 점에 aria-current="page"를 달아 화살표 키로 넘길 때마다 그 값이 함께 옮겨갑니다. 01·08은 각각 IntersectionObserver와 scroll 이벤트라는 표준 API만으로 반응해 별도의 스크립트 라이브러리 없이도 키보드로 스크롤하는 사용자에게 같은 동작을 보장합니다.

변형 세 가지와 접근성

변형 값 느낌
새 행 등장 스태거 지연 넓히기 01 SCROLL_APPEND_STAGGER_MS 40 → 90 새로 붙는 행 사이 간격이 40ms에서 90ms로 늘어나 한 번에 우르르 붙는 느낌 대신 한 줄씩 또렷하게 내려앉는 게 보인다
정렬 시 행 이동 애니메이션 시간 늘리기 07 SORT_REORDER_MS 250 → 500 정렬 버튼을 눌렀을 때 행이 옛 자리에서 새 자리까지 이동하는 시간이 250ms에서 500ms로 늘어나, 어떤 행이 어디로 갔는지 눈으로 따라가기 쉬워진다
고정 칼럼 그림자 진하기 낮추기 08 STICKY_COL_SHADOW_ALPHA 0.35 → 0.15 가로로 끝까지 스크롤했을 때 고정 칼럼 오른쪽 그림자의 최대 진하기가 0.35에서 0.15로 낮아져, 스크롤이 걸려 있다는 신호가 훨씬 옅어진다

세 변형 다 마크업이나 리스너 배선은 손대지 않고 상수 하나씩만 옮긴 값이라, aria-sort·aria-checked·aria-current 같은 접근성 속성과 reduce 분기는 변형과 상관없이 그대로 살아 있습니다. 등장 간격이나 이동 시간, 그림자 진하기를 바꿔도 스크린리더가 읽는 값이나 동작 줄이기 대응은 깨지지 않습니다.

FAQ

아홉 개 다 실제 서버에서 데이터를 받아오나요?

아니요. 아홉 개 모두 브랜드·인물·날짜가 가상인 데이터로 채워져 있고 fetch를 한 번도 부르지 않습니다. 실제 서비스에 붙일 때는 addRow·setLoaded 같은 함수가 받는 값을 서버 응답으로만 바꾸면 되고, 스태거 지연·감쇠 곡선·FLIP 이동 같은 계산은 데이터 출처와 무관하게 그대로 동작합니다.

무한 스크롤과 무한 스크롤 페이지 네이션을 같이 쓸 수 있나요?

가능하지만 같은 화면에 동시에 두지는 않는 편이 낫습니다. 01은 바닥에 닿을 때마다 계속 이어붙이는 방식이고 09는 정해진 페이지 수 안에서 점을 눌러 이동하는 방식이라, 둘을 한 목록에 같이 쓰면 사용자가 지금 스크롤 중인지 페이지를 넘기는 중인지 헷갈립니다. 짧은 목록엔 09를, 끝이 없는 피드엔 01을 고르는 편이 낫습니다.

06의 스와이프를 삭제가 아니라 다른 동작에 써도 되나요?

됩니다. openRow()·closeRow()가 여닫는 것은 ACTION_WIDTH만큼의 빈 버튼 영역이라, 그 안에 담긴 버튼의 onclick만 삭제 대신 보관·즐겨찾기 같은 다른 동작으로 바꾸면 열림·닫힘 판정 로직은 그대로 재사용할 수 있습니다. 속도·거리 임계치(VELOCITY_OPEN_THRESHOLD)는 버튼의 의미와 무관하게 동작합니다.

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

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