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

아코디언 테이블 9종 — 행이 펼쳐지고 접히는 아홉 가지 방식

아코디언 테이블은 표의 행 하나가 열리고 닫히는 상태에 따라 주변 값이 함께 바뀌는 인터랙션인데, 아홉 개 중 하나는 행의 확장 값이 바뀌면 그 아래 상세 영역의 높이 값이 함께 늘어나고, 하나는 행의 존재 값이 사라지는 순간 그 행의 높이 값이 0으로 줄며 아래 행들의 위치 값이

아홉 항목 모두 자동 시연 루프가 도는 중에도 클릭(01·02·03·04·05·06·07·08, 06은 키보드·블러도 함께), 스크롤(09) 같은 실사용자 입력에 반응해 값을 다시 계산하는 결정론적 계산입니다 — Math.random 은 0이라 같은 순서로 조작하면 항상 같은 값이 나옵니다. 표 안의 상품명·주문명·수치는 전부 가상 예시라 실 fetch·서버 저장·특정 브랜드나 회사명은 하나도 담지 않습니다.

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

아홉 개의 해부 — 어떤 값이 언제 바뀌는가

아홉 항목 모두 각 데모 화면의 실사용자 입력(01~08은 클릭, 06은 키보드·블러도 포함, 09는 스크롤)으로 값을 재설정·전환할 수 있고, 로드 즉시 도는 자동 시연 루프는 값을 미리 보여주는 보조 장치일 뿐 리스너 자체를 대신하지 않습니다.

번호 이름 리스너 누적면적% 강도 움직인 프레임/23
01 행 확장 값 전환 click(행 클릭) 19.90 48.9 6
02 그룹 접힘 값 전환 click(그룹 머리글 클릭) 17.35 77.3 7
03 선택 값 강조 전환 click(행 클릭) 21.54 117.9 8
04 행 추가 값과 위치 값 함께 변화 click(표 영역 클릭) 11.48 103.8 10
05 행 삭제 값과 높이 값 동시 변화 click(삭제 버튼) 5.46 75.5 6
06 편집 상태 값 전환 click+keydown+blur(편집 셀) 1.25 68.3 8
07 페이지 값 전환과 행 위치 값 이동 click(이전·다음 버튼) 2.21 91.9 6
08 빈 목록 상태 값 전환 click(표 영역 클릭) 17.01 33.6 9
09 스크롤 위치 값과 그림자 값 연동 scroll(뷰포트) 22.13 78.0 23

06은 셀 하나 안에서 문구 값과 입력 칸 값의 자리를 바꾸는 좁은 범위의 계산이라 면적이 작은 게 정상입니다 — 06은 면적이 아니라 두 요소의 위치·크기를 실측해 겹치는 정교함으로 재는 항목입니다. 나머지 여덟 항목도 값을 계산하는 방식이 같은 것끼리 아래 여섯 무리로 나뉩니다.

목표까지 남은 거리의 일정 비율만큼 다가가는 값 — 01·04·06·08

01의 tick() 함수는 LERP(0.22)로 상세 영역의 현재 높이에서 목표 높이까지 남은 거리를 매 프레임 22%씩 좁히고, 04는 LERP(0.24)로 새 행이 슬롯 자리로 미끄러지는 y 값을, 06은 LERP(0.25)로 문구 값과 입력 칸 값 사이 박스 크기를, 08은 LERP(0.2)로 빈 화면 문구의 등장 값을 같은 방식으로 다가가게 합니다. 넷 다 같은 lerp 공식을 쓰지만 대상이 높이·위치·크기·투명도로 각각 다릅니다.

서로 다른 두 속도로 위치와 높이를 나누어 보간하는 값 — 05

05는 위치 값과 높이 값을 하나의 LERP 로 묶지 않고 POS_LERP(0.22)와 H_LERP(0.26) 두 상수로 따로 계산합니다. 행이 삭제되면 아래 행들의 순서(order) 값이 먼저 당겨지고, 그 순서를 따라가는 위치 이동과 사라지는 행 자체의 높이 축소가 각자 다른 속도로 동시에 진행됩니다.

임계 구간으로 걸러낸 값을 다시 보간하는 값 — 09

09는 스크롤 위치 값을 그대로 쓰지 않고 scrollTop 을 maxScroll 로 나눈 비율(ratio)에서 0.18~0.5 구간만 0에서 1로 다시 매핑한 뒤, 그 결과(pinTarget)를 LERP(0.18)로 한 번 더 보간해 그림자 값(shadow)을 만듭니다. 구간 밖에서는 값이 0 또는 1로 고정되고, 구간 안에서만 스크롤에 비례해 서서히 짙어집니다.

자식 행마다 전환 시작 시각을 미리 걸어두는 값 — 02

02의 apply() 함수는 자식 행 인덱스 × STAGGER_MS(70ms)로 각 행의 transitionDelay 값을 미리 정해두고 height·opacity·transform 목표값만 한 번에 바꿉니다. 값을 매 프레임 계산하는 rAF 루프 없이, 나머지 애니메이션은 style.scss 에 걸린 CSS transition 이 그 지연 시각 이후부터 대신 진행합니다.

가속도와 속도를 적분해 오버슛을 만드는 값 — 03

03의 tick() 함수는 목표(0 또는 1)와 현재 진행 값(pos)의 차이에 강성 K(0.26)를 곱하고 속도(vel)에 감쇠 D(0.62)를 곱해 가속도(acc)를 구한 뒤, 그 가속도를 매 프레임 속도와 위치에 그대로 더하는 적분 방식입니다. 이 계산 덕분에 진행 값이 1을 살짝 넘겼다 되돌아오는 오버슛이 생기고, 색상은 그 넘친 만큼(over) 흰 하이라이트로 따로 표시됩니다.

위치 보간과 시차 지연을 함께 쓰는 값 — 07

07의 goTo() 함수는 나가는 행·들어오는 행 각각에 행 순서 × STAGGER_MS(60ms)만큼 다른 delayUntil 시각을 찍어두고, tick() 이 그 시각이 지난 행만 LERP(0.28)로 목표 x 값까지 이동시킵니다. 페이지 전체가 한 번에 밀리지 않고 위쪽 행부터 순서대로 빠져나가고 들어오는 이유가 이 시차 값입니다.

패턴별 코드

아래 아홉 조각은 각 항목의 index.html 계산부를 그대로 옮긴 것으로, 자리표시자 하나 없이 지금 데모에서 실제로 도는 코드입니다.

01행 확장 값 전환 — 항목의 확장 값이 바뀌면 그 아래 상세 영역의 높이 값이 함께 늘어나거나 줄어드는 방식

행을 클릭하면 retarget() 함수가 그 행의 확장 값을 뒤집어 상세 영역을 scrollHeight 로 실측한 자연 높이(최대 MAX_H 80px)를 목표로 잡고, tick() 이 LERP(0.22)로 현재 높이를 그 목표까지 매 프레임 22%씩 좁혀 늘어나거나 줄어듭니다. 관리자 대시보드에서 주문 한 건의 배송지·결제 수단 같은 상세 정보를 펼쳐 보는 화면에 맞는 계산입니다.

  // 상수: 상세 영역 진입 상한(px, 넉넉히 잡고 실측 scrollHeight 로 대체) · 보간 속도 · 자동 루프 간격
  var MAX_H = 80;
  var LERP = 0.22;
  var AUTO_TOGGLE_MS = 1100;

  var openIdx = [false, false, false];
  var h = [0, 0, 0]; // 각 상세 영역의 현재(보간 중) 높이값
  var target = [0, 0, 0];

  // 계산 겹: 열림 값이 바뀌면 목표 높이를 그 행의 실측 scrollHeight 로 다시 잰다
  function retarget(i) {
    if (openIdx[i]) {
      details[i].style.height = 'auto';
      var natural = Math.min(MAX_H, details[i].scrollHeight);
      details[i].style.height = h[i].toFixed(1) + 'px';
      target[i] = natural;
    } else {
      target[i] = 0;
    }
  }
행 확장 값 전환 — 항목의 확장 값이 바뀌면 그 아래 상세 영역의 높이 값이 함께 늘어나거나 줄어드는 방식
새 창에서 열기

01 · 행 확장 값 전환 — 항목의 확장 값이 바뀌면 그 아래 상세 영역의 높이 값이 함께 늘어나거나 줄어드는 방식 — 측정 누적면적 19.903% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

scrollHeightLERP 0.22.tre__detail

02그룹 접힘 값 전환 — 그룹 머리글의 접힘 값이 바뀌면 그 그룹에 속한 여러 행의 표시 값이 한꺼번에 바뀌는 방식

그룹 머리글을 클릭하면 apply() 함수가 자식 행 인덱스마다 STAGGER_MS(70ms)씩 다른 transitionDelay 값을 걸어두고 height·opacity·transform 목표값만 한 번에 바꾸는데, 실제 변화는 rAF 가 아니라 style.scss 의 CSS transition 이 그 지연 시각 이후부터 진행합니다. 카테고리별로 묶은 재고 목록이나 피벗 테이블 접기처럼 그룹 단위로 여러 행을 한꺼번에 숨기는 화면에 맞는 계산입니다.

  // 상수: 자식 행 높이(px) · 자식 사이 stagger 간격(ms) · 자동 루프 간격
  var CHILD_H = 30;
  var STAGGER_MS = 70;
  var AUTO_TOGGLE_MS = 1300;

  var collapsed = false;

  // 계산 겹: 접힘 값이 켜지면 자식 행 수 × stagger 로 각 행의 표시 값 전환 시각을 다르게 건다
  function apply() {
    children.forEach(function (el, i) {
      var delay = i * STAGGER_MS;
      el.style.transitionDelay = delay + 'ms';
      if (collapsed) {
        el.style.height = '0px';
        el.style.opacity = '0';
        el.style.transform = 'translateY(-' + (CHILD_H * 0.3).toFixed(1) + 'px)';
      } else {
        el.style.height = CHILD_H + 'px';
        el.style.opacity = '1';
        el.style.transform = 'translateY(0px)';
      }
    });
    group.classList.toggle('tgc__group--collapsed', collapsed);
  }
그룹 접힘 값 전환 — 그룹 머리글의 접힘 값이 바뀌면 그 그룹에 속한 여러 행의 표시 값이 한꺼번에 바뀌는 방식
새 창에서 열기

02 · 그룹 접힘 값 전환 — 그룹 머리글의 접힘 값이 바뀌면 그 그룹에 속한 여러 행의 표시 값이 한꺼번에 바뀌는 방식 — 측정 누적면적 17.353% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STAGGER_MS 70transitionDelay.tgc__child

03선택 값 강조 전환 — 행의 선택 값이 켜지면 그 행의 강조 색 값이 짙어지고 꺼지면 원래 값으로 돌아가는 방식

행을 클릭하면 선택 값이 뒤집히고, tick() 함수가 목표(0 또는 1)와 현재 진행 값의 차이에 강성 K(0.26)를 곱하고 속도에 감쇠 D(0.62)를 곱해 가속도를 구해 매 프레임 속도·위치에 더하는 스프링 방정식으로 배경색을 계산합니다. 여러 항목을 골라 일괄 처리하는 관리 화면에서 값이 1을 살짝 넘겼다 되돌아오는 오버슛이 선택했다는 느낌을 분명하게 만듭니다.

  // 계산 겹: 목표(0 또는 1)로 스프링 방정식(가속도 = -k*(pos-target) - d*vel)을 매 프레임 적분해
  // 값이 1을 살짝 넘겼다 되돌아오는 오버슛을 만든다(단순 트윈이 아니다)
  function tick() {
    for (var i = 0; i < 4; i++) {
      var target = selected[i] ? 1 : 0;
      var acc = -K * (pos[i] - target) - D * vel[i];
      vel[i] += acc * DT;
      pos[i] += vel[i] * DT;
      var clampedForColor = Math.max(0, Math.min(1, pos[i]));
      var over = Math.max(0, pos[i] - 1);
      var b = baseColor(), h = hiColor();
      var r = b[0] + (h[0] - b[0]) * clampedForColor;
      var g = b[1] + (h[1] - b[1]) * clampedForColor;
      var bl = b[2] + (h[2] - b[2]) * clampedForColor;
      rows[i].style.background = 'rgb(' + r.toFixed(0) + ',' + g.toFixed(0) + ',' + bl.toFixed(0) + ')';
      rows[i].style.boxShadow = over > 0.001 ? ('inset 0 0 0 ' + (over * 6).toFixed(1) + 'px rgba(255,255,255,' + Math.min(0.6, over).toFixed(2) + ')') : 'none';
    }
    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);
선택 값 강조 전환 — 행의 선택 값이 켜지면 그 행의 강조 색 값이 짙어지고 꺼지면 원래 값으로 돌아가는 방식
새 창에서 열기

03 · 선택 값 강조 전환 — 행의 선택 값이 켜지면 그 행의 강조 색 값이 짙어지고 꺼지면 원래 값으로 돌아가는 방식 — 측정 누적면적 21.536% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

K 0.26 / D 0.62vel.tsh__row

04행 추가 값과 위치 값 함께 변화 — 새 행이 더해지면 그 행의 위치 값이 자리를 잡는 동안 아래 행들의 위치 값도 함께 밀리는 방식

표 영역을 클릭하면 insert() 함수가 기존 행들의 슬롯(order) 값을 하나씩 뒤로 미루고 새 행을 order 0으로 넣은 뒤, tick() 이 LERP(0.24)로 각 행을 목표 y = order × ROW_H 까지 옮기며 SLOTS(4)를 넘어간 행은 서서히 투명해지다 사라집니다. 실시간으로 새 항목이 추가되는 주문 목록에서 새 행이 순간이동하지 않고 자리를 밀며 들어오는 계산입니다.

  function tick() {
    rows.forEach(function (r) {
      var target = r.order * ROW_H;
      r.y += (target - r.y) * LERP;
      var opacity = r.order >= SLOTS ? Math.max(0, 1 - (r.y - (SLOTS - 1) * ROW_H) / ROW_H) : 1;
      r.el.style.transform = 'translateY(' + r.y.toFixed(1) + 'px)';
      r.el.style.opacity = opacity.toFixed(2);
    });
    for (var i = rows.length - 1; i >= 0; i--) {
      var r = rows[i];
      if (r.order >= SLOTS && Math.abs(r.order * ROW_H - r.y) < 1) {
        r.el.remove();
        rows.splice(i, 1);
      }
    }
    requestAnimationFrame(tick);
  }
행 추가 값과 위치 값 함께 변화 — 새 행이 더해지면 그 행의 위치 값이 자리를 잡는 동안 아래 행들의 위치 값도 함께 밀리는 방식
새 창에서 열기

04 · 행 추가 값과 위치 값 함께 변화 — 새 행이 더해지면 그 행의 위치 값이 자리를 잡는 동안 아래 행들의 위치 값도 함께 밀리는 방식 — 측정 누적면적 11.482% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

SLOTS 4order.tis__row

05행 삭제 값과 높이 값 동시 변화 — 행의 존재 값이 사라지면 그 행의 높이 값이 0으로 줄며 아래 행들의 위치 값이 빈자리를 채우는 방식

삭제 버튼을 누르면 remove() 함수가 그 행보다 아래 있던 행들의 순서(order) 값을 하나씩 당기고, tick() 이 위치는 POS_LERP(0.22)로 높이는 H_LERP(0.26)로 각각 다른 속도로 목표에 다가가게 합니다. 항목을 지울 수 있는 관리자 표에서 지워지는 행과 그 자리를 메우는 행이 서로 다른 리듬으로 움직이는 이유가 이 두 상수입니다.

  function tick() {
    for (var i = rows.length - 1; i >= 0; i--) {
      var r = rows[i];
      var targetY = r.order * ROW_H;
      r.y += (targetY - r.y) * POS_LERP;
      var targetH = r.removing ? 0 : ROW_H;
      r.h += (targetH - r.h) * H_LERP;
      r.el.style.transform = 'translateY(' + r.y.toFixed(1) + 'px)';
      r.inner.style.height = Math.max(0, r.h).toFixed(1) + 'px';
      r.inner.style.opacity = (r.h / ROW_H).toFixed(2);
      if (r.removing && r.h < 0.5) {
        r.el.remove();
        rows.splice(i, 1);
      }
    }
    requestAnimationFrame(tick);
  }
  requestAnimationFrame(tick);
행 삭제 값과 높이 값 동시 변화 — 행의 존재 값이 사라지면 그 행의 높이 값이 0으로 줄며 아래 행들의 위치 값이 빈자리를 채우는 방식
새 창에서 열기

05 · 행 삭제 값과 높이 값 동시 변화 — 행의 존재 값이 사라지면 그 행의 높이 값이 0으로 줄며 아래 행들의 위치 값이 빈자리를 채우는 방식 — 측정 누적면적 5.46% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

POS_LERP 0.22H_LERP 0.26.tdc__inner

06편집 상태 값 전환 — 셀의 편집 상태 값이 바뀌면 문구 값이 입력 칸 값으로 자리를 바꾸는 방식

셀을 클릭하면 편집 상태 값이 켜지고, measure() 함수가 문구 요소와 입력 칸 요소 각각의 위치·크기를 getBoundingClientRect() 로 실측한 뒤 tick() 이 LERP(0.25)로 두 사각형 사이를 보간한 박스를 그립니다. 입력을 마치고 포커스를 벗어나면(blur) 다시 문구 값으로 돌아가고, 표 안에서 바로 재고 수량을 고치는 관리자 화면에 맞는 계산입니다.

  // 계산 겹: 문구 값의 자연 크기와 입력 칸 값의 자연 크기를 실측(offsetWidth/Height)해
  // 편집 상태 값의 진행도(prog)만큼 두 크기 사이를 보간한 박스를 그린다
  function measure(c) {
    var tr = c.text.getBoundingClientRect();
    var ir = c.input.getBoundingClientRect();
    var pr = c.cell.getBoundingClientRect();
    return {
      textRect: { l: tr.left - pr.left, t: tr.top - pr.top, w: tr.width, h: tr.height },
      inputRect: { l: ir.left - pr.left, t: ir.top - pr.top, w: ir.width, h: ir.height }
    };
  }
편집 상태 값 전환 — 셀의 편집 상태 값이 바뀌면 문구 값이 입력 칸 값으로 자리를 바꾸는 방식
새 창에서 열기

06 · 편집 상태 값 전환 — 셀의 편집 상태 값이 바뀌면 문구 값이 입력 칸 값으로 자리를 바꾸는 방식 — 측정 누적면적 1.251% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getBoundingClientRectLERP 0.25.tie__box

07페이지 값 전환과 행 위치 값 이동 — 페이지 값이 바뀌면 이전 행들의 위치 값이 빠져나가고 새 행들의 위치 값이 들어오는 방식

이전·다음 버튼을 누르면 goTo() 함수가 나가는 행과 들어오는 행 각각에 행 순서 × STAGGER_MS(60ms)만큼 다른 delayUntil 시각을 매기고, tick() 이 그 시각이 지난 행만 LERP(0.28)로 목표 x 값까지 옮깁니다. 페이지 단위로 나뉜 데이터 표에서 화면 위 행들이 한 번에 밀리지 않고 위에서부터 순서대로 빠져나가는 이유가 이 시차 값입니다.

  // 계산 겹: 페이지 값이 바뀌면 방향 값(dir)에 따라 나가는 행·들어오는 행에 행 순서 × STAGGER_MS 만큼
  // 시작 시각을 다르게 걸어(stagger) 목표 x 로 보간한다
  function goTo(newIndex) {
    if (transitioning || newIndex < 0 || newIndex >= pages.length || newIndex === pageIndex) return;
    transitioning = true;
    var dir = newIndex > pageIndex ? 1 : -1;
    var now = performance.now();
    var outgoing = live.slice();
    outgoing.forEach(function (r, i) {
      r.role = 'out';
      r.targetX = -dir * W;
      r.delayUntil = now + i * STAGGER_MS;
    });
    var incoming = pages[newIndex].map(function (item, i) {
      var el = makeRow(item);
      el.style.top = (i * ROW_H) + 'px';
      el.style.transform = 'translateX(' + (dir * W) + 'px)';
      return { el: el, x: dir * W, targetX: 0, delayUntil: now + i * STAGGER_MS, role: 'in' };
    });
    live = outgoing.concat(incoming);
    pageIndex = newIndex;
    pageLabel.textContent = (pageIndex + 1) + ' / ' + pages.length;
페이지 값 전환과 행 위치 값 이동 — 페이지 값이 바뀌면 이전 행들의 위치 값이 빠져나가고 새 행들의 위치 값이 들어오는 방식
새 창에서 열기

07 · 페이지 값 전환과 행 위치 값 이동 — 페이지 값이 바뀌면 이전 행들의 위치 값이 빠져나가고 새 행들의 위치 값이 들어오는 방식 — 측정 누적면적 2.208% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STAGGER_MS 60delayUntil.tps__row

08빈 목록 상태 값 전환 — 표시할 행이 하나도 없다는 상태 값이 켜지면 빈 화면 문구의 등장 값이 서서히 올라가는 방식

표 영역을 클릭해 행을 하나씩 지우다 마지막 행까지 사라지면 tick() 이 rows.length === 0 을 확인해 빈 화면 문구의 등장 값(emptyProg)을 LERP(0.2)로 0에서 1까지 서서히 올립니다. 검색 결과가 없는 관리자 표에서 화면이 갑자기 텅 비지 않고 문구가 떠오르며 그 자리를 대신합니다.

  // 계산 겹: 행이 하나도 없는 상태 값이 켜지면 빈 화면 문구의 등장 값(emptyProg)이 0에서
  // 매 프레임 남은 거리의 20%씩 올라간다 — index.html tick() 참조
  function tick() {
    for (var i = rows.length - 1; i >= 0; i--) {
      var r = rows[i];
      var targetH = r.removing ? 0 : ROW_H;
      r.h += (targetH - r.h) * LERP;
      r.el.style.height = Math.max(0, r.h).toFixed(1) + 'px';
      r.el.style.opacity = (r.h / ROW_H).toFixed(2);
      if (r.removing && r.h < 0.5) {
        r.el.remove();
        rows.splice(i, 1);
      }
    }
    var noRows = rows.length === 0;
    var target = noRows ? 1 : 0;
    emptyProg += (target - emptyProg) * LERP;
    empty.style.opacity = emptyProg.toFixed(2);
    empty.style.transform = 'translateY(' + ((1 - emptyProg) * 10).toFixed(1) + 'px)';
    requestAnimationFrame(tick);
  }
빈 목록 상태 값 전환 — 표시할 행이 하나도 없다는 상태 값이 켜지면 빈 화면 문구의 등장 값이 서서히 올라가는 방식
새 창에서 열기

08 · 빈 목록 상태 값 전환 — 표시할 행이 하나도 없다는 상태 값이 켜지면 빈 화면 문구의 등장 값이 서서히 올라가는 방식 — 측정 누적면적 17.008% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

emptyProgLERP 0.2.tef__empty

09스크롤 위치 값과 그림자 값 연동 — 스크롤 위치 값이 기준을 넘으면 머리글이 고정되며 그림자 값이 짙어지는 방식

프레임 안 표를 스크롤하면 tick() 이 scrollTop 을 maxScroll 로 나눈 비율(ratio)에서 0.18~0.5 구간만 0~1로 다시 매핑한 값을 LERP(0.18)로 보간해 머리글의 그림자 번짐·내림·배경 불투명도를 계산합니다. 긴 데이터 표에서 머리글을 고정할 때 그림자가 갑자기 나타나지 않고 스크롤에 비례해 서서히 짙어지는 이유입니다.

  // 계산 겹: 스크롤 위치 값(scrollTop / maxScroll)에서 0.18~0.5 구간만 걸러 머리글 고정 값(pin)을
  // 만들고, 그 값으로 그림자 번짐·내림을 매 프레임 남은 거리의 18%씩 보간한다
  function tick() {
    measure();
    var ratio = viewport.scrollTop / maxScroll;
    var pinTarget = ratio < 0.18 ? 0 : ratio > 0.5 ? 1 : (ratio - 0.18) / 0.32;
    shadow += (pinTarget - shadow) * LERP;
    var blur = (shadow * 14).toFixed(1);
    var drop = (shadow * 6).toFixed(1);
    var alpha = (shadow * 0.5).toFixed(2);
    head.style.boxShadow = '0 ' + drop + 'px ' + blur + 'px rgba(10, 15, 30, ' + alpha + ')';
    head.style.backgroundColor = 'rgba(35, 31, 40, ' + (0.94 + shadow * 0.06).toFixed(2) + ')';
    requestAnimationFrame(tick);
  }
스크롤 위치 값과 그림자 값 연동 — 스크롤 위치 값이 기준을 넘으면 머리글이 고정되며 그림자 값이 짙어지는 방식
새 창에서 열기

09 · 스크롤 위치 값과 그림자 값 연동 — 스크롤 위치 값이 기준을 넘으면 머리글이 고정되며 그림자 값이 짙어지는 방식 — 측정 누적면적 22.126% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

scrollTop / maxScrollLERP 0.18.tss__head

값이 패턴을 바꾸는 곳 — 확장 높이·삭제 지속시간·스크롤 임계의 기준

확장 높이의 기준은 01의 MAX_H(80px)가 정합니다 — 실제 목표값은 이 상한과 그 행의 실측 scrollHeight 중 작은 쪽이라, 상세 문구가 짧으면 80px 보다 낮은 값에서 멈추고 길면 80px 에서 잘립니다. html 테이블 디자인을 처음부터 새로 짜지 않고 이 상수 하나만 조정해도 상세 영역이 얼마나 늘어날지 통제할 수 있습니다.

삭제가 끝나는 기준은 고정된 ms 값이 아니라 05의 H_LERP(0.26)가 만드는 비율입니다 — 높이 값이 목표(0)와 0.5px 미만으로 가까워지는 순간(r.h < 0.5) 그 행이 DOM 에서 완전히 제거됩니다. 08의 LERP(0.2)로 채워지는 빈 화면 문구의 등장 값도 같은 방식이라, 지속시간을 정하는 건 시간이 아니라 남은 거리를 좁히는 비율입니다.

스크롤 임계의 기준은 09의 0.18~0.5 구간이 정합니다 — 스크롤 위치 값이 전체의 18% 아래면 그림자 값이 0으로 고정되고, 50%를 넘으면 1로 고정되며, 그 사이에서만 스크롤한 만큼 그림자가 짙어집니다. 02·07의 STAGGER_MS(각각 70ms·60ms)는 이 임계와 무관하게 여러 행이 동시에 움직이지 않도록 순서만 어긋나게 미루는 값입니다.

어디서 깨지는가 — 빈 목록으로 바뀌는 순간·좁은 화면·모션 감소

빈 목록으로 바뀌는 순간을 다루는 방식이 항목마다 다릅니다. 08은 rows.length 가 0이 되면 emptyProg 값을 올려 빈 화면 문구를 대신 띄우지만, 05는 같은 삭제 로직(remove·tick)을 쓰면서도 빈 상태 문구 요소 자체가 소스에 없어 행을 전부 지우면 아무 문구도 없이 빈 공간만 남습니다. 아홉 항목의 html·scss·js 원본은 비번 r3p62vhd 으로 열리는 압축 파일 안에 그대로 들어 있어, 05에 08의 emptyProg 계산을 그대로 옮겨 빈 상태 문구를 추가하는 수리를 코드로 직접 맞춰볼 수 있습니다.

좁은 화면에서는 CSS 값과 JS 값이 서로 다른 상수를 쓰는 항목이 깨지기 쉽습니다. 07은 style.scss 의 340px 이하 구간에서 컨테이너 폭을 200px 로 줄이지만 JS 의 W(240)는 그대로라 목표 x 값이 실제 폭보다 커져 행이 화면 밖으로 밀려날 수 있고, 04도 같은 구간에서 CSS 행 높이를 28px 로 줄이면서 JS 의 ROW_H(34)는 그대로 두어 계산된 y 값과 실제 행 높이 사이에 틈이 생길 수 있습니다. 아코디언 테이블 ui 를 좁은 화면에 그대로 옮길 때는 이렇게 CSS 값과 JS 상수를 같이 맞춰야 합니다.

모션 감소는 아홉 항목 모두 style.scss 의 prefers-reduced-motion 규칙으로 각 요소의 transition: none 을 걸어 두지만, 실제로 화면 표현이 달라지는 항목은 갈립니다. 02는 apply() 가 height·opacity·transform 목표값만 한 번 바꾸고 나머지는 CSS transition 이 대신 진행하는 구조라 reduce 가 켜지면 그 전환 자체가 즉시 끝나고, 01은 상세 영역 높이는 rAF 로 그대로 움직이되 행이 열릴 때 셀 배경이 옅게 번지던 transition 만 사라집니다. 04·05·06·07·08은 값을 그리는 요소에 애초에 transition 이 걸려 있지 않아 reduce 여부와 무관하게 rAF 계산과 화면 표시가 그대로 돌아가며, 03만 추가로 matchMedia로 감지해 K·D 를 1로 바꿔 스프링 방정식 자체의 오버슛을 없앱니다.

변형 세 가지와 접근성

이름 값 느낌
행 펼침 보간 속도 01 LERP 0.22 → 0.08 펼침이 늘어져서 세부 행이 한참 뒤에야 다 열립니다 — 무겁고 진지한 느낌입니다
묶음 지우기 stagger 간격 02 STAGGER_MS 70 → 220 자식 행들이 하나씩 순서대로 사라져 폭포처럼 흘러 내려갑니다
강조 스프링 감쇠 03 D 0.62 → 0.18 오버슛이 커져 강조가 튕겨 올라갔다 여러 번 흔들립니다 — 통통 튀는 인상입니다

접근성은 항목마다 남기는 값이 다릅니다. 02는 reduce 가 켜지면 값 계산(transitionDelay) 자체는 그대로 두고 CSS transition 이 꺼져 전환이 즉시 끝나며, 01은 상세 영역 높이를 rAF 로 계산하는 로직은 그대로 두고 행이 열릴 때 셀 배경이 옅게 번지던 표현만 사라집니다. 04·05·06·07·08은 값을 그리는 요소에 애초에 transition 이 걸려 있지 않아 reduce 여부와 무관하게 rAF 계산과 화면 표시가 그대로 유지되고, 03만 K·D 를 1로 바꿔 계산 자체에서 스프링 오버슛을 없앱니다. 09는 스크롤이라는 실제 입력 위치 값을 그대로 쓰기 때문에 reduce 여부와 무관하게 그림자 값이 스크롤한 만큼만 움직입니다.

FAQ

05에서 행을 지우면 왜 즉시 사라지지 않고 서서히 줄어드나요?

remove() 가 그 행의 removing 값을 켜기만 하고, 실제 높이는 tick() 이 H_LERP(0.26)로 목표(0)까지 남은 거리를 매 프레임 26%씩 좁히기 때문입니다. 높이 값이 0.5px 미만으로 줄어드는 순간에야 r.el.remove()로 DOM 에서 완전히 지워지므로, 클릭한 순간과 실제로 사라지는 순간 사이에 짧은 간격이 생깁니다.

08의 빈 상태 문구는 04·05에도 똑같이 적용되나요?

아니요. 08은 rows.length === 0 을 확인해 emptyProg 값을 올리는 로직과 빈 화면 문구 요소가 소스에 함께 있지만, 04와 05는 같은 구조의 tick() 을 쓰면서도 빈 상태 문구 요소 자체가 없습니다. 04는 삽입만 반복해 목록이 비지 않도록 설계됐고, 05는 전부 지우면 아무 문구 없이 빈 공간만 남는 차이가 있습니다.

테이블 디자인 css 를 이 아홉 가지 코드에 그대로 적용해도 되나요?

값 몇 개(LERP·STAGGER_MS·K·D)만 바꾸면 되므로 기존 테이블 디자인 css 위에 얹기는 쉽지만, ROW_H·MAX_H 처럼 픽셀 단위 상수는 실제 셀 크기·글자 크기에 맞춰 다시 재야 합니다. 04·07처럼 JS 상수와 CSS 값이 어긋나면 좁은 화면에서 행이 겹치거나 밀려날 수 있어, 값을 옮길 때는 CSS 쪽 크기도 같이 확인하는 편이 안전합니다.

더 읽을거리

여러 열을 정렬·리사이즈·재배치하는 데이터 표는 열 단위로 순서·너비 값을 계산하는 데 비해, 이 편은 행 하나의 생애주기 안에서 높이·위치 값이 어떻게 변하는지를 다룬다는 점에서 계산 축이 다릅니다. 여러 위젯을 배치하는 대시보드 구성은 카드 단위 레이아웃 값을 계산하는 반면, 이 편은 표 행 안의 확장·삭제·선택 같은 상태 값 변화를 다룹니다. 드래그로 순서를 바꾸는 UI는 사용자가 직접 끄는 위치 값으로 순서를 바꾸는데, 이 편의 04·05·07은 드래그 없이 삽입·삭제·페이지 전환이라는 상태 값 변화만으로 순서·위치가 다시 계산된다는 점에서 다릅니다.

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

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