导航设计的重要性
导航是用户探索应用的路标系统。良好的导航设计能够:
- 提升可发现性 - 用户快速找到所需功能
- 建立心智模型 - 帮助用户理解应用结构
- 减少认知负担 - 清晰的层级降低决策成本
- 增强用户信心 - 随时知道"我在哪里"
研究表明,超过 50% 的用户会在导航不清晰时直接离开网站。本文将详解各类导航组件的设计与实现。
导航类型与适用场景
导航类型对比
┌─────────────────────────────────────────────────────────────────┐
│ 导航类型选择矩阵 │
├──────────────┬──────────────┬──────────────┬───────────────────┤
│ 类型 │ 适用场景 │ 优点 │ 缺点 │
├──────────────┼──────────────┼──────────────┼───────────────────┤
│ 顶部导航 │ 5-7个主要项 │ 可见性高 │ 空间有限 │
│ 侧边导航 │ 多级深层结构 │ 可容纳更多项 │ 占用水平空间 │
│ 底部导航 │ 移动端核心功能 │ 易于触达 │ 仅限少量入口 │
│ 汉堡菜单 │ 空间受限场景 │ 节省空间 │ 可发现性低 │
│ 标签页 │ 并列内容切换 │ 直观清晰 │ 数量受限 │
└──────────────┴──────────────┴──────────────┴───────────────────┘
选择决策树
开始
│
├─ 导航项 ≤ 5 个?
│ ├─ 是 → 移动端核心功能?
│ │ ├─ 是 → 底部导航
│ │ └─ 否 → 顶部导航
│ │
│ └─ 否 → 有多级结构?
│ ├─ 是 → 侧边导航
│ └─ 否 → 顶部导航 + 下拉菜单
顶部导航栏组件
基础导航栏
<!-- components/navigation/Navbar.vue -->
<script setup lang="ts">
interface NavItem {
label: string
href?: string
to?: string
children?: NavItem[]
icon?: string
}
interface Props {
items: NavItem[]
logo?: string
logoAlt?: string
sticky?: boolean
transparent?: boolean
}
const props = withDefaults(defineProps<Props>(), {
sticky: true,
transparent: false,
})
const route = useRoute()
const isScrolled = ref(false)
const isMobileMenuOpen = ref(false)
// 滚动检测
onMounted(() => {
if (props.sticky) {
window.addEventListener('scroll', handleScroll)
}
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
})
const handleScroll = () => {
isScrolled.value = window.scrollY > 20
}
// 检查是否为当前路由
const isActive = (item: NavItem): boolean => {
if (item.to) {
return route.path === item.to || route.path.startsWith(item.to + '/')
}
return false
}
// 切换移动端菜单
const toggleMobileMenu = () => {
isMobileMenuOpen.value = !isMobileMenuOpen.value
// 防止背景滚动
document.body.style.overflow = isMobileMenuOpen.value ? 'hidden' : ''
}
// 路由变化时关闭菜单
watch(() => route.path, () => {
isMobileMenuOpen.value = false
document.body.style.overflow = ''
})
</script>
<template>
<header
class="navbar"
:class="{
'navbar--sticky': sticky,
'navbar--scrolled': isScrolled,
'navbar--transparent': transparent && !isScrolled,
}"
>
<div class="navbar__container">
<!-- Logo -->
<NuxtLink to="/" class="navbar__logo">
<img v-if="logo" :src="logo" :alt="logoAlt || 'Logo'" />
<slot v-else name="logo">
<span class="navbar__brand">HTMLPAGE</span>
</slot>
</NuxtLink>
<!-- 桌面端导航 -->
<nav class="navbar__nav" role="navigation" aria-label="主导航">
<ul class="navbar__menu">
<li
v-for="item in items"
:key="item.label"
class="navbar__item"
:class="{ 'navbar__item--has-children': item.children }"
>
<!-- 有子菜单的项 -->
<NavDropdown
v-if="item.children"
:item="item"
:is-active="isActive(item)"
/>
<!-- 普通链接 -->
<NuxtLink
v-else-if="item.to"
:to="item.to"
class="navbar__link"
:class="{ 'navbar__link--active': isActive(item) }"
>
<Icon v-if="item.icon" :name="item.icon" />
{{ item.label }}
</NuxtLink>
<!-- 外部链接 -->
<a
v-else-if="item.href"
:href="item.href"
class="navbar__link"
target="_blank"
rel="noopener noreferrer"
>
<Icon v-if="item.icon" :name="item.icon" />
{{ item.label }}
<Icon name="external-link" class="navbar__external-icon" />
</a>
</li>
</ul>
</nav>
<!-- 右侧操作区 -->
<div class="navbar__actions">
<slot name="actions" />
<!-- 移动端菜单按钮 -->
<button
class="navbar__toggle"
:aria-expanded="isMobileMenuOpen"
aria-controls="mobile-menu"
aria-label="打开菜单"
@click="toggleMobileMenu"
>
<span class="navbar__toggle-icon" :class="{ 'is-open': isMobileMenuOpen }">
<span></span>
<span></span>
<span></span>
</span>
</button>
</div>
</div>
<!-- 移动端菜单 -->
<Teleport to="body">
<Transition name="mobile-menu">
<div
v-if="isMobileMenuOpen"
id="mobile-menu"
class="navbar__mobile-menu"
>
<div class="navbar__mobile-overlay" @click="toggleMobileMenu" />
<nav class="navbar__mobile-nav" role="navigation">
<ul class="navbar__mobile-list">
<li v-for="item in items" :key="item.label">
<MobileNavItem :item="item" @close="toggleMobileMenu" />
</li>
</ul>
<div class="navbar__mobile-actions">
<slot name="mobile-actions" />
</div>
</nav>
</div>
</Transition>
</Teleport>
</header>
</template>
<style scoped>
.navbar {
position: relative;
width: 100%;
height: var(--navbar-height, 64px);
background: var(--color-bg-primary);
border-bottom: 1px solid var(--color-border-light);
z-index: 100;
}
.navbar--sticky {
position: sticky;
top: 0;
}
.navbar--scrolled {
box-shadow: var(--shadow-sm);
}
.navbar--transparent {
background: transparent;
border-bottom: none;
}
.navbar__container {
display: flex;
align-items: center;
justify-content: space-between;
max-width: var(--container-max-width, 1280px);
height: 100%;
margin: 0 auto;
padding: 0 24px;
}
/* Logo */
.navbar__logo {
display: flex;
align-items: center;
text-decoration: none;
}
.navbar__logo img {
height: 32px;
width: auto;
}
.navbar__brand {
font-size: 20px;
font-weight: 700;
color: var(--color-text-primary);
}
/* 桌面导航 */
.navbar__nav {
display: none;
}
@media (min-width: 768px) {
.navbar__nav {
display: block;
}
}
.navbar__menu {
display: flex;
align-items: center;
gap: 8px;
list-style: none;
margin: 0;
padding: 0;
}
.navbar__link {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
color: var(--color-text-secondary);
text-decoration: none;
border-radius: var(--radius-md);
transition: all 0.2s ease;
}
.navbar__link:hover {
color: var(--color-text-primary);
background: var(--color-bg-secondary);
}
.navbar__link--active {
color: var(--color-primary);
background: var(--color-primary-light);
}
.navbar__external-icon {
width: 12px;
height: 12px;
opacity: 0.5;
}
/* 移动端菜单按钮 */
.navbar__toggle {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
}
@media (min-width: 768px) {
.navbar__toggle {
display: none;
}
}
.navbar__toggle-icon {
position: relative;
width: 24px;
height: 18px;
}
.navbar__toggle-icon span {
position: absolute;
left: 0;
width: 100%;
height: 2px;
background: var(--color-text-primary);
border-radius: 1px;
transition: all 0.3s ease;
}
.navbar__toggle-icon span:nth-child(1) { top: 0; }
.navbar__toggle-icon span:nth-child(2) { top: 50%; transform: translateY(-50%); }
.navbar__toggle-icon span:nth-child(3) { bottom: 0; }
.navbar__toggle-icon.is-open span:nth-child(1) {
top: 50%;
transform: translateY(-50%) rotate(45deg);
}
.navbar__toggle-icon.is-open span:nth-child(2) {
opacity: 0;
}
.navbar__toggle-icon.is-open span:nth-child(3) {
bottom: 50%;
transform: translateY(50%) rotate(-45deg);
}
/* 移动端菜单 */
.navbar__mobile-menu {
position: fixed;
inset: 0;
z-index: 1000;
}
.navbar__mobile-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
}
.navbar__mobile-nav {
position: absolute;
top: 0;
right: 0;
width: min(320px, 80vw);
height: 100%;
background: var(--color-bg-primary);
padding: 80px 24px 24px;
overflow-y: auto;
}
.navbar__mobile-list {
list-style: none;
margin: 0;
padding: 0;
}
/* 动画 */
.mobile-menu-enter-active,
.mobile-menu-leave-active {
transition: all 0.3s ease;
}
.mobile-menu-enter-active .navbar__mobile-nav,
.mobile-menu-leave-active .navbar__mobile-nav {
transition: transform 0.3s ease;
}
.mobile-menu-enter-from,
.mobile-menu-leave-to {
opacity: 0;
}
.mobile-menu-enter-from .navbar__mobile-nav,
.mobile-menu-leave-to .navbar__mobile-nav {
transform: translateX(100%);
}
</style>
下拉菜单组件
<!-- components/navigation/NavDropdown.vue -->
<script setup lang="ts">
interface NavItem {
label: string
href?: string
to?: string
children?: NavItem[]
icon?: string
description?: string
}
interface Props {
item: NavItem
isActive?: boolean
}
const props = defineProps<Props>()
const isOpen = ref(false)
const dropdownRef = ref<HTMLElement>()
let closeTimeout: ReturnType<typeof setTimeout>
const handleMouseEnter = () => {
clearTimeout(closeTimeout)
isOpen.value = true
}
const handleMouseLeave = () => {
closeTimeout = setTimeout(() => {
isOpen.value = false
}, 150)
}
// 点击外部关闭
onClickOutside(dropdownRef, () => {
isOpen.value = false
})
// 键盘支持
const handleKeydown = (e: KeyboardEvent) => {
switch (e.key) {
case 'Enter':
case ' ':
e.preventDefault()
isOpen.value = !isOpen.value
break
case 'Escape':
isOpen.value = false
break
case 'ArrowDown':
e.preventDefault()
if (!isOpen.value) {
isOpen.value = true
}
break
}
}
</script>
<template>
<div
ref="dropdownRef"
class="nav-dropdown"
:class="{ 'nav-dropdown--open': isOpen }"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<!-- 触发器 -->
<button
class="nav-dropdown__trigger"
:class="{ 'nav-dropdown__trigger--active': isActive }"
:aria-expanded="isOpen"
:aria-haspopup="true"
@keydown="handleKeydown"
>
<Icon v-if="item.icon" :name="item.icon" />
{{ item.label }}
<Icon
name="chevron-down"
class="nav-dropdown__arrow"
:class="{ 'nav-dropdown__arrow--rotated': isOpen }"
/>
</button>
<!-- 下拉面板 -->
<Transition name="dropdown">
<div v-if="isOpen" class="nav-dropdown__panel">
<ul class="nav-dropdown__list" role="menu">
<li
v-for="child in item.children"
:key="child.label"
role="menuitem"
>
<NuxtLink
v-if="child.to"
:to="child.to"
class="nav-dropdown__item"
@click="isOpen = false"
>
<Icon v-if="child.icon" :name="child.icon" class="nav-dropdown__item-icon" />
<div class="nav-dropdown__item-content">
<span class="nav-dropdown__item-label">{{ child.label }}</span>
<span v-if="child.description" class="nav-dropdown__item-desc">
{{ child.description }}
</span>
</div>
</NuxtLink>
<a
v-else-if="child.href"
:href="child.href"
class="nav-dropdown__item"
target="_blank"
rel="noopener noreferrer"
>
<Icon v-if="child.icon" :name="child.icon" class="nav-dropdown__item-icon" />
<div class="nav-dropdown__item-content">
<span class="nav-dropdown__item-label">{{ child.label }}</span>
<span v-if="child.description" class="nav-dropdown__item-desc">
{{ child.description }}
</span>
</div>
<Icon name="external-link" class="nav-dropdown__external" />
</a>
</li>
</ul>
</div>
</Transition>
</div>
</template>
<style scoped>
.nav-dropdown {
position: relative;
}
.nav-dropdown__trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
color: var(--color-text-secondary);
background: transparent;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.2s ease;
}
.nav-dropdown__trigger:hover,
.nav-dropdown--open .nav-dropdown__trigger {
color: var(--color-text-primary);
background: var(--color-bg-secondary);
}
.nav-dropdown__trigger--active {
color: var(--color-primary);
}
.nav-dropdown__arrow {
width: 16px;
height: 16px;
transition: transform 0.2s ease;
}
.nav-dropdown__arrow--rotated {
transform: rotate(180deg);
}
.nav-dropdown__panel {
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
min-width: 240px;
margin-top: 8px;
background: var(--color-bg-primary);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
overflow: hidden;
z-index: 100;
}
.nav-dropdown__list {
list-style: none;
margin: 0;
padding: 8px;
}
.nav-dropdown__item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
text-decoration: none;
border-radius: var(--radius-md);
transition: background 0.15s ease;
}
.nav-dropdown__item:hover {
background: var(--color-bg-secondary);
}
.nav-dropdown__item-icon {
width: 20px;
height: 20px;
color: var(--color-primary);
flex-shrink: 0;
margin-top: 2px;
}
.nav-dropdown__item-content {
flex: 1;
}
.nav-dropdown__item-label {
display: block;
font-size: 14px;
font-weight: 500;
color: var(--color-text-primary);
}
.nav-dropdown__item-desc {
display: block;
font-size: 12px;
color: var(--color-text-secondary);
margin-top: 2px;
}
.nav-dropdown__external {
width: 14px;
height: 14px;
color: var(--color-text-tertiary);
flex-shrink: 0;
}
/* 动画 */
.dropdown-enter-active,
.dropdown-leave-active {
transition: all 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(-8px);
}
</style>
侧边导航组件
侧边栏导航
<!-- components/navigation/Sidebar.vue -->
<script setup lang="ts">
interface NavItem {
label: string
to?: string
icon?: string
badge?: string | number
children?: NavItem[]
}
interface Props {
items: NavItem[]
collapsed?: boolean
width?: number
collapsedWidth?: number
}
const props = withDefaults(defineProps<Props>(), {
collapsed: false,
width: 256,
collapsedWidth: 64,
})
const emit = defineEmits<{
'update:collapsed': [value: boolean]
}>()
const route = useRoute()
const expandedItems = ref<Set<string>>(new Set())
// 当前宽度
const currentWidth = computed(() =>
props.collapsed ? props.collapsedWidth : props.width
)
// 检查是否激活
const isActive = (item: NavItem): boolean => {
if (item.to) {
return route.path === item.to
}
return false
}
// 检查是否有激活的子项
const hasActiveChild = (item: NavItem): boolean => {
if (!item.children) return false
return item.children.some(child =>
isActive(child) || hasActiveChild(child)
)
}
// 切换展开
const toggleExpand = (label: string) => {
if (expandedItems.value.has(label)) {
expandedItems.value.delete(label)
} else {
expandedItems.value.add(label)
}
}
// 检查是否展开
const isExpanded = (label: string): boolean => {
return expandedItems.value.has(label)
}
// 自动展开包含激活项的父级
watchEffect(() => {
props.items.forEach(item => {
if (item.children && hasActiveChild(item)) {
expandedItems.value.add(item.label)
}
})
})
// 切换收起状态
const toggleCollapse = () => {
emit('update:collapsed', !props.collapsed)
}
</script>
<template>
<aside
class="sidebar"
:class="{ 'sidebar--collapsed': collapsed }"
:style="{ width: `${currentWidth}px` }"
>
<!-- 导航列表 -->
<nav class="sidebar__nav" role="navigation">
<ul class="sidebar__list">
<li v-for="item in items" :key="item.label" class="sidebar__item">
<!-- 有子菜单 -->
<template v-if="item.children">
<button
class="sidebar__link sidebar__link--expandable"
:class="{ 'sidebar__link--expanded': isExpanded(item.label) }"
:aria-expanded="isExpanded(item.label)"
@click="toggleExpand(item.label)"
>
<Icon v-if="item.icon" :name="item.icon" class="sidebar__icon" />
<span v-if="!collapsed" class="sidebar__label">{{ item.label }}</span>
<Icon
v-if="!collapsed"
name="chevron-right"
class="sidebar__arrow"
:class="{ 'sidebar__arrow--rotated': isExpanded(item.label) }"
/>
</button>
<!-- 子菜单 -->
<Transition name="expand">
<ul
v-if="isExpanded(item.label) && !collapsed"
class="sidebar__submenu"
>
<li v-for="child in item.children" :key="child.label">
<NuxtLink
:to="child.to"
class="sidebar__sublink"
:class="{ 'sidebar__sublink--active': isActive(child) }"
>
<Icon v-if="child.icon" :name="child.icon" class="sidebar__subicon" />
<span class="sidebar__sublabel">{{ child.label }}</span>
<span v-if="child.badge" class="sidebar__badge">{{ child.badge }}</span>
</NuxtLink>
</li>
</ul>
</Transition>
</template>
<!-- 无子菜单 -->
<NuxtLink
v-else
:to="item.to"
class="sidebar__link"
:class="{ 'sidebar__link--active': isActive(item) }"
>
<Icon v-if="item.icon" :name="item.icon" class="sidebar__icon" />
<span v-if="!collapsed" class="sidebar__label">{{ item.label }}</span>
<span v-if="!collapsed && item.badge" class="sidebar__badge">
{{ item.badge }}
</span>
</NuxtLink>
</li>
</ul>
</nav>
<!-- 收起按钮 -->
<button
class="sidebar__toggle"
:aria-label="collapsed ? '展开侧边栏' : '收起侧边栏'"
@click="toggleCollapse"
>
<Icon
name="chevrons-left"
class="sidebar__toggle-icon"
:class="{ 'sidebar__toggle-icon--rotated': collapsed }"
/>
</button>
</aside>
</template>
<style scoped>
.sidebar {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--color-bg-primary);
border-right: 1px solid var(--color-border-light);
transition: width 0.3s ease;
overflow: hidden;
}
.sidebar--collapsed {
width: 64px;
}
.sidebar__nav {
flex: 1;
overflow-y: auto;
padding: 16px 12px;
}
.sidebar__list {
list-style: none;
margin: 0;
padding: 0;
}
.sidebar__item {
margin-bottom: 4px;
}
.sidebar__link {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 10px 12px;
font-size: 14px;
font-weight: 500;
color: var(--color-text-secondary);
text-decoration: none;
background: transparent;
border: none;
border-radius: var(--radius-md);
cursor: pointer;
transition: all 0.15s ease;
}
.sidebar__link:hover {
color: var(--color-text-primary);
background: var(--color-bg-secondary);
}
.sidebar__link--active {
color: var(--color-primary);
background: var(--color-primary-light);
}
.sidebar__icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.sidebar__label {
flex: 1;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sidebar__arrow {
width: 16px;
height: 16px;
transition: transform 0.2s ease;
}
.sidebar__arrow--rotated {
transform: rotate(90deg);
}
.sidebar__badge {
padding: 2px 8px;
font-size: 12px;
font-weight: 500;
color: var(--color-primary);
background: var(--color-primary-light);
border-radius: 9999px;
}
/* 子菜单 */
.sidebar__submenu {
list-style: none;
margin: 4px 0 0;
padding: 0 0 0 32px;
}
.sidebar__sublink {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
font-size: 13px;
color: var(--color-text-secondary);
text-decoration: none;
border-radius: var(--radius-md);
transition: all 0.15s ease;
}
.sidebar__sublink:hover {
color: var(--color-text-primary);
background: var(--color-bg-secondary);
}
.sidebar__sublink--active {
color: var(--color-primary);
font-weight: 500;
}
.sidebar__subicon {
width: 16px;
height: 16px;
}
/* 收起按钮 */
.sidebar__toggle {
display: flex;
align-items: center;
justify-content: center;
height: 48px;
border: none;
border-top: 1px solid var(--color-border-light);
background: transparent;
cursor: pointer;
transition: background 0.15s ease;
}
.sidebar__toggle:hover {
background: var(--color-bg-secondary);
}
.sidebar__toggle-icon {
width: 20px;
height: 20px;
color: var(--color-text-secondary);
transition: transform 0.3s ease;
}
.sidebar__toggle-icon--rotated {
transform: rotate(180deg);
}
/* 展开动画 */
.expand-enter-active,
.expand-leave-active {
transition: all 0.3s ease;
overflow: hidden;
}
.expand-enter-from,
.expand-leave-to {
opacity: 0;
max-height: 0;
}
.expand-enter-to,
.expand-leave-from {
max-height: 500px;
}
</style>
面包屑导航
面包屑组件
<!-- components/navigation/Breadcrumb.vue -->
<script setup lang="ts">
interface BreadcrumbItem {
label: string
to?: string
icon?: string
}
interface Props {
items: BreadcrumbItem[]
separator?: string
showHome?: boolean
homeIcon?: string
maxItems?: number
}
const props = withDefaults(defineProps<Props>(), {
separator: '/',
showHome: true,
homeIcon: 'home',
maxItems: 0,
})
// 处理显示项
const displayItems = computed(() => {
let items = props.items
// 添加首页
if (props.showHome) {
items = [{ label: '首页', to: '/', icon: props.homeIcon }, ...items]
}
// 截断处理
if (props.maxItems > 0 && items.length > props.maxItems) {
const firstItem = items[0]
const lastItems = items.slice(-props.maxItems + 1)
return [firstItem, { label: '...', collapsed: true }, ...lastItems]
}
return items
})
</script>
<template>
<nav class="breadcrumb" aria-label="面包屑导航">
<ol class="breadcrumb__list" itemscope itemtype="https://schema.org/BreadcrumbList">
<li
v-for="(item, index) in displayItems"
:key="index"
class="breadcrumb__item"
itemprop="itemListElement"
itemscope
itemtype="https://schema.org/ListItem"
>
<!-- 分隔符 -->
<span
v-if="index > 0"
class="breadcrumb__separator"
aria-hidden="true"
>
<slot name="separator">
{{ separator }}
</slot>
</span>
<!-- 折叠项 -->
<span v-if="item.collapsed" class="breadcrumb__collapsed">
...
</span>
<!-- 链接项 -->
<NuxtLink
v-else-if="item.to && index < displayItems.length - 1"
:to="item.to"
class="breadcrumb__link"
itemprop="item"
>
<Icon v-if="item.icon" :name="item.icon" class="breadcrumb__icon" />
<span itemprop="name">{{ item.label }}</span>
</NuxtLink>
<!-- 当前项(最后一项) -->
<span
v-else
class="breadcrumb__current"
aria-current="page"
itemprop="name"
>
<Icon v-if="item.icon" :name="item.icon" class="breadcrumb__icon" />
{{ item.label }}
</span>
<meta itemprop="position" :content="String(index + 1)" />
</li>
</ol>
</nav>
</template>
<style scoped>
.breadcrumb {
padding: 12px 0;
}
.breadcrumb__list {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 4px;
list-style: none;
margin: 0;
padding: 0;
}
.breadcrumb__item {
display: flex;
align-items: center;
gap: 4px;
}
.breadcrumb__separator {
color: var(--color-text-tertiary);
margin: 0 4px;
}
.breadcrumb__link {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
color: var(--color-text-secondary);
text-decoration: none;
transition: color 0.15s ease;
}
.breadcrumb__link:hover {
color: var(--color-primary);
}
.breadcrumb__current {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
font-weight: 500;
color: var(--color-text-primary);
}
.breadcrumb__icon {
width: 16px;
height: 16px;
}
.breadcrumb__collapsed {
color: var(--color-text-tertiary);
}
</style>
自动生成面包屑
// composables/useBreadcrumb.ts
interface BreadcrumbItem {
label: string
to: string
}
export function useBreadcrumb() {
const route = useRoute()
// 路由名称映射
const routeLabels: Record<string, string> = {
'topics': '主题',
'seo': 'SEO优化',
'performance': '性能优化',
'nuxt': 'Nuxt开发',
'frontend': '前端开发',
'design': '设计系统',
}
// 根据路由生成面包屑
const breadcrumbs = computed<BreadcrumbItem[]>(() => {
const paths = route.path.split('/').filter(Boolean)
const items: BreadcrumbItem[] = []
let currentPath = ''
for (const segment of paths) {
currentPath += `/${segment}`
// 获取标签
const label = routeLabels[segment] ||
route.meta.title as string ||
segment.replace(/-/g, ' ')
items.push({
label,
to: currentPath,
})
}
return items
})
return {
breadcrumbs,
}
}
标签页导航
标签页组件
<!-- components/navigation/Tabs.vue -->
<script setup lang="ts">
interface TabItem {
key: string
label: string
icon?: string
disabled?: boolean
badge?: string | number
}
interface Props {
items: TabItem[]
modelValue?: string
variant?: 'line' | 'card' | 'pill'
size?: 'sm' | 'md' | 'lg'
}
const props = withDefaults(defineProps<Props>(), {
variant: 'line',
size: 'md',
})
const emit = defineEmits<{
'update:modelValue': [value: string]
'change': [value: string]
}>()
const activeKey = computed({
get: () => props.modelValue || props.items[0]?.key,
set: (val) => {
emit('update:modelValue', val)
emit('change', val)
},
})
const indicatorStyle = ref({})
const tabRefs = ref<HTMLElement[]>([])
// 更新指示器位置
const updateIndicator = () => {
const activeIndex = props.items.findIndex(item => item.key === activeKey.value)
const activeTab = tabRefs.value[activeIndex]
if (activeTab && props.variant === 'line') {
indicatorStyle.value = {
width: `${activeTab.offsetWidth}px`,
transform: `translateX(${activeTab.offsetLeft}px)`,
}
}
}
// 监听变化更新指示器
watch([activeKey, () => props.items], () => {
nextTick(updateIndicator)
})
onMounted(() => {
nextTick(updateIndicator)
})
</script>
<template>
<div
class="tabs"
:class="[`tabs--${variant}`, `tabs--${size}`]"
>
<div class="tabs__nav" role="tablist">
<button
v-for="(item, index) in items"
:key="item.key"
:ref="(el) => tabRefs[index] = el as HTMLElement"
class="tabs__tab"
:class="{
'tabs__tab--active': item.key === activeKey,
'tabs__tab--disabled': item.disabled,
}"
role="tab"
:aria-selected="item.key === activeKey"
:aria-disabled="item.disabled"
:tabindex="item.disabled ? -1 : 0"
@click="!item.disabled && (activeKey = item.key)"
>
<Icon v-if="item.icon" :name="item.icon" class="tabs__icon" />
<span class="tabs__label">{{ item.label }}</span>
<span v-if="item.badge" class="tabs__badge">{{ item.badge }}</span>
</button>
<!-- 滑动指示器(线条变体) -->
<div
v-if="variant === 'line'"
class="tabs__indicator"
:style="indicatorStyle"
/>
</div>
<!-- 内容区域 -->
<div class="tabs__content" role="tabpanel">
<slot :active-key="activeKey" />
</div>
</div>
</template>
<style scoped>
.tabs {
width: 100%;
}
.tabs__nav {
position: relative;
display: flex;
gap: 4px;
}
/* 线条变体 */
.tabs--line .tabs__nav {
border-bottom: 1px solid var(--color-border-light);
}
.tabs--line .tabs__tab {
padding: 12px 16px;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tabs--line .tabs__tab--active {
color: var(--color-primary);
}
.tabs--line .tabs__indicator {
position: absolute;
bottom: 0;
height: 2px;
background: var(--color-primary);
transition: all 0.3s ease;
}
/* 卡片变体 */
.tabs--card .tabs__nav {
background: var(--color-bg-secondary);
padding: 4px;
border-radius: var(--radius-lg);
}
.tabs--card .tabs__tab {
padding: 8px 16px;
background: transparent;
border: none;
border-radius: var(--radius-md);
}
.tabs--card .tabs__tab--active {
background: var(--color-bg-primary);
box-shadow: var(--shadow-sm);
}
/* 药片变体 */
.tabs--pill .tabs__tab {
padding: 8px 20px;
background: transparent;
border: 1px solid var(--color-border);
border-radius: 9999px;
}
.tabs--pill .tabs__tab--active {
background: var(--color-primary);
border-color: var(--color-primary);
color: white;
}
/* 通用样式 */
.tabs__tab {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 500;
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.2s ease;
}
.tabs__tab:hover:not(.tabs__tab--disabled) {
color: var(--color-text-primary);
}
.tabs__tab--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.tabs__icon {
width: 16px;
height: 16px;
}
.tabs__badge {
padding: 2px 6px;
font-size: 11px;
background: var(--color-bg-tertiary);
border-radius: 9999px;
}
.tabs__tab--active .tabs__badge {
background: var(--color-primary-light);
color: var(--color-primary);
}
/* 尺寸变体 */
.tabs--sm .tabs__tab {
padding: 8px 12px;
font-size: 13px;
}
.tabs--lg .tabs__tab {
padding: 14px 20px;
font-size: 15px;
}
.tabs__content {
padding-top: 16px;
}
</style>
移动端底部导航
<!-- components/navigation/BottomNav.vue -->
<script setup lang="ts">
interface NavItem {
key: string
label: string
to: string
icon: string
activeIcon?: string
badge?: string | number
}
interface Props {
items: NavItem[]
}
const props = defineProps<Props>()
const route = useRoute()
const isActive = (item: NavItem): boolean => {
return route.path === item.to || route.path.startsWith(item.to + '/')
}
</script>
<template>
<nav class="bottom-nav" role="navigation" aria-label="底部导航">
<NuxtLink
v-for="item in items"
:key="item.key"
:to="item.to"
class="bottom-nav__item"
:class="{ 'bottom-nav__item--active': isActive(item) }"
>
<div class="bottom-nav__icon-wrapper">
<Icon
:name="isActive(item) && item.activeIcon ? item.activeIcon : item.icon"
class="bottom-nav__icon"
/>
<span v-if="item.badge" class="bottom-nav__badge">
{{ item.badge }}
</span>
</div>
<span class="bottom-nav__label">{{ item.label }}</span>
</NuxtLink>
</nav>
</template>
<style scoped>
.bottom-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-around;
height: 64px;
background: var(--color-bg-primary);
border-top: 1px solid var(--color-border-light);
padding-bottom: env(safe-area-inset-bottom);
z-index: 100;
}
@media (min-width: 768px) {
.bottom-nav {
display: none;
}
}
.bottom-nav__item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 8px 4px;
text-decoration: none;
color: var(--color-text-secondary);
transition: color 0.2s ease;
}
.bottom-nav__item--active {
color: var(--color-primary);
}
.bottom-nav__icon-wrapper {
position: relative;
}
.bottom-nav__icon {
width: 24px;
height: 24px;
}
.bottom-nav__badge {
position: absolute;
top: -4px;
right: -8px;
min-width: 16px;
height: 16px;
padding: 0 4px;
font-size: 10px;
font-weight: 600;
color: white;
background: var(--color-error);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.bottom-nav__label {
font-size: 11px;
margin-top: 4px;
}
</style>
导航可访问性
键盘导航支持
// composables/useNavigationA11y.ts
export function useNavigationA11y(containerRef: Ref<HTMLElement | undefined>) {
// 获取所有可导航项
const getNavigableItems = (): HTMLElement[] => {
if (!containerRef.value) return []
return Array.from(
containerRef.value.querySelectorAll<HTMLElement>(
'a, button:not([disabled]), [tabindex="0"]'
)
)
}
// 当前焦点索引
const focusIndex = ref(-1)
// 聚焦指定索引
const focusItem = (index: number) => {
const items = getNavigableItems()
if (index >= 0 && index < items.length) {
items[index].focus()
focusIndex.value = index
}
}
// 键盘事件处理
const handleKeydown = (e: KeyboardEvent) => {
const items = getNavigableItems()
const currentIndex = items.indexOf(document.activeElement as HTMLElement)
switch (e.key) {
case 'ArrowDown':
case 'ArrowRight':
e.preventDefault()
focusItem((currentIndex + 1) % items.length)
break
case 'ArrowUp':
case 'ArrowLeft':
e.preventDefault()
focusItem((currentIndex - 1 + items.length) % items.length)
break
case 'Home':
e.preventDefault()
focusItem(0)
break
case 'End':
e.preventDefault()
focusItem(items.length - 1)
break
}
}
// 绑定事件
onMounted(() => {
containerRef.value?.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
containerRef.value?.removeEventListener('keydown', handleKeydown)
})
return {
focusIndex,
focusItem,
}
}
跳过导航链接
<!-- components/navigation/SkipLink.vue -->
<template>
<a href="#main-content" class="skip-link">
跳转到主要内容
</a>
</template>
<style scoped>
.skip-link {
position: fixed;
top: -100%;
left: 50%;
transform: translateX(-50%);
padding: 12px 24px;
background: var(--color-primary);
color: white;
font-weight: 500;
text-decoration: none;
border-radius: var(--radius-md);
z-index: 9999;
transition: top 0.3s ease;
}
.skip-link:focus {
top: 16px;
}
</style>
最佳实践总结
导航设计清单
| 类别 | 最佳实践 |
|---|---|
| 层级 | 最多3层,扁平化结构 |
| 标签 | 简洁明确,2-4个字 |
| 图标 | 统一风格,辅助文字 |
| 状态 | 清晰的激活态反馈 |
| 响应式 | 移动端使用汉堡/底部导航 |
| 可访问 | 支持键盘导航,ARIA标签 |
| 性能 | 延迟加载子菜单 |
组件设计原则
- 一致性 - 全站统一的导航模式
- 可预测 - 用户能预判交互结果
- 反馈性 - 清晰的状态指示
- 灵活性 - 支持多种配置场景
- 可访问 - 完整的键盘和屏幕阅读器支持
总结
优秀的导航系统需要:
- 清晰的信息架构 - 合理的层级和分类
- 灵活的组件设计 - 适应不同场景需求
- 完善的响应式 - 桌面端和移动端无缝切换
- 良好的可访问性 - 所有用户都能顺畅使用
通过本文介绍的组件和模式,你可以构建一套专业、统一、易用的导航系统,提升整体用户体验。


