Site Nav
The phone menu a SiteHeader's links fold into on a small screen: a panel with the brand, the main links, quieter links and the account.
Installation
$
npx shadcn@latest add https://ui.mattdowney.com/r/site-nav.jsonUsage
import { SiteNav, siteNavItemClass, siteNavItemActiveClass, siteNavUtilityItemClass } from "@/components/ui/site-nav"Examples
Default
You own the open state and the trigger, usually a HeaderIconButton shown only below md. Style your own router links with the exported classes; the current page takes siteNavItemActiveClass in place of the plain one.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | - | Whether the panel is open. Required: the trigger lives in your header, so you hold the state. |
| onOpenChange | (open: boolean) => void | - | Called when the panel opens or closes, including on Escape and a backdrop tap. |
| brand | ReactNode | - | Your logo at the top of the panel, so it names itself once open. |
| nav | ReactNode | - | The main links, styled with siteNavItemClass (44px tall, for a thumb). |
| utility | ReactNode | - | Quieter links (docs, feedback, legal), styled with siteNavUtilityItemClass. |
| account | ReactNode | - | Sign in or the account link. Keep the main sign-up action in the header bar, not here. |
| className | string | - | Extra classes on the panel. |
| openbooleanWhether the panel is open. Required: the trigger lives in your header, so you hold the state. |
| onOpenChange(open: boolean) => voidCalled when the panel opens or closes, including on Escape and a backdrop tap. |
| brandReactNodeYour logo at the top of the panel, so it names itself once open. |
navReactNodeThe main links, styled with siteNavItemClass (44px tall, for a thumb). |
utilityReactNodeQuieter links (docs, feedback, legal), styled with siteNavUtilityItemClass. |
| accountReactNodeSign in or the account link. Keep the main sign-up action in the header bar, not here. |
| classNamestringExtra classes on the panel. |
"use client"
import * as React from "react"
import { cn } from "@/registry/lib/utils"
import { Drawer, DrawerContent } from "@/registry/ui/drawer"
// These classes are plain values, but a client module's exports are still
// client references under RSC, so they live in a directive-free sibling
// module. See site-nav-classes.ts.
import {
siteNavItemClass,
siteNavItemActiveClass,
siteNavUtilityItemClass,
} from "@/registry/ui/site-nav-classes"
/**
* The third piece of the shared site chrome, beside SiteHeader and SiteFooter:
* the small-screen navigation panel those two collapse into.
*
* It exists because the header hides its nav below `md` and the footer's links
* are far down a scrolling page, so on a phone a project's whole navigation has
* to live in one panel. Without a shell for that, every project hand-rolls a
* bare Drawer, and the failure mode is consistent: a full-height panel holding
* one or two links, no close button, and the account controls left crowding the
* top bar. This owns the structure so that cannot happen per project.
*
* WHAT BELONGS IN IT (the part that matters more than the styling):
* - Every destination in the desktop header's nav.
* - From the footer, only what someone might want FROM THE NAV. Reference
* documents (privacy, terms) are not destinations and belong in the footer.
* An earlier version of this file said to bring all of them across, and the
* result was four utility rows stacked under a single primary link: the
* least important tier as the largest block in the panel.
* - Nothing that acts on the current page. Page actions belong to the page's
* own toolbar or overflow menu, not to site navigation.
*
* WHAT STAYS IN THE BAR, and this is the rule most easily got wrong:
* - The single most important action for the current auth state. Signed out
* that is sign-up, signed in it is the account control. Hiding navigation
* costs roughly a fifth of its discoverability and makes mobile tasks ~15%
* slower (NN/G's hidden-navigation study), which is a price worth paying
* for a link and never for a conversion action. Put the SECONDARY auth
* affordance in the panel's account block, not the primary one.
* - If what remains for the panel is fewer than three items, do not ship a
* trigger at all. A menu is a cost, and two links do not repay it.
*
* Slots, not opinions, matching the rest of the chrome family: `brand` (the
* wordmark, so the panel identifies itself once open), `nav` (the primary
* destinations), `utility` (the quieter ones the footer carries: spec links,
* feedback, legal), and `account`. Consumers compose their own router links
* with the exported item classes, so the shell stays router-free.
*/
// @use-when the phone menu for a site that has a SiteHeader: the panel its nav
// collapses into below `md`, holding the links, brand and account controls.
// Use it instead of a bare Drawer.
function SiteNav({
open,
onOpenChange,
brand,
nav,
utility,
account,
className,
}: {
open: boolean
onOpenChange: (open: boolean) => void
brand?: React.ReactNode
nav?: React.ReactNode
utility?: React.ReactNode
account?: React.ReactNode
className?: string
}) {
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent
side="left"
aria-label="Navigation"
// `px-2`, not the drawer's default `px-4`: the items carry their own
// `px-3`, so the row's hover background reaches nearer the panel edge
// and reads as a full-width row rather than a floating pill. The x-rail
// still clears the focus ring, which the repo's clip-container rule
// requires of anything wrapping focusable controls.
className={cn("w-72 gap-0 px-2 pt-3 pb-2", className)}
// The drawer's own close is kept, not re-implemented: it already sits at
// `top-3 right-3` with the shared focus ring and tap target. The brand
// row below reserves that corner with `pr-12` so the two never collide.
// A nav panel WITHOUT a close is the most common version of this bug —
// backdrop tap is not discoverable, and on a left drawer the remaining
// strip of page is a thin one.
>
{/* Identity row. A panel that slides over the page should still say
which site it belongs to; the header it came from is now hidden
behind the overlay. Reserves the right side for the close button. */}
{brand ? (
<div className="flex min-h-11 items-center px-3 pr-12">{brand}</div>
) : null}
{nav ? (
<nav className="mt-(--spacing-rhythm-bind) flex flex-col gap-0.5">{nav}</nav>
) : null}
{utility ? (
<>
{/* Hairline rather than a heading: these are the same kind of thing
as the links above, just less used. A label would give them more
weight than they earn. */}
<div className="mx-3 my-3 border-t border-border" />
<nav className="flex flex-col gap-0.5">{utility}</nav>
</>
) : null}
{/* Pinned to the bottom. It is the one block here that is about you
rather than about where you are going, and the bottom of a panel is
the reachable end of it on a phone. */}
{account ? (
<div className="mt-auto border-t border-border px-1 pt-3">
{account}
</div>
) : null}
</DrawerContent>
</Drawer>
)
}
// rsc-reexport: siteNavItemClass -> site-nav-classes
// rsc-reexport: siteNavItemActiveClass -> site-nav-classes
// rsc-reexport: siteNavUtilityItemClass -> site-nav-classes
// kept for existing client call sites; server callers import the sibling
export {
SiteNav,
siteNavItemClass,
siteNavItemActiveClass,
siteNavUtilityItemClass,
}const siteNavItemClass =
"flex min-h-11 items-center rounded-md px-3 text-base text-muted-foreground transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring-accent"
/* The current page: same accent chip treatment as the header's active item.
No hover:bg-muted: a muted hover over the accent fill would read as the
chip losing its state under the pointer. */
const siteNavItemActiveClass =
"flex min-h-11 items-center rounded-md px-3 text-base font-medium text-accent dark:text-accent-400 bg-accent-100 dark:bg-accent-800 transition-colors outline-none focus-visible:ring-1 focus-visible:ring-ring-accent"
/** Utility destination: same hit area, quieter type. */
const siteNavUtilityItemClass =
"flex min-h-11 items-center rounded-md px-3 text-sm text-subtle-foreground transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring-accent"
// NO "use client" HERE, ON PURPOSE. `site-nav.tsx` is a client module, and
// under React Server Components every export of a client module becomes a
// client reference: renderable or passable as a prop, never usable as a plain
// value. Interpolating one of these classes into `cn()` from a Server
// Component throws. These live in their own directive-free module so a
// Server Component can read them; `site-nav.tsx` re-exports them for the
// client callers that already import from there.
//
// This note and the one on `siteNavItemClass` sit DOWN HERE rather than at the
// top, because the shadcn CLI drops everything above a file's first statement
// when it installs it. Written at the top, neither reaches the consumer.
//
// `siteNavItemClass` is the primary destination, and it is bigger than the
// header's because this one is a tap target.
export { siteNavItemClass, siteNavItemActiveClass, siteNavUtilityItemClass }