웹 애플리케이션에서 탭(Tab) UI는 다양한 콘텐츠를 한 페이지에서 쉽게 전환할 수 있도록 도와주는 중요한 UI 요소이고 자주 사용하는 만큼, 정리를 해두면 좋을 거 같다는 생각을 했습니다.
Tab UI의 구조
탭 UI는 보통 다음과 같은 구조를 가집니다.
- 탭 목록(Tab List): 클릭 가능한 버튼 또는 링크 모음
- 탭 패널(Tab Panel): 선택된 탭에 따라 달라지는 콘텐츠 영역
이 구조를 구현하기 위해 상태 관리가 필요합니다. 선택된 탭을 State로 관리하고, 해당 값에 따라 콘텐츠를 동적으로 변경하면 됩니다.
기본적인 React Tab 구현
아래는 기본적인 탭 컴포넌트 구현 코드입니다.
import { Children, Fragment, useState } from "react"
const TAB_HEADERS = ['1', '2', '3']
function App() {
return <TabList headers={TAB_HEADERS} initialIndex={1}>
<div className="bg-red-200">Lorem, ipsum dolor sit amet consectetur adipisicing elit. Quaerat quae harum aut similique facilis dignissimos qui. Minima, explicabo voluptates delectus voluptatibus id sit eligendi quidem dolorum odit qui doloremque voluptate?</div>
<div className="bg-green-200">Lorem ipsum, dolor sit amet consectetur adipisicing elit. Quidem autem culpa eius, est similique libero quo quos voluptas officiis molestias soluta cupiditate, cum dolores porro consequatur reprehenderit architecto exercitationem doloribus.</div>
<div className="bg-blue-200">Lorem ipsum dolor, sit amet consectetur adipisicing elit. Temporibus corporis vel minus, iste eius vitae, itaque labore ex eos, quisquam corrupti reiciendis perferendis omnis tenetur fugit. Pariatur ea dolore quibusdam!</div>
</TabList>
}
function TabList({ children, headers, initialIndex }) {
const [current, setCurrent] = useState(initialIndex);
return <div className="w-[300px]">
<header className="relative flex justify-between w-full">
{headers.map((item, index) => <button className="w-full h-[40px] bg-slate-200" key={index} onClick={() => setCurrent(index)}>
{item}
</button>)}
<div className="">
</div>
</header>
<div className="w-full">
{Children.toArray(children).map((tab, index) => {
return <Fragment key={index}>{current === index ? <>{tab}</> : null}</Fragment>
})}
</div>
</div>
}
export default App
설명
useState를 활용한 탭 상태 관리
const [current, setCurrent] = useState(initialIndex);
현재 선택된 탭의 인덱스를 useState로 관리합니다.
초기값은 initialIndex로 설정하여 원하는 탭을 기본 선택할 수 있도록 합니다.
탭 버튼 목록 생성
<header className="relative flex justify-between w-full">
{headers.map((item, index) => <button className="w-full h-[40px] bg-slate-200" key={index} onClick={() => setCurrent(index)}>
{item}
</button>)}
<div className="">
</div>
</header>
headers 배열을 기반으로 탭 버튼을 동적으로 생성합니다.
버튼을 클릭하면 setCurrent(index)를 호출하여 해당 탭의 인덱스를 변경합니다.
선택된 탭 콘텐츠만 렌더링
<div className="w-full">
{Children.toArray(children).map((tab, index) => {
if(current !== index) return null
return <Fragment key={index}>{tab}</Fragment>
})}
</div>
Children.toArray(children)을 사용하여 children을 배열로 변환합니다.
현재 선택된 탭의 인덱스(current)와 일치하는 콘텐츠만 화면에 렌더링합니다.
Fragment를 사용하여 불필요한 DOM 요소가 추가되지 않도록 합니다.
탭 인디케이터 만들기
이 코드는 React의 상태 관리 및 DOM 조작을 활용하여 탭 컴포넌트를 만들고, 선택된 탭 아래에 인디케이터(밑줄)를 표시하는 방법을 보여줍니다.
function TabList({ children, headers, initialIndex }) {
const [current, setCurrent] = useState(initialIndex);
const buttons = useRef([]) // 버튼 요소 배열
const [indicatorStyle, setIndicatorStyle] = useState({ // 인디케이터 위치 값
left: 0,
width: 0
})
useEffect(() => { // 탭이 변할때마다 인디케이터 적용
const button = buttons.current[current]
const left = button.offsetLeft
const width = button.offsetWidth
setIndicatorStyle({
left,
width
})
}, [current])
return <div className="w-[300px]">
<header className="relative flex justify-between w-full">
{headers.map((item, index) => <button ref={el => buttons.current[index] = el} className="w-full h-[40px] bg-slate-200" key={index} onClick={() => setCurrent(index)}>
{item}
</button>)}
{/* 인디케이터 (선택된 탭 아래의 밑줄) */}
<div
className="absolute bottom-0 w-0 h-[3px] bg-brand rounded-[4px] desktop:transition-none bg-red-500"
style={indicatorStyle}
></div>
</header>
<div className="w-full">
{Children.toArray(children).map((tab, index) => {
if(current !== index) return null
return <Fragment key={index}>{tab}</Fragment>
})}
</div>
</div>
}
추가된 부분은, useRef를 활용해 각 버튼의 위치와 크기를 가져오고, useEffect로 current가 변경될 때 버튼 위치(offsetLeft)와 너비(offsetWidth)를 계산하여 inidicator의 style을 업데이트 합니다.
탭과 슬라이드 연결하고, 실시간 인디케이터 구현
탭과 슬라이드를 연결하고, 탭에 인디케이터를 두어서 슬라이드의 진행상태를 표시하는 컴포넌트를 만든적이 있습니다.
슬라이드는 @egjs/react-flicking 라이브러리를 사용했습니다.
npm i @egjs/react-flicking
import { Children, Fragment, useEffect, useRef, useState } from "react"
import Flicking from "@egjs/react-flicking";
import "@egjs/react-flicking/dist/flicking.css";
const TAB_HEADERS = ['1', '2', '3']
function App() {
return <TabList headers={TAB_HEADERS} initialIndex={1}>
<div className="bg-red-200 w-full">Lorem, ipsum dolor sit amet consectetur adipisicing elit. Quaerat quae harum aut similique facilis dignissimos qui. Minima, explicabo voluptates delectus voluptatibus id sit eligendi quidem dolorum odit qui doloremque voluptate?</div>
<div className="bg-green-200 w-full">Lorem ipsum, dolor sit amet consectetur adipisicing elit. Quidem autem culpa eius, est similique libero quo quos voluptas officiis molestias soluta cupiditate, cum dolores porro consequatur reprehenderit architecto exercitationem doloribus.</div>
<div className="bg-blue-200 w-full">Lorem ipsum dolor, sit amet consectetur adipisicing elit. Temporibus corporis vel minus, iste eius vitae, itaque labore ex eos, quisquam corrupti reiciendis perferendis omnis tenetur fugit. Pariatur ea dolore quibusdam!</div>
</TabList>
}
function TabList({ children, headers, initialIndex }) {
const [current, setCurrent] = useState(initialIndex);
const flicking = useRef()
const buttonsRef = useRef([])
const [indicatorStyle, setIndicatorStyle] = useState({
left: 0,
width: 0
})
// 탭 클릭 시 슬라이드 이동
useEffect(() => {
const slider = flicking.current;
if (!slider) return;
const isTabMove = current !== -1 && slider.index !== current;
if (isTabMove) {
if (slider.animating) slider.stopAnimation(); // 기존 애니메이션 중지
slider.moveTo(current); // 해당 슬라이드로 이동
}
}, [current])
return <div className="w-[300px]">
<header className="relative flex justify-between w-full">
{headers.map((item, index) => <button ref={el => buttonsRef.current[index] = el} className="w-full h-[40px] bg-slate-200" key={index} onClick={() => setCurrent(index)}>
{item}
</button>)}
<div
className="absolute bottom-0 w-0 h-[3px] bg-brand rounded-[4px] desktop:transition-none bg-red-500"
style={indicatorStyle}
></div>
</header>
<Flicking
ref={flicking}
className="w-full"
align="center"
onMove={() => {
const flickingInstance = flicking.current;
if (!flickingInstance) return;
const buttons = buttonsRef.current;
const panels = flickingInstance.panels;
if (buttons.length === 0 || panels.length === 0) return;
const currentPanel = flickingInstance.currentPanel;
if (!currentPanel) return;
const panelIndex = currentPanel.index;
const progress = currentPanel.progress;
// 진행 정도를 기반으로 다음 탭 인덱스 계산
const nextIndex = Math.max(0, Math.min(panelIndex + -progress, buttons.length - 1));
// 현재 및 다음 탭 버튼 정보 가져오기
const prevButton = buttons[Math.floor(nextIndex)];
const nextButton = buttons[Math.ceil(nextIndex)];
if (!nextButton && !prevButton) return;
// 인디케이터 위치 및 크기 계산
const left =
prevButton.offsetLeft +
(nextButton.offsetLeft - prevButton.offsetLeft) * (nextIndex % 1);
const width =
prevButton.offsetWidth +
(nextButton.offsetWidth - prevButton.offsetWidth) * (nextIndex % 1);
// 인디케이터 스타일 업데이트
setIndicatorStyle({
left: `${left}px`,
width: `${width}px`,
});
}}
onChanged={(e) => {
if(e.index === current) return
setCurrent(e.index) // 슬라이드 변경 시 현재 탭 업데이트
}}
>
{Children.toArray(children).map((tab, index) => {
return <Fragment key={index}>{tab}</Fragment>
})}
</Flicking>
</div>
}
export default App
실시간으로 진행 정도를 표시해야하니, 핵심은 flicking에서 제공하는 onMove이벤트로 슬라이드가 이동하는 동안 실행됩니다. 즉 사용자가 슬라이드를 드래그하고 있는 순간에 계속 호출됩니다.
onMove내 코드 동작 설명
슬라이드 상태를 읽기 위해서 Flicking 인스턴스를 가져오고, 인스턴스가 없으면 종료합니다.
const flickingInstance = flicking.current;
if (!flickingInstance) return;
버튼과 패널 정보를 가져오고, 존재하지 않는다면, 종료합니다.
const buttons = buttonsRef.current;
const panels = flickingInstance.panels;
if (buttons.length === 0 || panels.length === 0) return;
현재 패널의 정보(슬라이드 상태)를 가져옵니다.
const currentPanel = flickingInstance.currentPanel;
if (!currentPanel) return;
현재 패널의 위치와 진행 상태를 가져옵니다.
const panelIndex = currentPanel.index;
const progress = currentPanel.progress;
플리킹은 현재 위치를 기준으로 우측이면 – 좌측이면 + 값을 줍니다.

그리고 panelIndex는 슬라이드의 index를 의미합니다. 따라서 progress와 index를 조합하여 다음 index를 구합니다.
const nextIndex = Math.max(0, Math.min(panelIndex + -progress, buttons.length - 1));
현재 패널 인덱스(panelIndex)에 진행 방향(-progress)을 더해 이동할 인덱스를 계산하고,
Math.max(0, Math.min(…)): 인덱스가 0보다 작아지거나 최대 길이를 넘지 않도록 제한합니다.
그 다음 위에서 나온 값을 활용해 이전 버튼과 다음 버튼을 구합니다.
const prevButton = buttons[Math.floor(nextIndex)];
const nextButton = buttons[Math.ceil(nextIndex)];
if (!nextButton && !prevButton) return;
동작은
Math.floor(nextIndex): 이전 버튼 인덱스
Math.ceil(nextIndex): 다음 버튼 인덱스
두 버튼 중 하나라도 없으면 종료합니다.
마지막으로 left(위치)와 width(너비)를 구해서 적용합니다.
const left =
prevButton.offsetLeft +
(nextButton.offsetLeft - prevButton.offsetLeft) * (nextIndex % 1);
const width =
prevButton.offsetWidth +
(nextButton.offsetWidth - prevButton.offsetWidth) * (nextIndex % 1);
setIndicatorStyle({
left: `${left}px`,
width: `${width}px`,
});
nextIndex % 1는 현재 슬라이드 위치를 실수로 표현한 값입니다.
예를 들어:
- 슬라이드 1 → 2 사이를 40% 이동했다면 → nextIndex = 1.4
- 슬라이드 2 → 3 사이를 70% 이동했다면 → nextIndex = 2.7
Left 계산 → 인디케이터의 X 위치
각 변수의 의미
- prevButton.offsetLeft: 이전 탭 버튼의 왼쪽 위치
- nextButton.offsetLeft: 다음 탭 버튼의 왼쪽 위치
- (nextButton.offsetLeft – prevButton.offsetLeft): 이전 탭과 다음 탭 사이의 거리
- nextIndex % 1: 현재 진행 상태 (0 ~ 1)
동작 방식
- prevButton.offsetLeft: 이전 탭 위치에서 시작
- (nextButton.offsetLeft – prevButton.offsetLeft) * 진행 상태 만큼 추가 이동
width 값 계산 → 인디케이터의 너비 조절
각 변수의 의미
- prevButton.offsetWidth: 이전 탭 버튼의 너비
- nextButton.offsetWidth: 다음 탭 버튼의 너비
- (nextButton.offsetWidth – prevButton.offsetWidth): 이전/다음 탭 간 너비 차이
- nextIndex % 1: 현재 진행 상태 (0 ~ 1)
동작 방식
- prevButton.offsetWidth: 이전 탭 버튼의 너비로 시작
- (nextButton.offsetWidth – prevButton.offsetWidth) * 진행 상태 만큼 변경
border와 indicator가 동시에 존재하는 탭
아래 이미지처럼, 탭 버튼 부분에 보더를 생성하면, 당연한 것이지만, indicator보다 보더가 선택된 탭임에도 불구하고 보이게 된다. 이는 간단하게 인디케이터의 보더 크기만큼 bottom으로 -값을 주면 해결이 되기는 한다. 하지만 탭 버튼 부분은 탭이 많아지는 경우 overflow를 사용하면 -값으로는 해결이 되지 않는다. 이런 경우에 나는 CSS 가상 클래스를 사용해서 해결 했다.

-값 준 상태

탭 버튼에 overflow를 적용 (-값 때문에 스크롤이 생겨버림)

기존 border 제거, 가상 선택자를 사용하고, indicator에 z-index를 추가
