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
✅ 권장 사항
-
접근성 우선
<nav>태그와aria-label사용aria-current="page"현재 페이지 표시- 키보드 네비게이션 지원 (Tab, Arrow keys)
- 스크린 리더 지원
-
시각적 피드백
- 활성 링크 하이라이트
- Hover 상태 명확히 표시
- 부드러운 전환 애니메이션
-
모바일 최적화
- 햄버거 메뉴 (768px 이하)
- 터치 친화적 크기 (최소 44x44px)
- 스와이프 제스처 지원
-
성능
- Next.js Link로 프리페칭
- 메가 메뉴는 hover 시에만 렌더링
- 중복 렌더링 방지 (React.memo)
-
사용자 경험
- 일관된 네비게이션 위치
- 계층 구조 명확히 표시 (브레드크럼)
- 로고 클릭 시 홈으로
⚠️ 피해야 할 것
-
접근성 문제
- 키보드 네비게이션 미지원
- 스크린 리더 미지원
- 색상만으로 상태 구분
-
UX 문제
- 너무 많은 메뉴 항목 (7±2 규칙)
- 깊은 중첩 구조 (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 예제는 곧 제공될 예정입니다.
로컬에서 실행하기
-
프로젝트 생성
npx @vortex/cli init my-nav-project --template next-app cd my-nav-project -
컴포넌트 추가
# Foundation npx @vortex/cli add button --package foundation # iCignal npx @vortex/cli add button --package icignal # Cals npx @vortex/cli add badge --package cals -
코드 복사 및 실행
pnpm dev
관련 패턴
- Responsive Design - 반응형 네비게이션
- Modal Patterns - 모바일 메뉴 모달
- Accessibility Patterns - 접근 가능한 네비게이션
Last updated on