Skip to Content
PatternsNavigation Patterns

Navigation Patterns

네비게이션 및 라우팅 구현 패턴입니다.

개요

Navigation Patterns는 사용자가 애플리케이션 내에서 효율적으로 이동하고, 현재 위치를 파악하며, 원하는 콘텐츠를 쉽게 찾을 수 있도록 돕는 검증된 구현 방법입니다. 반응형 네비게이션, 브레드크럼, 사이드바, 탭, 메가 메뉴 등 프로덕션 환경에서 필요한 모든 네비게이션 시나리오를 다룹니다.

사용 사례:

  • 헤더 네비게이션 (주요 메뉴)
  • 사이드바 네비게이션 (관리자 대시보드)
  • 브레드크럼 (현재 위치 표시)
  • 탭 네비게이션 (섹션 전환)
  • 모바일 햄버거 메뉴

사용하지 말아야 할 때:

  • 단일 페이지 애플리케이션 (SPA)에서 페이지 새로고침 필요
  • 매우 간단한 랜딩 페이지 (3개 이하 링크)
  • 선형 플로우 (마법사 UI - 스텝 인디케이터 사용)

기본 패턴

1. 헤더 네비게이션

기본 헤더 네비게이션 구현 패턴입니다.

"use client"; import { useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { Button } from "@vortex/ui-foundation"; const navItems = [ { href: "/", label: "홈" }, { href: "/products", label: "제품" }, { href: "/about", label: "소개" }, { href: "/contact", label: "연락처" }, ]; export default function HeaderNavigation() { const pathname = usePathname(); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); return ( <header className="bg-white shadow-sm"> <nav className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8" aria-label="메인 네비게이션" > <div className="flex justify-between items-center h-16"> {/* Logo */} <Link href="/" className="text-2xl font-bold"> Logo </Link> {/* Desktop Navigation */} <div className="hidden md:flex space-x-8"> {navItems.map((item) => ( <Link key={item.href} href={item.href} className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${ pathname === item.href ? "bg-blue-100 text-blue-700" : "text-gray-700 hover:bg-gray-100" }`} aria-current={pathname === item.href ? "page" : undefined} > {item.label} </Link> ))} </div> {/* Mobile Menu Button */} <button className="md:hidden p-2" onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)} aria-expanded={isMobileMenuOpen} aria-label="메뉴 열기" > <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={ isMobileMenuOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16" } /> </svg> </button> </div> {/* Mobile Navigation */} {isMobileMenuOpen && ( <div className="md:hidden pb-4"> {navItems.map((item) => ( <Link key={item.href} href={item.href} className={`block px-3 py-2 rounded-md text-base font-medium ${ pathname === item.href ? "bg-blue-100 text-blue-700" : "text-gray-700 hover:bg-gray-100" }`} onClick={() => setIsMobileMenuOpen(false)} > {item.label} </Link> ))} </div> )} </nav> </header> ); }

2. 브레드크럼

현재 위치를 표시하는 브레드크럼 패턴입니다.

"use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; interface BreadcrumbItem { label: string; href: string; } export default function Breadcrumb() { const pathname = usePathname(); // 경로를 브레드크럼 아이템으로 변환 const generateBreadcrumbs = (): BreadcrumbItem[] => { const segments = pathname.split("/").filter(Boolean); const breadcrumbs: BreadcrumbItem[] = [{ label: "홈", href: "/" }]; let currentPath = ""; segments.forEach((segment, index) => { currentPath += `/${segment}`; // URL을 사람이 읽을 수 있는 형태로 변환 const label = segment .split("-") .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(" "); breadcrumbs.push({ label, href: currentPath, }); }); return breadcrumbs; }; const breadcrumbs = generateBreadcrumbs(); return ( <nav aria-label="브레드크럼" className="py-4"> <ol className="flex items-center space-x-2 text-sm"> {breadcrumbs.map((item, index) => { const isLast = index === breadcrumbs.length - 1; return ( <li key={item.href} className="flex items-center"> {index > 0 && ( <svg className="w-4 h-4 text-gray-400 mx-2" fill="currentColor" viewBox="0 0 20 20" > <path d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" /> </svg> )} {isLast ? ( <span className="font-medium text-gray-900" aria-current="page"> {item.label} </span> ) : ( <Link href={item.href} className="text-gray-600 hover:text-gray-900 hover:underline" > {item.label} </Link> )} </li> ); })} </ol> </nav> ); }

고급 패턴

3. 사이드바 네비게이션

관리자 대시보드용 사이드바 패턴입니다.

"use client"; import { useState } from "react"; import Link from "next/link"; import { usePathname } from "next/navigation"; interface NavItem { label: string; href: string; icon: React.ReactNode; children?: NavItem[]; } const navigationItems: NavItem[] = [ { label: "대시보드", href: "/dashboard", icon: <HomeIcon />, }, { label: "사용자", href: "/users", icon: <UsersIcon />, children: [ { label: "전체 사용자", href: "/users", icon: null }, { label: "새 사용자 추가", href: "/users/new", icon: null }, ], }, { label: "설정", href: "/settings", icon: <SettingsIcon />, }, ]; export default function SidebarNavigation() { const pathname = usePathname(); const [isOpen, setIsOpen] = useState(true); const [expandedItems, setExpandedItems] = useState<string[]>([]); const toggleExpand = (label: string) => { setExpandedItems((prev) => prev.includes(label) ? prev.filter((item) => item !== label) : [...prev, label] ); }; const isActive = (href: string) => pathname === href || pathname.startsWith(href + "/"); return ( <aside className={`bg-gray-900 text-white h-screen transition-all duration-300 ${ isOpen ? "w-64" : "w-20" }`} > {/* Toggle Button */} <div className="flex justify-between items-center p-4 border-b border-gray-700"> {isOpen && <h2 className="text-xl font-bold">Menu</h2>} <button onClick={() => setIsOpen(!isOpen)} className="p-2 hover:bg-gray-800 rounded" aria-label={isOpen ? "사이드바 접기" : "사이드바 펼치기"} > <svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={ isOpen ? "M11 19l-7-7 7-7m8 14l-7-7 7-7" : "M13 5l7 7-7 7M5 5l7 7-7 7" } /> </svg> </button> </div> {/* Navigation Items */} <nav className="p-4"> <ul className="space-y-2"> {navigationItems.map((item) => ( <li key={item.label}> <div className="relative"> <Link href={item.href} className={`flex items-center gap-3 p-3 rounded-lg transition-colors ${ isActive(item.href) ? "bg-blue-600 text-white" : "text-gray-300 hover:bg-gray-800" }`} aria-current={isActive(item.href) ? "page" : undefined} > <span className="w-6 h-6">{item.icon}</span> {isOpen && <span className="font-medium">{item.label}</span>} </Link> {/* Expand/Collapse for items with children */} {item.children && isOpen && ( <button onClick={() => toggleExpand(item.label)} className="absolute right-3 top-3" aria-expanded={expandedItems.includes(item.label)} > <svg className={`w-4 h-4 transition-transform ${ expandedItems.includes(item.label) ? "rotate-180" : "" }`} fill="none" viewBox="0 0 24 24" stroke="currentColor" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> </svg> </button> )} </div> {/* Sub-items */} {item.children && isOpen && expandedItems.includes(item.label) && ( <ul className="ml-9 mt-2 space-y-2"> {item.children.map((child) => ( <li key={child.href}> <Link href={child.href} className={`block p-2 rounded text-sm ${ pathname === child.href ? "bg-gray-800 text-white" : "text-gray-400 hover:text-white hover:bg-gray-800" }`} > {child.label} </Link> </li> ))} </ul> )} </li> ))} </ul> </nav> </aside> ); } // Icon Components function HomeIcon() { return ( <svg fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" /> </svg> ); } function UsersIcon() { return ( <svg fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" /> </svg> ); } function SettingsIcon() { return ( <svg fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> </svg> ); }

4. 탭 네비게이션

섹션 전환을 위한 탭 패턴입니다.

"use client"; import { useState } from "react"; interface Tab { id: string; label: string; content: React.ReactNode; } const tabs: Tab[] = [ { id: "profile", label: "프로필", content: <div>프로필 내용</div>, }, { id: "settings", label: "설정", content: <div>설정 내용</div>, }, { id: "notifications", label: "알림", content: <div>알림 내용</div>, }, ]; export default function TabNavigation() { const [activeTab, setActiveTab] = useState(tabs[0].id); const handleKeyDown = (e: React.KeyboardEvent, index: number) => { if (e.key === "ArrowRight") { const nextIndex = (index + 1) % tabs.length; setActiveTab(tabs[nextIndex].id); } else if (e.key === "ArrowLeft") { const prevIndex = (index - 1 + tabs.length) % tabs.length; setActiveTab(tabs[prevIndex].id); } }; return ( <div className="w-full"> {/* Tab List */} <div className="border-b border-gray-200" role="tablist" aria-label="탭 네비게이션" > <div className="flex space-x-8"> {tabs.map((tab, index) => ( <button key={tab.id} role="tab" aria-selected={activeTab === tab.id} aria-controls={`panel-${tab.id}`} id={`tab-${tab.id}`} tabIndex={activeTab === tab.id ? 0 : -1} onClick={() => setActiveTab(tab.id)} onKeyDown={(e) => handleKeyDown(e, index)} className={`py-4 px-1 border-b-2 font-medium text-sm transition-colors ${ activeTab === tab.id ? "border-blue-500 text-blue-600" : "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" }`} > {tab.label} </button> ))} </div> </div> {/* Tab Panels */} {tabs.map((tab) => ( <div key={tab.id} role="tabpanel" id={`panel-${tab.id}`} aria-labelledby={`tab-${tab.id}`} hidden={activeTab !== tab.id} className="py-6" > {tab.content} </div> ))} </div> ); }

5. 메가 메뉴

복잡한 네비게이션을 위한 메가 메뉴 패턴입니다.

"use client"; import { useState } from "react"; import Link from "next/link"; interface MenuItem { label: string; href?: string; description?: string; children?: MenuItem[]; } const megaMenuItems: MenuItem[] = [ { label: "제품", children: [ { label: "Category 1", children: [ { label: "Product 1", href: "/products/1", description: "Description 1", }, { label: "Product 2", href: "/products/2", description: "Description 2", }, ], }, { label: "Category 2", children: [ { label: "Product 3", href: "/products/3", description: "Description 3", }, { label: "Product 4", href: "/products/4", description: "Description 4", }, ], }, ], }, ]; export default function MegaMenu() { const [activeMenu, setActiveMenu] = useState<string | null>(null); return ( <nav className="relative"> <div className="flex space-x-8"> {megaMenuItems.map((item) => ( <div key={item.label} onMouseEnter={() => setActiveMenu(item.label)} onMouseLeave={() => setActiveMenu(null)} className="relative" > <button className="px-3 py-2 text-sm font-medium text-gray-700 hover:text-gray-900" aria-expanded={activeMenu === item.label} aria-haspopup="true" > {item.label} <svg className="inline w-4 h-4 ml-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /> </svg> </button> {/* Mega Menu Dropdown */} {activeMenu === item.label && item.children && ( <div className="absolute left-0 top-full mt-2 w-screen max-w-4xl bg-white shadow-xl rounded-lg p-8 z-50"> <div className="grid grid-cols-2 gap-8"> {item.children.map((category) => ( <div key={category.label}> <h3 className="text-sm font-semibold text-gray-900 mb-4"> {category.label} </h3> <ul className="space-y-3"> {category.children?.map((product) => ( <li key={product.label}> <Link href={product.href || "#"} className="block hover:bg-gray-50 p-3 rounded-lg transition-colors" > <p className="text-sm font-medium text-gray-900"> {product.label} </p> {product.description && ( <p className="text-sm text-gray-500 mt-1"> {product.description} </p> )} </Link> </li> ))} </ul> </div> ))} </div> </div> )} </div> ))} </div> </nav> ); }

Best Practices

✅ 권장 사항

  1. 접근성 우선

    • <nav> 태그와 aria-label 사용
    • aria-current="page" 현재 페이지 표시
    • 키보드 네비게이션 지원 (Tab, Arrow keys)
    • 스크린 리더 지원
  2. 시각적 피드백

    • 활성 링크 하이라이트
    • Hover 상태 명확히 표시
    • 부드러운 전환 애니메이션
  3. 모바일 최적화

    • 햄버거 메뉴 (768px 이하)
    • 터치 친화적 크기 (최소 44x44px)
    • 스와이프 제스처 지원
  4. 성능

    • Next.js Link로 프리페칭
    • 메가 메뉴는 hover 시에만 렌더링
    • 중복 렌더링 방지 (React.memo)
  5. 사용자 경험

    • 일관된 네비게이션 위치
    • 계층 구조 명확히 표시 (브레드크럼)
    • 로고 클릭 시 홈으로

⚠️ 피해야 할 것

  1. 접근성 문제

    • 키보드 네비게이션 미지원
    • 스크린 리더 미지원
    • 색상만으로 상태 구분
  2. UX 문제

    • 너무 많은 메뉴 항목 (7±2 규칙)
    • 깊은 중첩 구조 (3단계 이상)
    • 모바일에서 작은 터치 영역
  3. 성능 문제

    • 페이지 새로고침 (a 태그 대신 Link 사용)
    • 불필요한 애니메이션
    • 큰 메가 메뉴 전체 렌더링

성능 고려사항

Prefetching

// Next.js Link는 자동으로 viewport 내 링크 프리페칭 <Link href="/products" prefetch={true}> 제품 </Link>

Lazy Loading

// 메가 메뉴를 동적 import import dynamic from "next/dynamic"; const MegaMenu = dynamic(() => import("./MegaMenu"), { loading: () => <div>Loading...</div>, });

Foundation 예제

범용 헤더 네비게이션

Foundation 컴포넌트로 구현한 중립적인 헤더입니다.

import Link from "next/link"; import { Button } from "@vortex/ui-foundation"; export default function FoundationNav() { return ( <nav className="bg-white shadow"> <div className="max-w-7xl mx-auto px-4 flex justify-between items-center h-16"> <Link href="/" className="text-xl font-bold"> Logo </Link> <div className="flex gap-6"> <Link href="/products" className="hover:text-blue-600"> 제품 </Link> <Link href="/about" className="hover:text-blue-600"> 소개 </Link> </div> </div> </nav> ); }

전체 예제 보기 →


iCignal 예제

Analytics 대시보드 사이드바

iCignal Blue 브랜드를 적용한 사이드바 네비게이션입니다.

import "@vortex/ui-icignal/theme"; import Link from "next/link"; export default function ISignalSidebar() { return ( <aside className="w-64 bg-gray-900 text-white h-screen"> <div className="p-4 border-b border-gray-700"> <h2 className="text-xl font-bold text-blue-400">iCignal</h2> </div> <nav className="p-4"> <Link href="/dashboard" className="block p-3 rounded bg-blue-600 mb-2"> 대시보드 </Link> <Link href="/reports" className="block p-3 rounded hover:bg-gray-800"> 리포트 </Link> </nav> </aside> ); }

전체 예제 보기 →


Cals 예제

예약 시스템 탭 네비게이션

Cals Pink 브랜드를 적용한 탭 네비게이션입니다.

import "@vortex/ui-cals/theme"; import { useState } from "react"; import { Badge } from "@vortex/ui-cals"; export default function CalsTabs() { const [activeTab, setActiveTab] = useState("pending"); return ( <div className="border-b border-pink-200"> <nav className="flex space-x-8"> <button onClick={() => setActiveTab("pending")} className={`py-4 px-1 border-b-2 ${ activeTab === "pending" ? "border-pink-500 text-pink-600" : "border-transparent" }`} > 승인 대기 <Badge variant="pending">3</Badge> </button> <button onClick={() => setActiveTab("confirmed")} className={`py-4 px-1 border-b-2 ${ activeTab === "confirmed" ? "border-pink-500 text-pink-600" : "border-transparent" }`} > 예약 확정 <Badge variant="confirmed">12</Badge> </button> </nav> </div> ); }

전체 예제 보기 →


CodeSandbox

CodeSandbox 예제는 곧 제공될 예정입니다.

로컬에서 실행하기

  1. 프로젝트 생성

    npx @vortex/cli init my-nav-project --template next-app cd my-nav-project
  2. 컴포넌트 추가

    # Foundation npx @vortex/cli add button --package foundation # iCignal npx @vortex/cli add button --package icignal # Cals npx @vortex/cli add badge --package cals
  3. 코드 복사 및 실행

    pnpm dev

관련 패턴

Last updated on