wui
组件

导航栏 Navbar

适用于页面全局顶部栏与应用级侧边抽屉栏的高可组合性导航组件,支持横向/纵向排版、折叠收起、二级悬浮子菜单及徽标标记。

第三方依赖 · radix-ui第三方依赖 · class-variance-authority第三方依赖 · lucide-react第三方依赖 · motion

基础用法

横向模式下的基础导航栏。组合品牌 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
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

属性类型默认值说明
orientation"horizontal" | "vertical""horizontal"导航栏布局方向。'horizontal' 适合顶部导航;'vertical' 适合左侧全局侧栏。
collapsedboolean—纵向模式下导航栏的受控折叠收起状态(变为仅展示图标的紧凑图标栏)。
defaultCollapsedbooleanfalse非受控模式下纵向导航栏的初始折叠状态。
onCollapsedChange(collapsed: boolean) => void—导航栏折叠与展开状态发生改变时的回调函数。
classNamestring—应用于导航栏外层容器的额外 CSS 类名。
属性类型默认值说明
activebooleanfalse是否为当前高亮处于激活态的页面,开启后自动挂载 aria-current="page"。同一 Navbar 内的选中底块会在链接之间(包括跨分组)滑动过渡;纵向模式附带左侧强调线。
asChildbooleanfalse启用后将样式与事件合并到底层唯一的子组件(如 Next.js `<Link>`)。
hrefstring—链接的目标地址。
classNamestring—应用于链接项目的额外 CSS 类名。

链接列表容器,继承原生 <ul> 属性。列表内的 NavbarLink 共享一个悬停底块,指针在链接之间移动时底块平滑跟随,离开列表时淡出;列表外的链接(如右侧图标操作)使用静态悬停背景。

用于在纵向侧边栏或顶部导航中展开二级悬浮卡片菜单:

属性类型默认值说明
sideOffsetnumber8子菜单浮层相对于触发项的像素偏移量。
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 设置,开启后将立即跳变,防止晕动症。