진행하고 있는 Expense-Tracker (거창하지만 그냥 가계부다)에서 지출 및 수입을 입력하는 페이지로 이동하는 NavBar가 필요했습니다. 간단하게 Header에 Navbar를 집어넣을 식으로 진행할 예정입니다.
모바일에서 봤을 때 헤더의 내용들이 겹쳐 있으면 안 되므로 반응형으로 제작해보기로 했습니다.
PC 버전에서의 헤더는 메뉴들이 일자로 나열되어 있는 경우들이 많습니다. 화면이 그만큼 여유롭게 크기 때문이죠.
하지만 모바일 버전은 대부분 이런 모양의 아이콘을 사용합니다.

화면이 일정 크기만큼 줄어들면 메뉴들을 저 아이콘으로 대체하면 되겠죠?
X 나 위의 메뉴 아이콘 이미지를 저장해서 사용하는 게 귀찮아서, 관련 라이브러리들이 있을까? 하고 찾아봤습니다.
웹 아이콘 라이브러리가 존재하더군요! 진짜 별 게 다 있습니다...
pnpm 을 사용하고 있으므로 아래 명령어를 입력해서 라이브러리 설치를 해줍니다.
pnpm install lucide-react
어떤 아이콘들이 있는지는 공식사이트 에서 확인하시고, 사용할 때는 이렇게 사용합니다.
import { Menu, X } from 'lucide-react'
기본적으로 Logo가 들어갈 자리와 Home, Analyze Nav를 집어넣어줍니다.
Tailwind에서 기본적으로 제공하는 미디어쿼리 브레이크 포인트가 있습니다.
저는 태블릿 브레이크 포인트를 744px로 잡아놨으니 그걸 사용하겠습니다.
const Header = () => {
const [isOpen, setIsOpen] = useState(false)
const menuItems = [
{ name: 'Home', to: '/' },
{ name: 'Analyze', to: '/analyze' },
]
return (
<header className='bg-white shadow-sm w-full'>
<nav className='mx-auto px-4 py-3'>
<div className='flex justify-between items-center'>
{/* 로고 영역 */}
<Link to='/' className='text-xl font-bold'>
Logo
</Link>
{/* 데스크탑 메뉴 (744px 미만에서 숨김) */}
<div className='hidden tablet:flex gap-6'>
{menuItems.map((menu) => (
<Link
key={menu.name}
to={menu.to}
className='text-gray-600 hover:text-gray-900'
>
{menu.name}
</Link>
))}
</div>
{/* 모바일 메뉴 버튼 (744px 이상에서 숨김) */}
<button
onClick={() => setIsOpen(!isOpen)}
className='tablet:hidden p-2'
aria-label='Toggle menu'
>
{isOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
{/* 모바일 메뉴 (744px 이상에서 숨김) */}
{isOpen && (
<div className='tablet:hidden py-4'>
<div className='flex flex-col gap-4'>
{menuItems.map((menu) => (
<Link
key={menu.name}
to={menu.to}
className='text-gray-600 hover:text-gray-900 px-2 py-1'
onClick={() => setIsOpen(false)}
>
{menu.name}
</Link>
))}
</div>
</div>
)}
</nav>
</header>
)
}

제대로 동작하는 것을 확인할 수 있습니다!
import { useState } from 'react'
import { Link, useLocation } from 'react-router'
import { Menu, X } from 'lucide-react'
const menuItems = [
{ name: 'Home', to: '/' },
{ name: 'Input', to: '/input' },
{ name: 'Analyze', to: '/analyze' },
]
const Header = () => {
const [isOpen, setIsOpen] = useState(false)
const { pathname } = useLocation()
{/* 현재 선택된 메뉴 강조하기 위한 함수 */}
const isCurrent = (path: string) => {
if (path === '/') {
return pathname === '/'
}
return pathname.startsWith(path)
}
return (
<header className='fixed top-0 z-50 w-full h-16 bg-white border-b border-gray-200'>
<nav className='relative mx-auto px-4 py-3 w-full h-full'>
<div className='flex w-full justify-between'>
{/* 로고 영역 */}
<Link to='/' className='flex shrink-0 items-center'>
<span className='text-xl font-bold'>Logo</span>
</Link>
{/* 데스크탑 메뉴 (744px 미만에서 숨김) */}
<div className='hidden tablet:flex gap-1 ml-auto'>
{menuItems.map((menu) => (
<Link
key={menu.name}
to={menu.to}
className={`items-center px-6 py-3 text-sm font-medium
${
isCurrent(menu.to)
? 'bg-indigo-50 border-indigo-500 text-indigo-700'
: 'text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700'
}`}
aria-current={isCurrent(menu.to) ? 'page' : undefined}
>
{menu.name}
</Link>
))}
</div>
{/* 모바일 메뉴 버튼 (744px 이상에서 숨김) */}
<div className='flex items-center tablet:hidden'>
<button
onClick={() => setIsOpen(!isOpen)}
className='inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-500'
aria-label='Toggle menu'
>
{isOpen ? <X size={24} /> : <Menu size={24} />}
</button>
</div>
</div>
{/* 모바일 메뉴 (744px 이상에서 숨김) */}
{isOpen && (
<div className='absolute top-full left-0 w-full bg-white border-b border-gray-200 tablet:hidden'>
<div className='space-y-1 pb-3 pt-2'>
{menuItems.map((menu) => (
<Link
key={menu.name}
to={menu.to}
className={`block py-2 pl-3 pr-4 text-base font-medium
${
isCurrent(menu.to)
? 'bg-indigo-50 border-indigo-500 text-indigo-700'
: 'text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700'
}`}
onClick={() => setIsOpen(false)}
>
{menu.name}
</Link>
))}
</div>
</div>
)}
</nav>
</header>
)
}
export default Header