Skip to content
Monogem

Component · Navigation & Feedback

Navbar

Overview

Top-of-page navigation: brand, primary links, and optional actions in one row, that collapses to a hamburger + Sheet below the md breakpoint instead of wrapping or compressing.

Use it for the primary entry point into an application or site. Reach for something else when the job is different:

  • A left-hand rail of destinations inside an already-entered app → Sidebar Nav.
  • Showing where the current page sits in a hierarchy → Breadcrumb.
  • Switching between views of the same page → Tabs.

Navbar is not a site shell: site-specific branding, routes, and copy stay with the consuming app; only reusable navigation structure and behavior live here.

Anatomy

Navbar (the <header> chrome) → NavbarContent (the width-capped row) → NavbarBrand (left slot) + NavbarLinks (desktop <nav> of NavbarLinks) + NavbarActions (always-visible controls) + NavbarMobileMenu (the md:hidden hamburger + Sheet).

  • Navbar — the <header> chrome.
  • NavbarContent — the width-capped row.
  • NavbarBrand — the left slot.
  • NavbarLinks — the desktop <nav> of NavbarLinks.
  • NavbarActions — the always-visible controls.
  • NavbarMobileMenu — the md:hidden hamburger plus its Sheet.

Compound parts, shadcn-style — the caller assembles the row and supplies its own brand mark, links, and actions; the component supplies structure, the responsive contract, and styling. No asChild: NavbarLink renders a native <a>; for a client-side router, apply the exported navbarLinkVariants({ current }) class string to the router's own link element directly (button.md's escape hatch — "put buttonVariants(...) on the element directly" — the same call Breadcrumb and Sidebar Nav make).

Live Example

Narrow viewport (below 768px) — inline links hand off to the menu button; brand and the “Sign in” action stay visible either way.

Code Example

navbar-showcase.tsx
"use client";

import type { MouseEvent } from "react";
import { useState } from "react";

import { Button } from "@/components/ui/button";
import {
Navbar,
NavbarActions,
NavbarBrand,
NavbarContent,
NavbarLink,
NavbarLinks,
NavbarMobileMenu,
} from "@/components/ui/navbar";
import { SheetClose } from "@/components/ui/sheet";

/**
* Live Navbar example. `"use client"` only so the demo can swallow real
* navigation and drive which link is `current` — the parts themselves are
* server components. Resize the preview below `md` (768px) to see the inline
* links hand off to the hamburger + Sheet (navbar.md#responsive-contract);
* both trees are always in the DOM, one hidden by breakpoint, same approach
* as the Sidebar Nav responsive example.
*/
const NAV_LINKS = [
{ href: "/overview", label: "Overview" },
{ href: "/components", label: "Components" },
{ href: "/pricing", label: "Pricing" },
];

const stayOnPage = (event: MouseEvent<HTMLAnchorElement>) =>
event.preventDefault();

export function NavbarShowcase() {
const [current, setCurrent] = useState(NAV_LINKS[0].href);

return (
<div className="flex flex-col gap-3">
<div className="overflow-hidden rounded-md border border-solid border-border">
<Navbar className="static">
<NavbarContent>
<NavbarBrand>
<span className="rounded-md px-1 py-1.5 text-sm font-semibold leading-snug tracking-tight">
Acme
</span>
</NavbarBrand>

<NavbarLinks>
{NAV_LINKS.map((link) => (
<NavbarLink
key={link.href}
href={link.href}
current={link.href === current}
onClick={(event) => {
stayOnPage(event);
setCurrent(link.href);
}}
>
{link.label}
</NavbarLink>
))}
</NavbarLinks>

<NavbarActions>
<Button variant="outline" size="sm">
Sign in
</Button>
</NavbarActions>

<NavbarMobileMenu>
{NAV_LINKS.map((link) => (
<SheetClose key={link.href}>
<NavbarLink
href={link.href}
current={link.href === current}
onClick={(event) => {
stayOnPage(event);
setCurrent(link.href);
}}
>
{link.label}
</NavbarLink>
</SheetClose>
))}
</NavbarMobileMenu>
</NavbarContent>
</Navbar>
</div>
<p className="text-xs leading-snug text-muted-foreground">
Narrow viewport (below 768px) — inline links hand off to the menu
button; brand and the “Sign in” action stay visible either way.
</p>
</div>
);
}

Variants

VariantHow
With actionsNavbarActions holds a ThemeToggle, a CTA Button, or both — it stays visible at every width, including beside the mobile trigger.
Without actionsOmit NavbarActions — NavbarMobileMenu's trigger still renders on its own.
Current linkNavbarLink current (or navbarLinkVariants({ current: true }) on a router Link) — see States and Accessibility.

Not in V1: a mega-menu / multi-level dropdown under a link, a built-in search field, an account/user menu, and a built-in active-route matcher (same deferral as Sidebar Nav — the caller owns "which link is current").

States

Per NavbarLink, using the same tokens the site header already used:

StateLook
Resting--muted-foreground text, transparent background, --radius-md corners.
Hover--foreground text (no background change — matches the existing Monogem header, not Sidebar Nav's filled hover).
Focus-visible--scale-0-5 ring in --ring, :focus-visible only.
Current--foreground text + font-medium, plus aria-current="page". No filled selected surface — a top nav's row is too shallow for the sidebar's --sidebar-primary treatment to read cleanly; the text-weight change is the visible signal, aria-current is the real one.

NavbarMobileMenu's trigger is a standard Button ghost icon: its hover / focus / active states are Button's, unchanged.

Usage Guidance

Relationship to Sheet

Navbar's mobile menu does not introduce a second drawer/disclosure system. NavbarMobileMenu is a pre-wired Sheet composition — the same public API Sidebar Nav's own responsive example uses for a rail-to-drawer handoff. Every open/close, Escape, overlay-click, focus-trap, and focus-return rule Sheet documents (sheet.md) applies unchanged; this page only adds what's specific to a top nav — which content collapses, and where the trigger sits.

Tokens

Navbar composes existing Button (the hamburger trigger) and Sheet (the mobile menu surface) unchanged, and reuses the same --background / --border / --muted-foreground / --foreground / --ring pairing as the rest of the system's chrome; it introduces no component-specific styling values.

TokenWhere used
--backgroundNavbar's translucent fill (/80 + backdrop-blur)
--borderNavbar's bottom edge
--muted-foregroundResting NavbarLink text
--foregroundHover / current NavbarLink text
--ringFocus-visible ring on a link or the mobile trigger
--radius-mdNavbarLink corner radius

Do / Don’t

Do

  • Give NavbarLinks and NavbarMobileMenu's inner list the same destinations, in the same order — one destination list rendered twice, not two information architectures (Responsive contract).
  • Wrap each mobile NavbarLink in SheetClose so choosing a destination also closes the menu (the same SheetClose pattern Sheet's header ✕ and footer Cancel already use).
  • Mark exactly one link current, matching the page the user is on — computed by the caller (usePathname, a router match), same as Sidebar Nav.
  • Keep NavbarBrand a real link back to the app/site root.

Don’t

  • Build a second mobile drawer instead of NavbarMobileMenu — that duplicates Sheet's accessibility contract for no reason.
  • Let the desktop row wrap or shrink links to fit — below md the whole NavbarLinks <nav> hides; there is no intermediate compressed state.
  • Mark more than one link current.
  • Reach for NavbarActions to hold primary destinations — it's for controls (toggles, CTAs), not navigation; put destinations in NavbarLinks / the mobile menu.

Accessibility

  • Landmarks — NavbarLinks renders a <nav> with aria-label defaulting to "Primary" (applied after {...props}, so it can't be dropped by accident); the mobile menu's inner <nav> shares the same label. Only one is ever exposed to assistive tech at a time — the hidden one is display:none (out of the accessibility tree) or, for the Sheet, simply not rendered while closed.
  • Current location — the current link is aria-current="page" (WAI-ARIA APG). Exactly one per nav. The font-medium text-weight change is reinforcement; aria-current is the real signal.
  • Links — native <a> elements (or the caller's router Link): Tab moves between them, Enter activates, the focus ring is keyboard-only. No roving tabindex.
  • Mobile trigger — a standard icon Button inside SheetTrigger, so it gets aria-haspopup="dialog" and aria-expanded for free; give it a real triggerLabel ("Open navigation" by default).
  • Mobile menu — everything Sheet documents: role="dialog" + aria-modal, a required SheetTitle naming it (title, default "Navigation"), Escape closes, focus moves into the panel on open and returns to the trigger on close, Tab is trapped inside while open, the rest of the page is inert (sheet.md#accessibility).

Responsive Behavior

Navbar bakes in the collapse itself — this is not a caller composition the way Sidebar Nav's rail-vs-drawer split is (that component has no opinion on the wide-screen surface; Navbar's wide surface is always the inline row).

  • md and up (768px, the same split Sidebar Nav's own rail/drawer example uses): NavbarLinks renders inline; NavbarMobileMenu renders nothing.
  • Below md: NavbarLinks renders nothing; NavbarMobileMenu's hamburger trigger appears instead, beside NavbarActions.
  • NavbarBrand and NavbarActions render at every width — the brand mark and controls like a theme toggle never go behind the hamburger.
  • Both the NavbarLinks tree and the NavbarMobileMenu tree are always in the DOM; only one is ever visible, switched purely by the md: breakpoint (a real viewport-width media query, not a container query) — resize the browser below 768px to see the handoff, the same approach the Live Example above and Sidebar Nav's own responsive example use.

Related Components / Patterns

  • Sidebar Nav — app-shell navigation on wider viewports.
  • Bottom Nav — the mobile-only counterpart.
  • Sheet — the mobile menu surface.
  • Navigation — how the navigation primitives combine on one screen.