组件
导航栏 Navbar
适用于页面全局顶部栏与应用级侧边抽屉栏的高可组合性导航组件,支持横向/纵向排版、折叠收起、二级悬浮子菜单及徽标标记。
基础用法
横向模式下的基础导航栏。组合品牌 Logo、主导航链接列表与右侧功能操作。鼠标在同一 NavbarList 内移动时,悬停底块会在链接之间滑动;点击切换页面时,选中底块同样以弹簧动效过渡:
Loading…
安装与引入
通过 CLI 自动添加组件,或手动复制源码至项目中:
pnpm dlx @wui-design/cli@latest add @wui/navbar安装基础依赖与图标库
pnpm add radix-ui motion class-variance-authority lucide-react clsx tailwind-merge复制组件源码到
components/ui/navbar.tsx"use client"
import * as React from "react"
import { HoverCard, Slot } from "radix-ui"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { cn } from "@/lib/utils"
type NavbarOrientation = "horizontal" | "vertical"
const HIGHLIGHT_SPRING = {
type: "spring",
stiffness: 520,
damping: 40,
mass: 0.7,
} as const
const NavbarContext = React.createContext<{
orientation: NavbarOrientation
collapsed: boolean
setCollapsed: (collapsed: boolean) => void
/** Shared-layout id of the active-page indicator, unique per navbar. */
layoutId: string
}>({
orientation: "horizontal",
collapsed: false,
setCollapsed: () => undefined,
layoutId: "wui-navbar",
})
/** Tracks the hovered link of one list so a single highlight can glide between items. */
const NavbarListContext = React.createContext<{
layoutId: string
hovered: string | null
setHovered: (id: string | null) => void
} | null>(null)
const navbarVariants = cva("bg-background text-foreground", {
variants: {
orientation: {
horizontal: "flex h-14 w-full items-center gap-2 border-b px-3",
vertical:
"relative flex h-full min-h-0 w-64 shrink-0 flex-col border-r px-3 py-4 transition-[width] duration-200 data-[collapsed=true]:w-16 motion-reduce:transition-none",
},
},
defaultVariants: { orientation: "horizontal" },
})
export interface NavbarProps
extends React.ComponentProps<"nav">,
VariantProps<typeof navbarVariants> {
/** Layout direction. Use `vertical` for an application-level left rail. @default "horizontal" */
orientation?: NavbarOrientation
/** Controlled compact state for a vertical navbar. */
collapsed?: boolean
/** Initial compact state when uncontrolled. @default false */
defaultCollapsed?: boolean
/** Called when the vertical navbar changes between full and compact widths. */
onCollapsedChange?: (collapsed: boolean) => void
}
/** A composable application navigation region. */
function Navbar({
className,
orientation = "horizontal",
collapsed,
defaultCollapsed = false,
onCollapsedChange,
...props
}: NavbarProps) {
const [internalCollapsed, setInternalCollapsed] =
React.useState(defaultCollapsed)
const resolvedCollapsed =
orientation === "vertical" ? (collapsed ?? internalCollapsed) : false
const layoutId = React.useId()
function setCollapsed(next: boolean) {
if (collapsed === undefined) setInternalCollapsed(next)
onCollapsedChange?.(next)
}
return (
<NavbarContext.Provider
value={{
orientation,
collapsed: resolvedCollapsed,
setCollapsed,
layoutId,
}}
>
<nav
data-slot="navbar"
data-orientation={orientation}
data-collapsed={resolvedCollapsed ? "true" : "false"}
className={cn(navbarVariants({ orientation }), className)}
{...props}
/>
</NavbarContext.Provider>
)
}
function NavbarHeader({
className,
...props
}: React.ComponentProps<"div">) {
const { collapsed, orientation } = React.useContext(NavbarContext)
return (
<div
data-slot="navbar-header"
className={cn(
"flex shrink-0 items-center",
orientation === "vertical" ? "w-full pb-4" : "mr-4",
collapsed && "justify-center",
className
)}
{...props}
/>
)
}
function NavbarBrand({
className,
asChild = false,
...props
}: React.ComponentProps<"a"> & { asChild?: boolean }) {
const Component = asChild ? Slot.Root : "a"
return (
<Component
data-slot="navbar-brand"
className={cn(
"inline-flex items-center gap-2 rounded-md text-sm font-semibold tracking-tight no-underline outline-none hover:no-underline focus-visible:ring-[3px] focus-visible:ring-ring/35",
className
)}
{...props}
/>
)
}
function NavbarContent({
className,
...props
}: React.ComponentProps<"div">) {
const { orientation } = React.useContext(NavbarContext)
return (
<div
data-slot="navbar-content"
className={cn(
"flex min-w-0 flex-1",
orientation === "vertical"
? "w-full flex-col gap-5 overflow-y-auto"
: "items-center gap-1",
className
)}
{...props}
/>
)
}
function NavbarGroup({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="navbar-group"
className={cn("min-w-0", className)}
{...props}
/>
)
}
function NavbarLabel({
className,
...props
}: React.ComponentProps<"div">) {
const { collapsed, orientation } = React.useContext(NavbarContext)
if (orientation !== "vertical") return null
return (
<div
data-slot="navbar-label"
className={cn(
"mb-1 px-3 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground",
collapsed && "sr-only",
className
)}
{...props}
/>
)
}
function NavbarList({
className,
onPointerLeave,
...props
}: React.ComponentProps<"ul">) {
const { orientation } = React.useContext(NavbarContext)
const layoutId = React.useId()
const [hovered, setHovered] = React.useState<string | null>(null)
return (
<NavbarListContext.Provider value={{ layoutId, hovered, setHovered }}>
<ul
data-slot="navbar-list"
className={cn(
"m-0 flex min-w-0 list-none p-0 [&>li]:m-0",
orientation === "vertical"
? "w-full flex-col gap-0.5"
: "items-center gap-1",
className
)}
onPointerLeave={(event) => {
setHovered(null)
onPointerLeave?.(event)
}}
{...props}
/>
</NavbarListContext.Provider>
)
}
function NavbarItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="navbar-item"
className={cn("min-w-0", className)}
{...props}
/>
)
}
export interface NavbarLinkProps extends React.ComponentProps<"a"> {
/** Mark the destination as the current page. */
active?: boolean
/** Render as the single child element, such as a router Link or button. */
asChild?: boolean
}
function NavbarLink({
className,
active = false,
asChild = false,
children,
onPointerEnter,
...props
}: NavbarLinkProps) {
const { collapsed, orientation, layoutId } = React.useContext(NavbarContext)
const list = React.useContext(NavbarListContext)
const reduceMotion = useReducedMotion()
const itemId = React.useId()
const Component = asChild ? Slot.Root : "a"
const vertical = orientation === "vertical"
const transition = reduceMotion ? { duration: 0 } : HIGHLIGHT_SPRING
return (
<Component
data-slot="navbar-link"
data-active={active ? "true" : "false"}
aria-current={active ? "page" : undefined}
className={cn(
"relative isolate flex h-9 min-w-0 items-center gap-2.5 rounded-md px-3 text-sm font-medium text-muted-foreground no-underline outline-none transition-colors duration-200 hover:text-foreground hover:no-underline focus-visible:ring-[3px] focus-visible:ring-ring/35 data-[active=true]:text-foreground [&_svg]:size-4 [&_svg]:shrink-0",
// Links outside a NavbarList (e.g. icon actions) fall back to a static hover surface.
!list && "hover:bg-accent",
vertical && "w-full",
collapsed && "justify-center px-0",
className
)}
onPointerEnter={(event: React.PointerEvent<HTMLAnchorElement>) => {
list?.setHovered(itemId)
onPointerEnter?.(event)
}}
{...props}
>
<AnimatePresence>
{list && list.hovered === itemId ? (
<motion.span
key="hover"
aria-hidden
data-slot="navbar-hover-highlight"
layoutId={`${list.layoutId}-hover`}
className="absolute inset-0 -z-10 rounded-md bg-accent/60"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ ...transition, opacity: { duration: reduceMotion ? 0 : 0.15 } }}
/>
) : null}
</AnimatePresence>
{active ? (
<motion.span
aria-hidden
data-slot="navbar-active-indicator"
layoutId={`${layoutId}-active`}
className="absolute inset-0 -z-10 rounded-md bg-accent"
transition={transition}
>
{vertical && !collapsed ? (
<span className="absolute inset-y-2.5 -left-px w-0.5 rounded-full bg-primary" />
) : null}
</motion.span>
) : null}
<Slot.Slottable>{children}</Slot.Slottable>
</Component>
)
}
function NavbarBadge({
className,
...props
}: React.ComponentProps<"span">) {
const { collapsed } = React.useContext(NavbarContext)
return (
<span
data-slot="navbar-badge"
className={cn(
"ml-auto inline-flex min-w-5 items-center justify-center rounded-full bg-muted px-1.5 text-[10px] font-semibold tabular-nums text-muted-foreground",
collapsed && "hidden",
className
)}
{...props}
/>
)
}
function NavbarFooter({
className,
...props
}: React.ComponentProps<"div">) {
const { orientation } = React.useContext(NavbarContext)
return (
<div
data-slot="navbar-footer"
className={cn(
"flex shrink-0 items-center",
orientation === "vertical"
? "mt-auto w-full border-t pt-3"
: "ml-auto",
className
)}
{...props}
/>
)
}
function NavbarSeparator({
className,
...props
}: React.ComponentProps<"div">) {
const { orientation } = React.useContext(NavbarContext)
return (
<div
data-slot="navbar-separator"
role="separator"
aria-orientation={orientation === "vertical" ? "horizontal" : "vertical"}
className={cn(
"shrink-0 bg-border",
orientation === "vertical" ? "my-3 h-px w-full" : "mx-2 h-5 w-px",
className
)}
{...props}
/>
)
}
function NavbarBrandLabel({
className,
...props
}: React.ComponentProps<"span">) {
const { collapsed } = React.useContext(NavbarContext)
return (
<span
data-slot="navbar-brand-label"
className={cn(collapsed && "sr-only", className)}
{...props}
/>
)
}
function NavbarLinkLabel({
className,
...props
}: React.ComponentProps<"span">) {
const { collapsed } = React.useContext(NavbarContext)
return (
<span
data-slot="navbar-link-label"
className={cn("min-w-0 truncate", collapsed && "sr-only", className)}
{...props}
/>
)
}
function NavbarLinkAccessory({
className,
...props
}: React.ComponentProps<"span">) {
const { collapsed } = React.useContext(NavbarContext)
return (
<span
data-slot="navbar-link-accessory"
className={cn(
"ml-auto flex shrink-0 items-center text-muted-foreground",
collapsed && "hidden",
className
)}
{...props}
/>
)
}
function NavbarSubmenu(
props: React.ComponentProps<typeof HoverCard.Root>
) {
return <HoverCard.Root openDelay={100} closeDelay={120} {...props} />
}
function NavbarSubmenuTrigger(
props: React.ComponentProps<typeof HoverCard.Trigger>
) {
return <HoverCard.Trigger data-slot="navbar-submenu-trigger" {...props} />
}
function NavbarSubmenuContent({
className,
sideOffset = 8,
align = "start",
...props
}: React.ComponentProps<typeof HoverCard.Content>) {
return (
<HoverCard.Portal>
<HoverCard.Content
data-slot="navbar-submenu-content"
side="right"
sideOffset={sideOffset}
align={align}
collisionPadding={12}
className={cn(
"z-50 min-w-40 rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-left-1 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:slide-in-from-left-1 motion-reduce:animate-none",
className
)}
{...props}
/>
</HoverCard.Portal>
)
}
function NavbarSubList({
className,
...props
}: React.ComponentProps<"ul">) {
const { orientation } = React.useContext(NavbarContext)
if (orientation !== "vertical") return null
return (
<ul
data-slot="navbar-sub-list"
className={cn(
"m-0 w-full list-none p-0 [&>li]:m-0",
className
)}
{...props}
/>
)
}
function NavbarSubLink({
className,
active = false,
asChild = false,
...props
}: NavbarLinkProps) {
const Component = asChild ? Slot.Root : "a"
return (
<Component
data-slot="navbar-sub-link"
data-active={active ? "true" : "false"}
aria-current={active ? "page" : undefined}
role="menuitem"
className={cn(
"flex h-8 w-full min-w-0 items-center rounded-md px-3 text-left text-xs font-medium text-muted-foreground no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground hover:no-underline focus-visible:ring-[3px] focus-visible:ring-ring/35 data-[active=true]:text-foreground",
className
)}
{...props}
/>
)
}
function NavbarCollapseTrigger({
className,
onClick,
...props
}: React.ComponentProps<"button">) {
const { collapsed, orientation, setCollapsed } =
React.useContext(NavbarContext)
if (orientation !== "vertical") return null
const label = collapsed ? "展开导航栏" : "收起导航栏"
return (
<button
type="button"
data-slot="navbar-collapse-trigger"
aria-label={label}
title={label}
className={cn(
"absolute -right-3 top-5 z-20 flex size-6 shrink-0 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-xs outline-none transition-[color,background-color] hover:bg-accent hover:text-accent-foreground focus-visible:ring-[3px] focus-visible:ring-ring/35",
className
)}
onClick={(event) => {
setCollapsed(!collapsed)
onClick?.(event)
}}
{...props}
>
{collapsed ? (
<PanelLeftOpenIcon className="size-4" />
) : (
<PanelLeftCloseIcon className="size-4" />
)}
</button>
)
}
export {
Navbar,
NavbarBadge,
NavbarBrand,
NavbarBrandLabel,
NavbarCollapseTrigger,
NavbarContent,
NavbarFooter,
NavbarGroup,
NavbarHeader,
NavbarItem,
NavbarLabel,
NavbarLink,
NavbarLinkAccessory,
NavbarLinkLabel,
NavbarList,
NavbarSeparator,
NavbarSubLink,
NavbarSubList,
NavbarSubmenu,
NavbarSubmenuContent,
NavbarSubmenuTrigger,
navbarVariants,
}
属性 Props
Navbar (根组件)
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| orientation | "horizontal" | "vertical" | "horizontal" | 导航栏布局方向。'horizontal' 适合顶部导航;'vertical' 适合左侧全局侧栏。 |
| collapsed | boolean | — | 纵向模式下导航栏的受控折叠收起状态(变为仅展示图标的紧凑图标栏)。 |
| defaultCollapsed | boolean | false | 非受控模式下纵向导航栏的初始折叠状态。 |
| onCollapsedChange | (collapsed: boolean) => void | — | 导航栏折叠与展开状态发生改变时的回调函数。 |
| className | string | — | 应用于导航栏外层容器的额外 CSS 类名。 |
NavbarLink
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| active | boolean | false | 是否为当前高亮处于激活态的页面,开启后自动挂载 aria-current="page"。同一 Navbar 内的选中底块会在链接之间(包括跨分组)滑动过渡;纵向模式附带左侧强调线。 |
| asChild | boolean | false | 启用后将样式与事件合并到底层唯一的子组件(如 Next.js `<Link>`)。 |
| href | string | — | 链接的目标地址。 |
| className | string | — | 应用于链接项目的额外 CSS 类名。 |
NavbarList
链接列表容器,继承原生 <ul> 属性。列表内的 NavbarLink 共享一个悬停底块,指针在链接之间移动时底块平滑跟随,离开列表时淡出;列表外的链接(如右侧图标操作)使用静态悬停背景。
NavbarSubmenu / NavbarSubmenuTrigger / NavbarSubmenuContent
用于在纵向侧边栏或顶部导航中展开二级悬浮卡片菜单:
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| sideOffset | number | 8 | 子菜单浮层相对于触发项的像素偏移量。 |
| align | "start" | "center" | "end" | "start" | 浮层沿对齐轴的对齐方式。 |
事件 Events
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| onCollapsedChange | (collapsed: boolean) => void | — | 用户点击折叠切换按钮或通过外部控制触发宽度收起/展开时调用。 |
| onClick | (event: React.MouseEvent<HTMLAnchorElement>) => void | — | 点击导航链接项时触发,常用于 SPA 路由拦截或数据埋点统计。 |
使用场景与设计规范
Navbar 用于承载应用的核心骨架导航体系:
- 顶部横向导航(Horizontal Navbar):适合信息层级相对扁平、功能模块在 4~7 个以内的 SaaS 门户、营销官网、移动端 H5 顶部栏。
- 左侧纵向侧栏(Vertical Sidebar):适合后台管理系统、云控制台、工作台等具备较多子模块、需要长期固定在屏幕左侧的复杂系统。
- 收起状态下的可用性:纵向导航收起时(Icon-only 模式),所有链接文本应通过
NavbarLinkLabel自动隐藏,但必须通过title或 Tooltip 浮层提示当前图标的含义,不可造成认知盲区。 - 单一激活原则:在同一组导航列表中,在同一时刻应仅有一个
NavbarLink设置为active={true}。
场景示例
应用级顶部全局头栏
集成工作区品牌切换、核心功能入口、搜索快捷键(⌘K)、消息通知红点与用户头像:
Loading…
后台纵向侧栏与二级子菜单
支持在纵向侧边栏中悬停弹出二级浮层子菜单,搭配折叠按钮与分组标签:
Loading…
受控模式与折叠收起
通过 collapsed 和 onCollapsedChange 精确控制侧栏状态。点击不同分组中的链接,选中底块会跨分组滑动:
Loading…
无障碍与交互 Accessibility
- 地标 Landmark:根组件自动渲染为
<nav>元素,建议通过aria-label为其提供具有区分度的描述(如“主导航”、“侧边栏导航”)。 - 当前页状态:当
NavbarLink设置active={true}时,组件自动挂载aria-current="page",以便读屏器在聚焦时向盲人用户播报“当前选中的页面”。 - 折叠按钮无障碍文案:
NavbarCollapseTrigger内置动态计算的aria-label(“展开导航栏” / “收起导航栏”),支持键盘回车触发。 - 动效降级:宽度折叠过渡、悬停底块与选中底块的滑动均遵循系统的
prefers-reduced-motion设置,开启后将立即跳变,防止晕动症。