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 없이도 키보드로 바로 오갈 수 있습니다.
.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필 배경 이동
탭 뒤에 깔린 알약 배경이 고른 탭의 자리까지 translateX와 scaleX로 옮겨가며 모양을 바꿉니다. 라벨 글자 수가 서로 달라 폭을 고정 상수로 미리 계산해 뒀습니다.
.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로 떠오르게 합니다. 세 패널이 같은 자리에 있어서 전환 중에도 레이아웃이 흔들리지 않습니다.
.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)만큼 밀어 고른 탭의 패널만 뷰포트 안에 들어오게 합니다. 탭 아래 밑줄도 같은 시점에 같이 옮겨가야 어색하지 않습니다.
.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와 포커스를 함께 옮기고, 선택된 아이콘만 살짝 커집니다.
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과 같은 방식으로 고른 탭 아래까지 미끄러집니다.
.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)을 축만 바꾼 것처럼 보이지만 세로 목록은 항목 수가 늘어나기 쉬워 인디케이터 높이를 행 높이에 맞춰 계산해 둬야 합니다.
.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로 짧게 튕깁니다. 글자색은 항상 같은 색으로 고정해 두고 인디케이터의 위치와 튕김만으로 활성 탭을 알립니다.
.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로 짧게 튀어 올라 몇 번째 칸이 지금 열려 있는지 눈에 띄게 합니다.
.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); }
}
어디서 깨지는가 — 함정 하나
아홉 개 전부 인디케이터·패널은 transform과 opacity만 움직입니다. 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에서는 아홉 개 전부 transition과 animation을 끄고 인디케이터·패널·배지가 현재 탭의 최종 상태로 곧장 표시됩니다. 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 박자로 묶어야 항상 같은 탭을 가리킵니다.