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>ofNavbarLinks.NavbarActions— the always-visible controls.NavbarMobileMenu— themd:hiddenhamburger 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
"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
| Variant | How |
|---|---|
| With actions | NavbarActions holds a ThemeToggle, a CTA Button, or both — it stays visible at every width, including beside the mobile trigger. |
| Without actions | Omit NavbarActions — NavbarMobileMenu's trigger still renders on its own. |
| Current link | NavbarLink 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:
| State | Look |
|---|---|
| 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.
| Token | Where used |
|---|---|
--background | Navbar's translucent fill (/80 + backdrop-blur) |
--border | Navbar's bottom edge |
--muted-foreground | Resting NavbarLink text |
--foreground | Hover / current NavbarLink text |
--ring | Focus-visible ring on a link or the mobile trigger |
--radius-md | NavbarLink corner radius |
Do / Don’t
Do
- Give
NavbarLinksandNavbarMobileMenu's inner list the same destinations, in the same order — one destination list rendered twice, not two information architectures (Responsive contract). - Wrap each mobile
NavbarLinkinSheetCloseso choosing a destination also closes the menu (the sameSheetClosepattern 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
NavbarBranda 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
mdthe wholeNavbarLinks<nav>hides; there is no intermediate compressed state. - Mark more than one link
current. - Reach for
NavbarActionsto hold primary destinations — it's for controls (toggles, CTAs), not navigation; put destinations inNavbarLinks/ the mobile menu.
Accessibility
- Landmarks —
NavbarLinksrenders a<nav>witharia-labeldefaulting 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 isdisplay: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. Thefont-mediumtext-weight change is reinforcement;aria-currentis the real signal. - Links — native
<a>elements (or the caller's routerLink):Tabmoves between them,Enteractivates, the focus ring is keyboard-only. No roving tabindex. - Mobile trigger — a standard icon Button inside
SheetTrigger, so it getsaria-haspopup="dialog"andaria-expandedfor free; give it a realtriggerLabel("Open navigation" by default). - Mobile menu — everything Sheet documents:
role="dialog"+aria-modal, a requiredSheetTitlenaming it (title, default "Navigation"),Escapecloses, focus moves into the panel on open and returns to the trigger on close,Tabis trapped inside while open, the rest of the page isinert(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).
mdand up (768px, the same split Sidebar Nav's own rail/drawer example uses):NavbarLinksrenders inline;NavbarMobileMenurenders nothing.- Below
md:NavbarLinksrenders nothing;NavbarMobileMenu's hamburger trigger appears instead, besideNavbarActions. NavbarBrandandNavbarActionsrender at every width — the brand mark and controls like a theme toggle never go behind the hamburger.- Both the
NavbarLinkstree and theNavbarMobileMenutree are always in the DOM; only one is ever visible, switched purely by themd: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.