React Tab 구현하기

웹 애플리케이션에서 탭(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)

동작 방식

  1. prevButton.offsetLeft: 이전 탭 위치에서 시작
  2. (nextButton.offsetLeft – prevButton.offsetLeft) * 진행 상태 만큼 추가 이동

width 값 계산 → 인디케이터의 너비 조절

각 변수의 의미

  • prevButton.offsetWidth: 이전 탭 버튼의 너비
  • nextButton.offsetWidth: 다음 탭 버튼의 너비
  • (nextButton.offsetWidth – prevButton.offsetWidth): 이전/다음 탭 간 너비 차이
  • nextIndex % 1: 현재 진행 상태 (0 ~ 1)

동작 방식

  1. prevButton.offsetWidth: 이전 탭 버튼의 너비로 시작
  2. (nextButton.offsetWidth – prevButton.offsetWidth) * 진행 상태 만큼 변경

border와 indicator가 동시에 존재하는 탭

아래 이미지처럼, 탭 버튼 부분에 보더를 생성하면, 당연한 것이지만, indicator보다 보더가 선택된 탭임에도 불구하고 보이게 된다. 이는 간단하게 인디케이터의 보더 크기만큼 bottom으로 -값을 주면 해결이 되기는 한다. 하지만 탭 버튼 부분은 탭이 많아지는 경우 overflow를 사용하면 -값으로는 해결이 되지 않는다. 이런 경우에 나는 CSS 가상 클래스를 사용해서 해결 했다.

-값 준 상태

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

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

Leave a Comment