Site Header
The shared site chrome header: brand, nav, and utility slots, with the layout decisions exposed as props.
Installation
Usage
import { SiteHeader } from "@/components/ui/site-header"
import { Nav, NavItem, NavList } from "@/components/ui/nav"Examples
Default
Nav beside the brand, sticky, hairlined, and transparent until content scrolls under it. The blur needs real page scroll, so the bar shows here at rest. Click between the nav items: the selected pill is Nav's, not the header's.
Page title
The bar spans the full width; its contents sit on the shared chrome width, the same one the footer uses.
Centered nav
A three-column grid holds the nav on the bar's true center, so it stays put when a label changes or a badge appears beside the utilities.
Page title
The bar spans the full width; its contents sit on the shared chrome width, the same one the footer uses.
Solid, not sticky
A static bar that scrolls away with the page. The scroll listener is skipped entirely.
Page title
The bar spans the full width; its contents sit on the shared chrome width, the same one the footer uses.
No hairline
For a bar running flush into a section that shares its surface, where a line would only announce a seam. This is an edge decision and nothing more; artwork is the hero surface below. The page decides it, not the header.
A flush section
The bar and the section under it share one surface, so a line between them would only announce a seam nobody needs to see.
Over a hero image
surface="hero" lays a masked scrim (tint plus blur) behind the chrome and makes the bar a dark island, so the nav's selected pill and every other component inside resolve against dark tokens whatever the page is set to. Over a photograph the page's light/dark mode is not the background, the photograph is. The hairline defaults off here: the scrim's falloff is the edge. The page positions the bar over the art; the shell never takes over layout.

The bar sits on the art
The scrim darkens whatever runs under the chrome. The bright band on the left and the near-black one beside it get the same treatment, so nothing in the bar has to know what it landed on.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| brandSiteHeader | ReactNode | - | Start of the bar, usually a home link wrapping <Brand />. |
| navSiteHeader | ReactNode | - | Primary navigation. Put the kit's <Nav /> here: it is the primitive for a top nav and brings the sliding selected pill, the control-scale sizes, and its own <nav> landmark. siteHeaderNavItemClass is the quieter alternative, colour-only links with no fill and no indicator, for chrome that should ship no client JS; wrap those in your own <nav>, since the slot wrapper is a plain div. Hidden below md, where sites bring their own drawer. |
| childrenSiteHeader | ReactNode | - | Right-side utilities: theme toggle, search, account. |
| navPlacementSiteHeader | "start" | "center" | "start" | start keeps the nav beside the brand with a spacer absorbing the rest. center switches to a three-column grid that holds the nav on the true center regardless of how wide the brand and utilities are. |
| stickySiteHeader | boolean | true | Pins the bar to the top of the viewport at z-40. |
| surfaceSiteHeader | "scroll-translucent" | "solid" | "transparent" | "hero" | "scroll-translucent" | scroll-translucent is transparent at rest and blurs once content scrolls under it (needs the --background-translucent token). solid and transparent are static and skip the scroll listener. hero is for a bar sitting on artwork: a top-down scrim plus light content in both themes. Pair hero with sticky={false}, or with a page that swaps to solid on scroll, since a scrim travelling over body copy is a bug. |
| hairlineSiteHeader | boolean | `true`, or `false` under `surface="hero"` | The bottom border. Turn it off for a bar running flush into a section that shares its surface. Under hero it defaults off because the scrim's falloff is already the edge; passing it explicitly still wins in both directions. |
| classNameSiteHeader | string | - | Merged onto the <header>, not the inner chrome container. |
"use client"
import * as React from "react"
import { useEffect, useState } from "react"
import { cn } from "@/registry/lib/utils"
// 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-header-classes.ts.
import {
siteHeaderNavItemClass,
siteHeaderNavItemActiveClass,
} from "@/registry/ui/site-header-classes"
/**
* The shared site chrome header for mattdowney.com subdomain projects. One
* shell so every project's top bar reads as the same family, on the chrome
* width and bar height tokens (`--container-chrome`, `--spacing-chrome-gutter`,
* `--spacing-site-header`) that the matching SiteFooter reads too. Override
* those in your theme and both ends of the page move together.
*
* Slots, not opinions: `brand` (usually a home link wrapping <Brand/>), `nav`
* (hidden on mobile, where sites bring their own drawer), and `children` for
* right-side utilities (theme toggle, search, account).
*
* Put the kit's `Nav` in the `nav` slot. It is the primitive for this job and
* brings the sliding selected pill, the control-scale sizes, and its own <nav>
* landmark. `siteHeaderNavItemClass` is the other, quieter option: colour-only
* links with no fill and no indicator, for a site that wants a plain text nav
* and no client JS in its chrome. Wrap those in your own <nav>, because this
* shell deliberately does not.
*
* Layout is a prop, not a fork. Every default reproduces the original header:
* sticky, hairlined, nav beside the brand, transparent until content scrolls
* under it. `scroll-translucent` requires the `--background-translucent` token
* from the base theme.
*
* Route awareness is deliberately absent. A site that wants no hairline over
* its flush section passes `hairline={false}` from that page; the shell never
* learns what page it is on.
*/
function SiteHeader({
brand,
nav,
navPlacement = "start",
sticky = true,
surface = "scroll-translucent",
hairline,
className,
children,
}: {
brand?: React.ReactNode
nav?: React.ReactNode
/**
* `"start"` keeps the nav beside the brand with a spacer absorbing the rest.
* `"center"` holds the nav on the bar's true center regardless of how wide
* the brand and utilities are.
*/
navPlacement?: "start" | "center"
sticky?: boolean
/**
* `"scroll-translucent"` is transparent at rest and blurs once content
* scrolls under it. `"solid"` and `"transparent"` are static, and skip the
* scroll listener entirely. Independent of `sticky`: a solid sticky bar is a
* valid combination.
*
* `"hero"` is for a bar sitting on artwork: a masked scrim (tint plus blur)
* behind the chrome, and the bar itself becomes a dark island so everything
* inside it resolves against dark tokens whatever the page is set to. Over a
* photograph the page's light/dark mode is not the background, the photograph
* is, so a `text-muted-foreground` nav vanishes into any bright region and a
* pale selected pill glares. Pair it with `sticky={false}`, or with a page
* that swaps to `"solid"` on scroll: a scrim that travels over ordinary body
* copy is a bug.
*/
surface?: "scroll-translucent" | "solid" | "transparent" | "hero"
/**
* Defaults to `true`, except under `surface="hero"`, where the scrim's own
* falloff is the edge and a hard line across the artwork is what the caller
* is trying to avoid. Passing it explicitly always wins.
*/
hairline?: boolean
className?: string
children?: React.ReactNode
}) {
const isScrollSurface = surface === "scroll-translucent"
const isHero = surface === "hero"
// The scrim IS the edge under `hero`, so the hairline defaults off there.
// An explicit value still wins in both directions.
const showHairline = hairline ?? !isHero
const [scrolled, setScrolled] = useState(false)
useEffect(() => {
// Nothing reads `scrolled` on a static surface, so do not attach a
// listener to compute it.
if (!isScrollSurface) return
const onScroll = () => setScrolled(window.scrollY > 8)
onScroll()
window.addEventListener("scroll", onScroll, { passive: true })
return () => window.removeEventListener("scroll", onScroll)
}, [isScrollSurface])
// A div, not a <nav>. The slot takes arbitrary content, and the kit's own Nav
// brings its own <nav> landmark, so owning one here would nest two of them on
// every site that uses the component the kit ships for this exact job. The
// wrapper's only work is layout and the mobile hide; whatever goes in it
// carries the landmark, which for plain links means wrapping them yourself.
const navEl = nav ? (
<div
className={cn(
"hidden items-center gap-1 md:flex",
navPlacement === "start" && "ml-4"
)}
>
{nav}
</div>
) : null
return (
<header
data-slot="site-header"
data-surface={surface}
className={cn(
"w-full transition-colors duration-200 motion-reduce:transition-none",
sticky && "sticky top-0 z-40",
showHairline && "border-b border-border",
surface === "solid" && "bg-background",
surface === "transparent" && "bg-transparent",
// `dark` is not a typo. Chrome on artwork is a dark surface no matter
// what the page is set to, so the bar becomes a dark island and every
// component inside it resolves against dark tokens without being told:
// Nav's selected pill picks `accent-900` instead of the pale
// `accent-100` that would glare on a photo, and a ghost Button's hover
// fill comes along too. The three token overrides in this item's `css`
// then guarantee pure white type on top, rather than leaving it to
// whatever the dark palette happens to be.
isHero && "dark relative bg-transparent",
isScrollSurface &&
(scrolled
? "bg-background-translucent backdrop-blur-md"
: "bg-transparent"),
className
)}
>
{isHero ? (
// Darkening AND blur, faded out by a mask rather than a gradient fill.
// A `backdrop-filter` applies uniformly across its element and cannot
// be ramped by a gradient background, so a blurred band would end in a
// hard horizontal seam across the artwork. The mask fades the element
// itself, blur and tint together, and the two stay in step.
//
// Blur is not decoration here. The scrim fixes luminance, but type over
// high-frequency detail still fights the edges underneath it; softening
// the art is what stops a nav label from sitting in the middle of a
// hard boundary. `blur-md` matches the scrolled chrome's own blur, so
// one page does not use two different amounts.
//
// Full strength through the first 30% of a band twice the bar's height,
// so it is still solid where the type actually sits (the bar's optical
// centre, ~32px down) and only then falls off. A ramp that reaches
// transparent by the bar's bottom edge is already half gone behind the
// text, which is where scrims usually fail. Sized from artwork, not by
// eye: the brightest pixel under this demo's bar leaves white type at
// 1.9:1, and 60% black lands it at 8.7:1, with the muted step at 6.0:1.
// Both measured off the actual file, blur not counted.
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-site-header-scrim bg-black/60 backdrop-blur-md mask-b-from-30%"
/>
) : null}
{navPlacement === "center" ? (
// A three-column grid, not `justify-center` on a flex row. The brand
// and the utilities are different widths, so centering the middle of a
// row lands the nav off-center by half their difference, and moves it
// again every time a cart badge appears or a label changes. Equal 1fr
// rails hold the nav on the bar's true center whatever sits either
// side, and `min-w-0` lets those rails shrink instead of forcing the
// grid wider than the bar.
<div className="relative mx-auto grid h-site-header w-full max-w-chrome grid-cols-[1fr_auto_1fr] items-center gap-4 px-chrome-gutter">
<div className="flex min-w-0 items-center justify-start">{brand}</div>
<div className="flex items-center justify-center">{navEl}</div>
<div className="flex min-w-0 items-center justify-end gap-4">
{children}
</div>
</div>
) : (
<div className="relative mx-auto flex h-site-header w-full max-w-chrome items-center gap-4 px-chrome-gutter">
{brand}
{navEl}
<div className="flex-1" />
{children}
</div>
)}
</header>
)
}
// rsc-reexport: siteHeaderNavItemClass -> site-header-classes
// rsc-reexport: siteHeaderNavItemActiveClass -> site-header-classes
// kept for existing client call sites; server callers import the sibling
export { SiteHeader, siteHeaderNavItemClass, siteHeaderNavItemActiveClass }// NO "use client" HERE, ON PURPOSE. `site-header.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-header.tsx` re-exports them for the
// client callers that already import from there.
/**
* Nav link treatment beside the brand. Sites compose their own router links
* (next/link etc.) with these classes so the header shell stays router-free.
*
* `inline-flex` is load-bearing, not cosmetic. A plain box puts a trailing
* icon (an external-link arrow) in the inline flow, where the space before it
* is a line-break opportunity, so the item's min-content is the label alone
* and the arrow drops to a second line the moment the row is squeezed. Flex
* also makes a `gap-*` override actually apply.
*/
/**
* `font-medium` is on BOTH states on purpose, and the active one differs only
* in colour. Weight was the difference before, which reflows the label the
* instant selection moves: a medium "Writing" is wider than a regular one, so
* every item after it shifts. Selection is a colour change, never a metrics
* change. NavItem has always done it this way; these had drifted.
*/
const siteHeaderNavItemClass =
"inline-flex items-center px-3 py-1.5 rounded-md text-sm font-medium text-muted-foreground transition-colors outline-none hover:text-foreground focus-visible:ring-1 focus-visible:ring-ring-accent"
const siteHeaderNavItemActiveClass =
"inline-flex items-center px-3 py-1.5 rounded-md text-sm font-medium text-foreground transition-colors outline-none focus-visible:ring-1 focus-visible:ring-ring-accent"
export { siteHeaderNavItemClass, siteHeaderNavItemActiveClass }