GODRICH

CSS 탭 메뉴 전환 애니메이션 9종 — JS 없이, CSS만으로

CSS 탭 메뉴 전환 애니메이션은 라디오 버튼의 :checked 상태나 role="tablist" 버튼만으로 탭 사이를 오가며 인디케이터가 미끄러지고 패널이 바뀌는 동작을 말합니다.

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

아홉 개는 인기 순이 아니라 탭이 뭘 담느냐에 따라 고르는 순서로 놓았습니다. 인디케이터만 옮겨가는 것(01~02)에서 패널 내용까지 함께 바뀌는 것(03~04)으로, 그다음은 접근성 트리를 직접 짜야 하는 실전 UI(05)와 탭이 가로로 넘치거나 세로로 쌓이는 상황(06~07), 마지막은 장식이 필요한 것(08~09) 순입니다.

01밑줄 인디케이터 슬라이드

탭 세 개를 <input type="radio">로 만들고 라벨 아래 밑줄 span 하나를 각 라디오의 :checked ~ 형제결합자로 옮겨갑니다. 라디오 그룹은 브라우저가 원래 화살표 키 이동을 지원해서 JS 없이도 키보드로 바로 오갈 수 있습니다.

:checkedtranslateX300ms
.us__indicator {
  position: absolute; left: 0; bottom: 0;
  width: calc(100% / 3); height: 3px;
  background: $color;
  transform: translateX(0);
  transition: transform $duration $easing;
}
#us-1:checked ~ .us__nav .us__indicator { transform: translateX(0%); }
#us-2:checked ~ .us__nav .us__indicator { transform: translateX(100%); }
#us-3:checked ~ .us__nav .us__indicator { transform: translateX(200%); }

02필 배경 이동

탭 뒤에 깔린 알약 배경이 고른 탭의 자리까지 translateXscaleX로 옮겨가며 모양을 바꿉니다. 라벨 글자 수가 서로 달라 폭을 고정 상수로 미리 계산해 뒀습니다.

translateXscaleX:checked
.pi__pill {
  position: absolute; top: $sp-1; left: $sp-1; bottom: $sp-1;
  width: $w1;
  background: $color;
  transform: translateX(0) scaleX(1);
  transform-origin: left center;
  transition: transform $duration $easing;
}
#pi-2:checked ~ .pi__nav .pi__pill {
  transform: translateX(calc(#{$w1} + #{$sp-2})) scaleX(1.357);
}

03패널 페이드

패널 세 개를 grid 한 칸에 포개 두고 고른 탭의 패널만 opacity로 떠오르게 합니다. 세 패널이 같은 자리에 있어서 전환 중에도 레이아웃이 흔들리지 않습니다.

opacitygrid 겹침220ms
.fp__panel {
  grid-area: 1 / 1;
  opacity: 0;
  pointer-events: none;
  transition: opacity $duration $easing;
}
#fp-1:checked ~ .fp__panels .fp__panel--1,
#fp-2:checked ~ .fp__panels .fp__panel--2,
#fp-3:checked ~ .fp__panels .fp__panel--3 { opacity: 1; pointer-events: auto; }

04패널 슬라이드

패널 세 장을 가로로 나란히 둔 트랙을 통째로 translateX(-33.333%×n)만큼 밀어 고른 탭의 패널만 뷰포트 안에 들어오게 합니다. 탭 아래 밑줄도 같은 시점에 같이 옮겨가야 어색하지 않습니다.

translateXoverflow:hidden300ms
.sp__track {
  display: flex;
  width: 300%;
  transform: translateX(0%);
  transition: transform $duration $easing;
}
#sp-2:checked ~ .sp__viewport .sp__track { transform: translateX(-33.333%); }
#sp-3:checked ~ .sp__viewport .sp__track { transform: translateX(-66.667%); }

05아이콘 탭

여기부터는 라디오가 아니라 진짜 role="tablist"role="tab" 버튼입니다. 클릭과 좌우 화살표 키가 같은 select() 함수를 호출해 aria-selected와 포커스를 함께 옮기고, 선택된 아이콘만 살짝 커집니다.

role=tablistaria-selectedscale
function select(i) {
  tabs.forEach(function (t, j) {
    var on = j === i;
    t.setAttribute('aria-selected', on);
    t.tabIndex = on ? 0 : -1;
  });
  tabs[i].focus();
}
tabs[0].addEventListener('keydown', function (e) {
  if (e.key === 'ArrowRight') select(1);
});

06가로 스냅 탭

탭 다섯 개를 화면 폭보다 넓게 늘어놓고 scroll-snap-type으로 하나씩 딱딱 끊어 멈추게 합니다. 밑줄 인디케이터는 01과 같은 방식으로 고른 탭 아래까지 미끄러집니다.

scroll-snap-typeoverflow-xtranslateX
.st__scroller {
  overflow-x: auto;
  scroll-snap-type: x mandatory;
}
.st__tab {
  flex: 0 0 88px;
  scroll-snap-align: start;
}
#st-2:checked ~ .st__scroller .st__indicator { transform: translateX(88px); }

07세로 탭

탭이 세로로 쌓인 사이드 메뉴에서 왼쪽 막대 인디케이터가 translateY로 위아래로 미끄러져 지금 열린 항목을 가리킵니다. 가로 버전(01)을 축만 바꾼 것처럼 보이지만 세로 목록은 항목 수가 늘어나기 쉬워 인디케이터 높이를 행 높이에 맞춰 계산해 둬야 합니다.

translateY:checked300ms
.vt__indicator {
  position: absolute; left: -2px; top: 0;
  width: 2px; height: $row-h;
  background: $color;
  transform: translateY(0);
  transition: transform $duration $easing;
}
#vt-2:checked ~ .vt__nav .vt__indicator { transform: translateY(#{$row-h}); }
#vt-3:checked ~ .vt__nav .vt__indicator { transform: translateY(#{$row-h * 2}); }

08탭 바운스

인디케이터가 목표 지점을 살짝 지나쳤다 돌아오는 스프링 이징으로 옮겨가고, 고른 탭 글자도 @keyframes로 짧게 튕깁니다. 글자색은 항상 같은 색으로 고정해 두고 인디케이터의 위치와 튕김만으로 활성 탭을 알립니다.

cubic-bezier 스프링scaletranslateX
.bt__indicator {
  position: absolute; top: $sp-1; bottom: $sp-1; left: $sp-1;
  width: calc((100% - #{$sp-1} * 2) / 3);
  background: $color;
  transform: translateX(0);
  transition: transform $duration $easing;
}
#bt-2:checked ~ .bt__nav .bt__indicator { transform: translateX(100%); }
#bt-2:checked ~ .bt__nav label[for="bt-2"] .bt__text { animation: bt-pop 400ms $easing; }
@keyframes bt-pop { 55% { transform: scale(1.18); } }

09탭 카운트 배지

메일함처럼 숫자 배지가 붙은 탭 셋을 05와 같은 role="tablist" 구조와 같은 select() 함수로 움직입니다. 새로 열린 탭의 배지만 scale로 짧게 튀어 올라 몇 번째 칸이 지금 열려 있는지 눈에 띄게 합니다.

role=tablistaria-selectedscale
.cb__badge {
  transform: scale(1);
  transition: transform $duration $easing;
}
.cb__tab[aria-selected="true"] .cb__badge {
  animation: cb-pop $duration $easing;
}
@keyframes cb-pop {
  0% { transform: scale(1); }
  55% { transform: scale(1.35); }
  100% { transform: scale(1); }
}

어디서 깨지는가 — 함정 하나

아홉 개 전부 인디케이터·패널은 transformopacity만 움직입니다. 02 필 배경을 만들 때 탭 폭 비율을 scaleX(calc(76px / 56px))처럼 런타임 calc() 안에서 나누려 했더니 값이 통째로 무효 처리돼 필이 전혀 움직이지 않았습니다. CSS의 calc()는 길이를 길이로 나누는 걸 허용하지 않아서, 비율은 SCSS 컴파일 시점에 숫자로 미리 계산해 넣어야 합니다.

더 크게 걸린 함정은 데모 자동 재생과 실제 상태가 따로 놀 수 있다는 것이었습니다. 04 패널 슬라이드를 만들면서 패널이 담긴 트랙과 탭 아래 밑줄을 각각 다른 애니메이션으로 만들었더니, 자동 재생 중에 트랙은 2단계 내용을 보여주는데 밑줄은 1단계에 그대로 남는 장면이 실제로 찍혔습니다. 08 탭 바운스에서는 활성 탭 글자를 밝은 색으로 바꿨다가, 인디케이터가 자동 재생으로 다른 탭에 가 있는 동안 그 밝은 글자가 흰 무대 위에 남아 거의 안 보이는 대비 실패까지 겹쳤습니다. 이 압축 파일의 32dvwmre은 두 문제를 같은 @keyframes 박자로 묶고 글자색은 고정해 고친 뒤의 아홉 개 파일을 엽니다.

접근성

01~04와 06~08은 네이티브 <input type="radio"> 그룹이라 별도 코드 없이 Tab으로 그룹에 들어가고 ←→(세로는 ↑↓)로 항목 사이를 옮깁니다. 05와 09는 라디오가 아니라 role="tablist"·role="tab"·aria-selected를 직접 쓴 자리라 select() 함수 하나가 클릭과 화살표 키 양쪽에서 포커스와 aria-selected를 함께 옮기도록 만들어야 합니다. prefers-reduced-motion: reduce에서는 아홉 개 전부 transitionanimation을 끄고 인디케이터·패널·배지가 현재 탭의 최종 상태로 곧장 표시됩니다. W3C ARIA Authoring Practices의 탭 패턴이 05·09가 따른 키보드 상호작용 기준입니다.

다른 CSS 애니메이션 소재는 CSS 애니메이션 카테고리에 모여 있고, 이 사이트가 무엇을 다루는지는 소개에 있습니다.

FAQ

탭 메뉴엔 꼭 role="tablist"를 써야 하나요?

아닙니다. 01~04·06~08처럼 <input type="radio">로도 탭 전환은 완전히 동작하고 브라우저가 화살표 키 이동까지 대신해 줍니다. role="tablist"가 필요한 건 마크업이 버튼이라 네이티브 라디오 그룹의 도움을 받을 수 없는 05·09 같은 경우뿐입니다.

React에서는 라디오 대신 뭘 쓰나요?

useState로 활성 탭 인덱스 하나만 들고, 인디케이터 위치는 그 인덱스를 CSS 변수 --i로 넘겨 transform: translateX(calc(var(--i) * 100%))처럼 계산합니다. 변수 3개(duration·easing·color)는 props로 받아 같은 CSS 변수 방식으로 전달합니다.

패널 내용이 인디케이터와 다르게 보일 때가 있어요.

인디케이터와 패널을 서로 다른 애니메이션으로 따로 만들면 자동 재생 중에 둘이 어긋날 수 있습니다. 이 편의 01·04·07이 실제로 겪은 문제로, 두 요소를 같은 @keyframes 박자로 묶어야 항상 같은 탭을 가리킵니다.

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

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