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.
Inside a padded page container
The shell inside a page column that pads itself. The first bar draws its border on the shell, so the line stops short of both page edges. The second passes bleed, and the rule escapes the padding to meet them while nothing in the bar moves. hairline decides whether an edge exists, bleed only where it is drawn.
The border is on the shell, so it stops where the container's padding starts.
The rule is on its own box, pulled out past the padding. The bar itself did not move.
Over a hero image
surface="hero" lays a masked scrim behind the chrome and turns the bar into a dark island, so the nav's pill and everything else inside resolve against dark tokens whatever the page is set to. The hairline defaults off here: the scrim's falloff is the edge. Positioning the bar over the art stays the page's job.

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 | Whether there is a bottom edge. 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. |
| bleedSiteHeader | boolean | string | false | Where that edge is drawn. The default is the full-width mount: the border sits on the shell, which is correct when the shell already spans the viewport. Set it for a bar inside a page container that pads itself, and the rule moves to a full-width child that escapes the padding by a negative inline margin, so it meets the page's real edges while the bar's contents stay put. true reads the distance from --spacing-chrome-bleed (which a responsive container redeclares at its own breakpoint); a string states it directly. A no-op wherever there is no hairline, including surface="hero". |
| containerSiteHeader | boolean | true | Whether the shell builds its own centred container. On by default, which is right when the bar spans the viewport: it centres the row at --container-chrome and pads it with --spacing-chrome-gutter. Set it to false when the bar already sits inside a page container that centres and pads, or the two nest and the bar's contents sit inboard of the rest of the page by a full gutter. The alternative, neutralising both tokens, is global: it moves every shell on the site, so a page with one nested bar and one full-width bar cannot express both. Orthogonal to bleed: that moves the edge past a container's padding, this declines to build the container at all. |
| 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. It shares
* `--container-chrome` and `--spacing-chrome-gutter` with the matching
* SiteFooter, so overriding either moves both ends of the page together, and
* owns `--spacing-site-header` (the bar height) alone. The footer has no
* height token; it is as tall as its contents.
*
* Bar height is read through the paren class form, `h-(--spacing-site-header)`,
* and that is load-bearing rather than a style preference. Tailwind v4 INLINES
* a named spacing token when it compiles, so the plain utility ships as
* `height: 5rem` with no `var()` left in it and a consumer's override of the
* token can never reach it. The paren form compiles to
* `height: var(--spacing-site-header)` and stays live. It is the same reason
* `Stack` sets its gaps from the rung vars instead of a `gap-rhythm-*` class,
* and the same mistake `frozen-page-scale-gap` exists to catch.
*
* That is also the whole responsive story, and why there is no `size` or
* `density` prop. A compact mobile bar and a taller desktop one is two lines
* in the consumer's own CSS:
*
* :root { --spacing-site-header: 3.5rem }
* @media (width >= 48rem) { :root { --spacing-site-header: 5rem } }
*
* Everything pinned to the bar follows, because they all read the same var:
* the sticky offset (`top-(--spacing-site-header)`), an anchor's scroll margin
* (`scroll-mt-(--spacing-site-header)`), and the hero scrim, whose token is
* `calc(var(--spacing-site-header) * 2)` rather than its own length. One number
* to move, and the consumer's breakpoints stay in the consumer, exactly as
* `--spacing-chrome-bleed` already works under `bleed`.
*
* 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. Both `scroll-translucent`'s scrolled fill and the
* `hero` scrim honor `prefers-reduced-transparency`: the scrolled bar drops
* its blur and goes fully opaque, and the hero scrim drops its blur (the tint
* alone already clears the contrast bar it was sized for).
*
* 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.
*
* `hairline` decides WHETHER there is an edge; `bleed` decides WHERE it is
* drawn. Mounted full width, the two are the same thing. Mounted inside a page
* container that pads itself, they are not, and `bleed` is the one you want.
*/
// @use-when the shared site chrome header for a mattdowney.com subdomain
// project.
function SiteHeader({
brand,
nav,
navPlacement = "start",
sticky = true,
surface = "scroll-translucent",
hairline,
bleed = false,
container = true,
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
/**
* For a bar mounted INSIDE a page container that already applies its own
* horizontal padding, where the edge must still meet the page's real edges.
* The default `false` is the full-width mount: the border is drawn on the
* shell, which is correct when the shell already spans the viewport.
*
* Set it and the rule moves to a full-width child that escapes the container
* with a negative inline margin, so it runs past the padding while nothing
* in the bar moves. `true` reads the distance from `--spacing-chrome-bleed`;
* pass a length (`"2rem"`, `"var(--page-gutter)"`) to state it directly.
*
* The token is the responsive path: a page container padded `px-4 md:px-8`
* declares `--spacing-chrome-bleed: 1rem` and redeclares it as `2rem` inside
* its own breakpoint, so the consumer's breakpoints stay in the consumer.
* It defaults to `0rem`, so `bleed` with no token set draws the rule exactly
* where the border was.
*
* Orthogonal to `hairline`, which still decides whether an edge exists at
* all: under `surface="hero"` there is no hairline by default and `bleed` is
* a no-op. Composes with either `navPlacement`.
*/
bleed?: boolean | string
/**
* Whether the shell builds its own centred container. On by default, which
* is right when the bar spans the viewport: it centres the row at
* `--container-chrome` and pads it with `--spacing-chrome-gutter`.
*
* Set it to `false` when the shell is already inside a page container that
* centres and pads. Otherwise you get two containers nested, and the bar's
* contents sit inboard of everything else on the page by a full gutter. The
* only other way out is neutralising both tokens, which is GLOBAL: it moves
* every shell on the site, so a page with one nested bar and one full-width
* bar cannot express both. This prop is per-instance, which is where the
* decision actually belongs.
*
* Off, the shell still owns bar height, layout, surface and edge, and still
* spans its parent (`w-full`). The caller owns width and gutter.
*
* Orthogonal to `bleed`. That one moves the EDGE out past a container's
* padding; this one declines to build the container at all. A shell inside a
* padded page container usually wants `container={false}` for the contents
* and `bleed` for the rule.
*/
container?: boolean
className?: string
children?: React.ReactNode
}) {
// Structure the shell always owns, then the container it owns only when the
// caller has not already built one.
const containerClass = container ? "mx-auto max-w-chrome px-chrome-gutter" : ""
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 bledEdge = showHairline && Boolean(bleed)
const bleedWidth =
typeof bleed === "string" ? bleed : "var(--spacing-chrome-bleed, 0rem)"
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(
// No reduced-motion guard: a colour fade moves nothing on screen, so it
// is not vestibular and stays on at every motion preference.
"w-full transition-colors duration-200",
sticky && "sticky top-0 z-40",
showHairline && !bledEdge && "border-b border-border",
bledEdge && "relative",
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
? // reduce-transparency: goes opaque and drops the blur. The
// hairline (drawn above, always on for this surface) already
// reads as the edge at rest, so the scrolled state only needs
// its fill to stop being see-through, not a new signal.
"bg-background-translucent backdrop-blur-md reduce-transparency:bg-background reduce-transparency:backdrop-blur-none"
: "bg-transparent"),
className
)}
>
{bledEdge ? (
// The edge, drawn on its own box instead of on the shell. Absolutely
// positioned so it takes no height and cannot shift the bar's
// contents, then pulled out past the page container's padding by a
// negative inline margin: `inset-x-0` pins it to both edges and the
// margins move those pins outward, which is why the rule can be wider
// than its own parent without the parent gaining a horizontal
// overflow it did not ask for.
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-px bg-border"
style={{ marginInline: `calc(-1 * ${bleedWidth})` }}
/>
) : null}
{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, which is why
// reduce-transparency: only has to drop the blur: the tint alone
// already clears 4.5:1 by a wide margin, so there is nothing to
// re-measure. The mask and the 60% tint stay as-is.
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-(--spacing-site-header-scrim) bg-black/60 backdrop-blur-md reduce-transparency:backdrop-blur-none 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={cn(
"relative grid h-(--spacing-site-header) w-full grid-cols-[1fr_auto_1fr] items-center gap-4",
containerClass
)}
>
<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={cn(
"relative flex h-(--spacing-site-header) w-full items-center gap-4",
containerClass
)}
>
{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 }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"
// 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.
//
// Every note in this file sits DOWN HERE rather than above the constants it
// describes, because the shadcn CLI drops everything above a file's first
// statement when it installs it. Written at the top, none of this reaches the
// consumer it is written for.
//
// WHAT THESE ARE. 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.
export { siteHeaderNavItemClass, siteHeaderNavItemActiveClass }