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

탭 전환 애니메이션 9가지 패턴 — 코드 그대로

탭 전환 애니메이션 아홉 개를 나란히 놓고 보면 높이 전환은 두 패널의 실측 높이 차이(px)로 늘어나는 시간을 정하는데, 스택 전환은 활성 탭과의 인덱스 거리로 축소 비율을 정하고, 탄성 슬라이드는 스프링 상수로 튕겨 넘기는 폭을 정한다 — 같은 탭인데 계산값은 저마다 다르다.

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

계산 값 하나가 아홉 가지 전환을 갈라놓습니다

아홉 항목은 전부 같은 뼈대에서 출발합니다. role="tablist" 안에 탭 버튼 세 개, role="tabpanel" 로 이어지는 패널 세 개, 그리고 화살표·Home·End 로 옮기고 Enter·Space 로 고정하는 roving tabindex 까지 동일합니다. 그런데 탭을 눌렀을 때 실제로 화면이 바뀌는 방식은 항목마다 숫자 하나가 갈라놓습니다 — 01 은 getBoundingClientRect 로 잰 높이 차이(px), 02 는 탭 인덱스 차의 부호와 절댓값, 09 는 스프링 강성·감쇠 두 상수입니다. 정렬 순서도 이 계산 축을 따라갑니다 — 높이·이동 거리처럼 좌표 계산이 앞서고, 회전·마스크처럼 도형 계산이 뒤를 잇고, 물리 계산인 09 가 마지막입니다.

01패널 실측 높이 차이가 늘이는 탭 컨테이너

전환 직전 두 패널의 실측 높이를 재 그 차이(px)를 구하고, 전환이 진행되는 동안 컨테이너 높이가 이전 값에서 그 차이만큼 보간되어 다음 패널 높이에 맞춰집니다. 숨은 패널은 hidden 상태 그대로는 높이가 0 으로 읽히므로, 잠깐 화면 밖에 겹치듯 세워 잰 뒤 다시 숨깁니다. 탭·화살표 전환은 CSS transition 이 그리고, 리사이즈 리스너는 붙어 있지 않아 창 너비가 바뀌어도 다음 클릭 전까지는 값을 다시 재지 않습니다.

패널 실측 높이 차이가 늘이는 탭 컨테이너
새 창에서 열기

01 · 패널 실측 높이 차이가 늘이는 탭 컨테이너 — 측정 누적면적 42.901% · 움직인 프레임 9/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

getBoundingClientRect()wrap.style.height180~620ms
  function rectHeight(idx) {
    // 계산 겹: 숨은 패널도 잠깐 측정 가능한 상태로 만들어 getBoundingClientRect 로 실측한다 — 짐작값을 쓰지 않는다
    var p = panels[idx];
    var wasHidden = p.hasAttribute('hidden');
    if (wasHidden) {
      p.style.visibility = 'hidden';
      p.style.position = 'absolute';
      p.style.width = wrap.clientWidth + 'px';
      p.removeAttribute('hidden');
    }
    var h = p.getBoundingClientRect().height;
    if (wasHidden) {
      p.setAttribute('hidden', '');
      p.style.visibility = '';
      p.style.position = '';
      p.style.width = '';
    }
    return h;
  }

  function human() {
    if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
  }

02인덱스 차 값이 방향을 정하는 슬라이드 패널

새로 누른 탭의 인덱스에서 이전 탭의 인덱스를 뺀 값의 부호로 슬라이드 방향을 정하고, 그 절댓값이 1보다 크면 건너뛴 탭 수만큼 이동 거리도 함께 늘어납니다. 두 칸을 건너뛰면 한 칸보다 전환 시간이 길어지는 것도 이 절댓값 계산 때문입니다. 클릭·키보드 전환은 CSS transition 이 맡고, 뷰포트 리사이즈 리스너가 붙어 있어 창 너비가 바뀌면 현재 위치를 다시 계산해 이동폭을 맞춥니다.

인덱스 차 값이 방향을 정하는 슬라이드 패널
새 창에서 열기

02 · 인덱스 차 값이 방향을 정하는 슬라이드 패널 — 측정 누적면적 6.432% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translateX()STEP_MS인덱스 부호
  function select(idx) {
    if (idx === active) return;
    var delta = idx - active; // 계산 겹: 새 인덱스 - 이전 인덱스
    var dir = delta > 0 ? 1 : -1; // 계산 겹: 부호가 슬라이드 방향을 정한다
    var dist = Math.abs(delta); // 계산 겹: 건너뛴 칸 수
    var dur = Math.min(MAX_MS, BASE_MS + (dist - 1) * STEP_MS); // 계산 겹: 건너뛴 칸이 많을수록 오래 이동한다
    var w = viewport.clientWidth;

    track.style.setProperty('--sp-dur', dur.toFixed(0) + 'ms');
    track.style.transform = 'translateX(' + (-idx * w).toFixed(2) + 'px)';

    tabs[active].classList.remove('is-active');
    tabs[active].setAttribute('aria-selected', 'false');
    tabs[active].setAttribute('tabindex', '-1');
    panels[active].setAttribute('aria-hidden', 'true');

    tabs[idx].classList.add('is-active');
    tabs[idx].setAttribute('aria-selected', 'true');
    tabs[idx].setAttribute('tabindex', '0');
    panels[idx].setAttribute('aria-hidden', 'false');

    distEl.textContent = String(dist);
    dirEl.textContent = dir > 0 ? '→' : '←';
    active = idx;
  }

03클릭 좌표 비율이 닦아내는 클립 패스 패널

클릭한 탭의 x 좌표를 전체 탭바 너비에 대한 비율(0~1)로 바꾸고, 그 값을 clip-path 인셋의 시작 지점으로 삼아 새 패널이 그 지점부터 좌우로 닦여 나오듯 드러납니다. 키보드로 넘길 때는 클릭 좌표가 없으므로 탭 버튼 중심 좌표를 대신 씁니다. 전환은 CSS transition 이 맡고 리사이즈 리스너는 없어, 탭바 폭이 바뀌어도 다음 전환 때 좌표를 새로 잽니다.

클릭 좌표 비율이 닦아내는 클립 패스 패널
새 창에서 열기

03 · 클릭 좌표 비율이 닦아내는 클립 패스 패널 — 측정 누적면적 7.335% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

clipPathinset()클릭 x 비율
    var barRect = tabsBar.getBoundingClientRect();
    var x = (typeof originX === 'number') ? originX : (tabs[idx].getBoundingClientRect().left + tabs[idx].getBoundingClientRect().width / 2);
    var r = barRect.width > 0 ? Math.min(1, Math.max(0, (x - barRect.left) / barRect.width)) : 0.5; // 계산 겹: 클릭 x 좌표 → 탭바 폭 대비 비율(0~1)
    var pct = (r * 100).toFixed(1) + '%';

    tabs[active].classList.remove('is-active');
    tabs[active].setAttribute('aria-selected', 'false');
    tabs[active].setAttribute('tabindex', '-1');
    panels[active].classList.remove('is-visible');
    panels[active].setAttribute('hidden', '');

    var next = panels[idx];
    next.style.transition = 'none';
    next.style.clipPath = 'inset(0 calc(100% - ' + pct + ') 0 ' + pct + ')'; // 계산 겹: 비율 지점에서 폭 0 인 슬릿으로 시작
    next.removeAttribute('hidden');
    next.getBoundingClientRect();
    next.style.transition = '';
    requestAnimationFrame(function () {
      next.style.clipPath = 'inset(0 0 0 0)'; // 다음 프레임에 전체 노출로 전환 → transition 이 닦아내는 모양을 그린다
      next.classList.add('is-visible');
    });

04인덱스 거리 값이 눕히는 스택 패널

활성 탭과의 인덱스 거리를 정수로 구해 그 값이 클수록 패널의 scale 과 밝기를 낮추고 z축 뒤로 밀어, 나가는 패널이 쌓인 카드처럼 한 겹씩 뒤로 눕습니다. 거리 0 은 그대로, 거리 1 부터 --d 커스텀 프로퍼티 값에 비례해 축소 폭이 커집니다. 전환은 CSS transition 이 맡고 리사이즈 리스너는 붙어 있지 않습니다.

인덱스 거리 값이 눕히는 스택 패널
새 창에서 열기

04 · 인덱스 거리 값이 눕히는 스택 패널 — 측정 누적면적 37.975% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

--dSCALE_STEPtranslateZ()
  function apply() {
    panels.forEach(function (p, i) {
      var d = Math.abs(i - active); // 계산 겹: 활성 탭과의 정수 인덱스 거리
      p.style.setProperty('--d', String(d));
      p.setAttribute('aria-hidden', d === 0 ? 'false' : 'true');
    });
    var scale = (1 - SCALE_STEP).toFixed(2); // 바로 다음 겹(거리1)의 scale 값을 대표로 표시
    distEl.textContent = '1';
    scaleEl.textContent = scale;
  }

  function select(idx) {
    if (idx === active) return;
    tabs[active].classList.remove('is-active');
    tabs[active].setAttribute('aria-selected', 'false');
    tabs[active].setAttribute('tabindex', '-1');
    tabs[idx].classList.add('is-active');
    tabs[idx].setAttribute('aria-selected', 'true');
    tabs[idx].setAttribute('tabindex', '0');
    active = idx;
    apply();
  }

05진행률 값이 흐렸다 맑히는 크로스페이드 패널

전환 진행률(0~1)을 t 로 두고 나가는 패널은 t 에 비례해 블러·투명도가 늘고 들어오는 패널은 (1-t) 에 비례해 블러가 줄어, 두 패널이 초점 이동처럼 겹쳐 지나갑니다. 이 진행률은 CSS transition 이 아니라 requestAnimationFrame 루프가 매 프레임 직접 계산해 두 패널 스타일에 나눠 씁니다. 리사이즈 리스너는 붙어 있지 않습니다.

진행률 값이 흐렸다 맑히는 크로스페이드 패널
새 창에서 열기

05 · 진행률 값이 흐렸다 맑히는 크로스페이드 패널 — 측정 누적면적 8.026% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

filter blur()BLUR_MAXt·(1-t)
    var start = 0;
    function frame(ts) {
      if (!start) start = ts;
      var t = Math.min(1, (ts - start) / DUR_MS); // 계산 겹: 실측 경과시간 / 총 시간 = 진행률 t(0~1)
      setT(outEl, t, false);
      setT(inEl, t, true);
      tEl.textContent = t.toFixed(2);
      if (t < 1) {
        raf = requestAnimationFrame(frame);
      } else {
        raf = 0;
        outEl.setAttribute('hidden', '');
        outEl.classList.remove('is-front');
        active = idx;
      }
    }

06실측 높이 값이 접었다 펴는 세로 아코디언 패널

선택되지 않은 패널의 max-height 를 0 으로 접어두고, 새로 선택된 패널만 scrollHeight 로 잰 실측 콘텐츠 높이 값으로 max-height 를 올려 세로로 펼쳐지며 그 자리에서 내용이 바뀝니다. 접힌 패널은 transitionend 가 와야 비로소 hidden 이 붙으므로, 눈에 보이지 않게 접히는 동안에도 레이아웃 계산에는 잠깐 남아 있습니다. 전환은 CSS transition 이 맡고, 리사이즈 리스너가 붙어 있어 창 높이가 바뀌면 펼쳐진 패널의 max-height 를 다시 잽니다.

실측 높이 값이 접었다 펴는 세로 아코디언 패널
새 창에서 열기

06 · 실측 높이 값이 접었다 펴는 세로 아코디언 패널 — 측정 누적면적 32.296% · 움직인 프레임 7/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

scrollHeightmaxHeighttransitionend
  function measure(el) {
    return el.firstElementChild.scrollHeight; // 계산 겹: 콘텐츠 실측 높이(px) — max-height 목표값
  }

  function openPanel(el, instant) {
    el.removeAttribute('hidden');
    var h = measure(el);
    if (instant) { el.style.transition = 'none'; }
    el.style.maxHeight = h + 'px';
    if (instant) { el.getBoundingClientRect(); el.style.transition = ''; }
    hEl.textContent = String(h);
  }

  function onCollapseEnd(e) {
    if (e.propertyName !== 'max-height') return;
    var el = e.currentTarget;
    if (el.style.maxHeight === '0px') el.setAttribute('hidden', '');
  }

  function closePanel(el, instant) {
    if (instant) { el.style.transition = 'none'; }
    el.style.maxHeight = '0px';
    if (instant) { el.getBoundingClientRect(); el.style.transition = ''; el.setAttribute('hidden', ''); }
  }

07진행률 각도 값이 뒤집는 3D 회전 패널

전환 진행률을 0~180도 사이 rotateY 각도 값으로 바꾸어 나가는 패널이 90도를 넘는 순간 보이지 않게 감추고 들어오는 패널이 나머지 각도를 이어받아 뒷면에서 돌아 나옵니다. 실제 회전은 CSS transition 이 rotateY() 값을 보간하고, 화면 숫자에 찍히는 각도만 별도 루프가 시간 경과로 따로 계산해 읽어줍니다. 목표 인덱스가 현재보다 큰지 작은지의 부호가 시계 방향인지 반시계 방향인지를 정하고, 리사이즈 리스너는 붙어 있지 않습니다.

진행률 각도 값이 뒤집는 3D 회전 패널
새 창에서 열기

07 · 진행률 각도 값이 뒤집는 3D 회전 패널 — 측정 누적면적 36.107% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

rotateY()perspectivebackface-visibility
  function tickAngle(start, dir) {
    function step(ts) {
      var t = Math.min(1, (ts - start) / DUR_MS); // 계산 겹: 실측 경과시간 / 총 시간 = 진행률 t(0~1)
      var angle = Math.round(t * 180); // 계산 겹: 진행률을 0~180도 rotateY 각도 값으로 변환
      angleEl.textContent = String(angle);
      if (t < 1 && busy) { raf = requestAnimationFrame(step); } else { raf = 0; }
    }
    raf = requestAnimationFrame(step);
  }

08클릭 좌표 값이 번지는 원형 마스크 패널

클릭한 탭 라벨의 중심 좌표를 새 패널의 clip-path circle() 중심점으로 넘기고, 반지름 값을 0 에서 패널 대각선 길이까지 키워 그 지점에서부터 원형으로 번지듯 드러납니다. 사각형 inset() 이 아니라 원형 circle() 을 쓰므로 번짐 경계가 탭 위치와 상관없이 항상 동그랗게 유지됩니다. 반지름 확장은 CSS transition 이 맡고, 리사이즈 리스너는 붙어 있지 않아 창 크기가 바뀌어도 다음 클릭 전까지 좌표를 다시 재지 않습니다.

클릭 좌표 값이 번지는 원형 마스크 패널
새 창에서 열기

08 · 클릭 좌표 값이 번지는 원형 마스크 패널 — 측정 누적면적 6.911% · 움직인 프레임 8/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

clipPathcircle()getBoundingClientRect()
    requestAnimationFrame(function () {
      incoming.style.clipPath = 'circle(' + r.toFixed(1) + 'px at ' + cx.toFixed(1) + 'px ' + cy.toFixed(1) + 'px)';
      rEl.textContent = String(Math.round(r));
    });

    function onEnd(e) {
      if (e.propertyName !== 'clip-path') return;
      incoming.removeEventListener('transitionend', onEnd);
      incoming.classList.add('is-active');
      incoming.classList.remove('is-front');
      incoming.style.clipPath = 'none';
      outgoing.classList.remove('is-active');
      outgoing.setAttribute('hidden', '');
      active = idx;
      busy = false;
    }
    incoming.addEventListener('transitionend', onEnd);

09스프링 상수 값이 튕겨 넘겼다 되돌리는 슬라이드 패널

스프링 감쇠 상수를 슬라이드 종료 위치에 적용해 패널이 목표 지점을 한 번 지나쳤다가 그 초과분(px)만큼 다시 당겨져 멈추고, 값이 작을수록 튕기는 폭이 커집니다. err*STIFFNESS - vel*DAMPING 으로 매 프레임 힘을 구해 속도와 위치를 갱신하는 물리 루프이므로 CSS transition 이 아니라 requestAnimationFrame 이 전 구간을 그립니다. 리사이즈 리스너가 붙어 있어 창 너비가 바뀌면 목표 위치를 다시 계산합니다.

스프링 상수 값이 튕겨 넘겼다 되돌리는 슬라이드 패널
새 창에서 열기

09 · 스프링 상수 값이 튕겨 넘겼다 되돌리는 슬라이드 패널 — 측정 누적면적 8.585% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STIFFNESSDAMPINGrequestAnimationFrame()
  function frame(ts) {
    if (!lastTs) lastTs = ts;
    var dt = Math.min(0.032, (ts - lastTs) / 1000);
    lastTs = ts;
    var err = target - pos;
    if (!crossed && Math.sign(err) !== Math.sign(startErr) && startErr !== 0) crossed = true; // 계산 겹: 목표를 지나친 순간부터 초과분 추적 시작
    if (crossed) overshootPeak = Math.max(overshootPeak, Math.abs(pos - target));
    var force = err * STIFFNESS - vel * DAMPING; // 계산 겹: 스프링 힘 = 변위*강성 - 속도*감쇠
    vel += force * dt;
    pos += vel * dt;
    track.style.transform = 'translateX(' + pos.toFixed(2) + 'px)';
    overEl.textContent = overshootPeak.toFixed(1);
    if (Math.abs(target - pos) < 0.4 && Math.abs(vel) < 2) {
      pos = target; vel = 0; lastTs = 0;
      track.style.transform = 'translateX(' + pos.toFixed(2) + 'px)';
      raf = 0;
      return;
    }
    raf = requestAnimationFrame(frame);
  }

탭 UI 디자인 실무에서 이 아홉 개를 어디에 붙이나

설정 화면의 「계정·알림·결제」 탭처럼 섹션마다 입력 필드 개수가 다른 자리에는 01 의 실측 높이 차이 계산이 맞습니다. 고정 높이로 만들면 짧은 탭 아래 빈 여백이 남거나 긴 탭 내용이 잘리는데, 01 은 전환 직전 다음 패널의 실제 높이를 재서 그 차이만큼만 컨테이너를 늘이거나 줄이므로 어느 탭을 눌러도 여백이 남지 않습니다. 탭 디자인 CSS 를 손볼 때 값 하나만 조정하면 되는 것도 이런 구조 덕분입니다.

상품 상세 페이지의 「설명·리뷰·문의」 탭에는 06 의 세로 아코디언이 어울립니다. 좁은 화면에서 가로로 카드가 밀리는 구조 대신 위아래로 펼쳐지는 쪽이 스크롤 흐름을 안 끊기 때문입니다. 관리자 대시보드의 사이드 패널처럼 자주 옮겨 다니는 탭에는 04 의 인덱스 거리 스택이 맞습니다. css 탭 메뉴 디자인을 고를 때는 전환 계산이 어디서 도는지부터 보는 편이 나중에 값을 조정하기 쉽습니다.

아홉 항목 실측값 — 누적 면적과 움직인 프레임

항목 누적 면적 움직인 프레임
01 높이 모프 42.9% 9/23
02 방향 슬라이드 6.4% 13/23
03 클립 와이프 7.3% 12/23
04 스택 뎁스 38.0% 11/23
05 블러 크로스페이드 8.0% 14/23
06 세로 아코디언 32.3% 7/23
07 3D 플립 36.1% 13/23
08 원형 마스크 6.9% 8/23
09 탄성 오버슛 8.6% 13/23

06 과 08 이 다른 항목보다 움직인 프레임 수가 낮은 것은 오류가 아니라 구조상 자연스러운 결과입니다. 06 은 transitionend 가 와야 다음 상태로 넘어가는 접힘 동작 하나가 끝을 맺을 때까지 중간값이 크게 뛰지 않고, 08 은 반지름이 0 에서 시작해 초반 몇 프레임은 원이 거의 보이지 않을 만큼 작기 때문입니다. 02·03·08 처럼 면적이 작은 항목도 마찬가지로, 이동 범위가 탭 하나 폭이나 얇은 원형 경계로 좁게 잡혀 있어 화면 전체가 움직이는 01·04·07 보다 픽셀 변화량이 작게 잡힙니다.

탭은 키보드가 우선이라 접근성부터 확인합니다

아홉 항목 전부 ARIA 탭 패턴 을 그대로 따라 role="tablist"·aria-selected·aria-controls 를 붙이고, 화살표 키로 탭 사이를 옮기고 Home·End 로 처음·끝 탭까지 한 번에 이동하는 roving tabindex 를 공유합니다. 선택되지 않은 탭은 tabindex="-1" 로 빠져 Tab 키 한 번으로 탭 목록을 건너뛸 수 있습니다.

prefers-reduced-motion 이 참일 때는 아홉 개 모두 자동으로 도는 데모 순환 루프를 멈춰 화면이 계속 바뀌는 것을 막습니다. 09 는 여기서 한 겹 더 나아가, 클릭으로 직접 전환할 때도 스프링 루프를 건너뛰고 목표 위치로 바로 스냅합니다 — 오버슛 애니메이션 자체가 전정 기관 자극에 민감한 사용자에게는 불편할 수 있기 때문입니다. 자세한 판정 기준은 prefers-reduced-motion 문서에 정리돼 있습니다.

값 세 가지를 바꾸면 전환 느낌이 달라집니다

항목 값 느낌
01 MS_PER_PX 1.1 → 1.8 픽셀당 시간 값을 키우면 높이 차이가 클수록 전환이 더 길어져 패널이 천천히 자리 잡는 쪽에 가까워집니다.
04 SCALE_STEP 0.08 → 0.12 축소 단계 값을 키우면 활성 인덱스에서 멀수록 패널이 더 작게 물러나 깊이감이 커지는 쪽에 가까워집니다.
09 DAMPING 20 → 32 감쇠 값을 키우면 오버슛 폭이 줄어 슬라이드가 목표를 살짝만 지나쳤다 돌아오는 쪽에 가까워집니다.

세 값 모두 레이아웃이나 마크업은 그대로 두고 상수 하나만 바꿉니다. 01 은 시간축, 04 는 공간축, 09 는 힘의 크기를 바꾸는 값이라 같은 「전환을 조정한다」는 목적이어도 만지는 자리가 서로 다릅니다.

숨은 패널 높이를 재다가 0 이 나오는 함정

01 의 rectHeight() 를 그대로 복사해 hidden 속성만 검사하지 않고 바로 getBoundingClientRect() 를 부르면 높이가 항상 0 으로 잡히는 함정에 걸립니다. hidden 이 붙은 요소는 display: none 과 같은 취급을 받아 실측 자체가 불가능하기 때문입니다. 01 코드가 굳이 visibility: hidden 과 position: absolute 로 잠깐 세워 잰 뒤 다시 감추는 이유가 여기 있습니다 — 화면에는 보이지 않지만 레이아웃 계산에는 참여하는 상태를 한순간 만들어야 실측값이 나옵니다.

na4hmpde 06 도 비슷한 함정을 품고 있습니다. scrollHeight 는 콘텐츠에 이미지가 있으면 로드 전과 후의 값이 달라지므로, 이미지가 없는 데모 콘텐츠라 값이 안정적으로 잡힙니다. 실제 이미지가 들어가는 콘텐츠에 06 구조를 옮긴다면 이미지 로드 완료 시점에 맞춰 높이를 다시 재는 처리를 더해야 열렸다 닫히는 도중에 높이가 어긋나지 않습니다.

세그먼트 컨트롤(209 편)과는 무엇이 다른가요?

세그먼트 컨트롤 선택 UI 9종 — 복붙하면 되는 편은 필터·정렬처럼 값 하나를 고르는 선택기이고, 고른 결과가 값 자체로 화면에 남습니다. 이 편은 값을 고르는 UI 가 아니라 이미 정해진 패널 하나를 다른 패널로 바꿔 보여주는 전환 애니메이션에 집중합니다 — 무엇을 고르느냐보다 그 사이를 어떻게 지나가느냐가 계산의 대상입니다.

전환이라는 축으로 보면 모바일 하단 탭바 9종 — 상태 값으로 바뀌는 아홉 가지 전환 편과 가장 가깝습니다. 다만 하단 탭바는 화면 전체를 바꾸는 내비게이션이고, 이 편은 한 페이지 안에서 패널만 바뀌는 지역적 전환이라는 점이 다릅니다. 03 의 클립 와이프처럼 경계선이 움직이는 방식은 글리치 전환 효과 9가지 — css 글리치 효과 모음 편의 색수차 화면 전환과도 비교할 만합니다 — 저쪽은 오프셋으로 어긋남을 만들고, 03 은 비율로 경계를 닦아냅니다.

탭 전환 아홉 개를 적용하기 전에 자주 나오는 질문

탭 UI 디자인에 아홉 개 전부 리사이즈에 반응하나요?

아니요. 리사이즈 리스너는 02·06·09 세 항목에만 붙어 있습니다. 02 는 뷰포트 너비로 이동폭을 다시 계산해야 하고, 06 은 펼쳐진 패널의 실측 높이가 창 크기에 따라 바뀌고, 09 는 슬라이드 목표 위치가 트랙 너비에 좌우되기 때문입니다. 나머지 여섯 항목은 클릭·키보드 조작 시점에만 값을 다시 계산합니다.

01·04·09 는 CSS 트랜지션인가요, requestAnimationFrame 인가요?

01 과 04 는 계산한 값을 CSS transition 에 넘기고 실제 보간은 브라우저가 처리합니다. 09 는 스프링 물리를 매 프레임 직접 계산해야 하므로 requestAnimationFrame 루프가 전 구간을 그립니다. 같은 이유로 05 도 requestAnimationFrame 을 쓰고, 나머지 01·02·03·04·06·07·08 일곱 항목은 CSS transition 이 값을 받아 보간합니다.

08 은 원형 대신 사각형 마스크로 바꿔도 되나요?

clip-path 를 circle() 대신 inset() 으로 바꾸면 됩니다. 다만 08 의 중심 좌표 계산은 원형 확장을 전제로 대각선 길이를 반지름 목표값으로 삼고 있어, 사각형으로 바꾸려면 반지름 대신 가로·세로 각각의 목표값을 따로 계산해야 경계가 어긋나지 않습니다.

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

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