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

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

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

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

아홉 개 탭 메뉴 css 는 인기 순이 아니라 탭이 뭘 담느냐에 따라 고르는 순서로 놓았습니다. 인디케이터만 옮겨가는 것(01~02)에서 패널 내용까지 함께 바뀌는 것(03~04)으로, 그다음은 접근성 트리를 직접 짜야 하는 실전 UI(05)와 탭이 가로로 넘치거나 세로로 쌓이는 상황(06~07), 마지막은 장식이 필요한 것(08~09) 순입니다. 같은 전환을 화면 옆으로 밀어내면 사이드바 드로어가 되고, 페이지 통째를 갈아 끼우는 규모로 키우면 CSS 화면 전환 효과의 영역입니다.

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

:checked 형제결합자로 옮기기

탭 세 개를 <input type="radio">로 만들고 라벨 아래 밑줄 span 하나를 각 라디오의 :checked ~ 형제결합자로 옮겨갑니다.

밑줄 인디케이터 슬라이드
새 창에서 열기

01 · 밑줄 인디케이터 슬라이드 — 측정 누적면적 2.301% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

:checkedtranslateX300ms

화살표 키가 그냥 되는 이유

라디오 그룹은 브라우저가 원래 화살표 키 이동을 지원해서 JS 없이도 키보드로 바로 오갈 수 있습니다. 밑줄 뒤에는 같은 @keyframes로 120ms 늦게 출발하는 ::after 잔상 겹을 깔아 목표를 살짝 지나쳤다 되돌아오는 관성까지 붙었습니다.

.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로 옮겨가며 모양을 바꿉니다.

필 배경 이동
새 창에서 열기

02 · 필 배경 이동 — 측정 누적면적 3.272% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translateXscaleX:checked

css 탭 메뉴 디자인에서 폭을 미리 계산하는 이유

라벨 글자 수가 서로 달라 폭을 고정 상수로 미리 계산해 뒀습니다. 필 뒤에도 01과 같은 120ms 지연 ::after 잔상이 깔려 알약이 자리를 바꿀 때 잠깐 두 개가 겹쳐 보이는 트레일이 생깁니다.

.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로 떠오르게 합니다.

패널 페이드
새 창에서 열기

03 · 패널 페이드 — 측정 누적면적 2.14% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

opacitygrid 겹침220ms

레이아웃이 흔들리지 않는 이유

세 패널이 같은 자리에 있어서 전환 중에도 레이아웃이 흔들리지 않습니다. 데모 루프에서는 등장 패널이 8px 아래에서 떠오르며 -2px를 지나쳤다 정착하는 관성 곡선을 타서 페이드에도 방향감이 남습니다.

.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)만큼 밀어 고른 탭의 패널만 뷰포트 안에 들어오게 합니다.

패널 슬라이드
새 창에서 열기

04 · 패널 슬라이드 — 측정 누적면적 2.853% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translateXoverflow:hidden300ms

밑줄도 같은 시점에

탭 아래 밑줄도 같은 시점에 같이 옮겨가야 어색하지 않습니다. 트랙은 원본 그대로 두고 밑줄에만 지연 ::after 잔상을 얹어, 무거운 트랙보다 가벼운 밑줄이 살짝 늦게 따라붙는 관성 차이를 만들었습니다.

.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아이콘 탭

라디오가 아니라 진짜 tablist

여기부터는 라디오가 아니라 진짜 role="tablist"와 role="tab" 버튼입니다.

아이콘 탭
새 창에서 열기

05 · 아이콘 탭 — 측정 누적면적 0.301% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

role=tablistaria-selectedscale

클릭과 화살표 키를 한 함수로

클릭과 좌우 화살표 키가 같은 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으로 하나씩 딱딱 끊어 멈추게 합니다.

가로 스냅 탭
새 창에서 열기

06 · 가로 스냅 탭 — 측정 누적면적 2.21% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

scroll-snap-typeoverflow-xtranslateX

밑줄은 01과 같은 방식

밑줄 인디케이터는 01과 같은 방식으로 고른 탭 아래까지 미끄러집니다. 이징을 목표를 12.6% 넘겼다 정착하는 lerp 곡선으로 바꾸고 밑줄 뒤에 트레일 겹을 더해, 딱 끊기는 스냅 옆에서도 인디케이터는 관성 있게 흘러갑니다.

.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로 위아래로 미끄러져 지금 열린 항목을 가리킵니다.

세로 탭
새 창에서 열기

07 · 세로 탭 — 측정 누적면적 1.549% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

translateY:checked300ms

행 높이에 맞춘 인디케이터

가로 버전(01)을 축만 바꾼 것처럼 보이지만 세로 목록은 항목 수가 늘어나기 쉬워 인디케이터 높이를 행 높이에 맞춰 계산해 둬야 합니다. 막대 뒤에 06과 같은 lerp 트레일을 세로로 깔아 목록이 길어져도 이동 방향이 한눈에 읽힙니다.

.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로 짧게 튕깁니다.

탭 바운스
새 창에서 열기

08 · 탭 바운스 — 측정 누적면적 7.166% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

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 와 같은 구조로 움직인다

메일함처럼 숫자 배지가 붙은 탭 셋을 05와 같은 role="tablist" 구조와 같은 select() 함수로 움직입니다.

탭 카운트 배지
새 창에서 열기

09 · 탭 카운트 배지 — 측정 누적면적 2.66% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

role=tablistaria-selectedscale

열린 칸만 튀어 오르는 배지

새로 열린 탭의 배지만 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); }
}

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

calc 는 길이를 길이로 나누지 못한다

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

자동 재생과 실제 상태가 따로 논다

탭 줄이 화면 위에 붙어 따라다녀야 한다면 CSS 헤더 고정 9가지 응용이 그 고정 층에서 생기는 겹침을 먼저 정리해 둡니다.

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

접근성

라디오 그룹과 tablist 의 차이

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

html css 탭 메뉴에 꼭 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 박자로 묶어야 항상 같은 탭을 가리킵니다. 탭이 열 개를 넘어 분류가 필요해지는 지점부터는 메가 메뉴 UI가 더 알맞은 그릇입니다.

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

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