# Site Header

> A marketing site's top navigation with a skip link and a drawer on phones: a plain row of links, panels of grouped links, or pieces floating over the page.

A shadcn/ui block from the [@miseui](https://miseui.junsu4621.workers.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/miseui/site-header).

- Page, with a live preview: https://shadcn.exchange/r/miseui/site-header
- Screenshot: https://shadcn.exchange/shot/miseui/site-header/light.png
- Registry item: https://miseui.junsu4621.workers.dev/r/site-header.json
- Categories: blocks
- Listed: 2026-11-05, source last changed 2026-11-05

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/miseui/site-header.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `utils`, `https://miseui.junsu4621.workers.dev/r/button.json`, `https://miseui.junsu4621.workers.dev/r/drawer.json`, `https://miseui.junsu4621.workers.dev/r/floating-nav.json`, `https://miseui.junsu4621.workers.dev/r/navigation-menu.json`

## Files

### `registry/miseui/blocks/site-header.tsx`

```tsx
import type * as React from "react"
import { useId, useState } from "react"
import { cn } from "@/registry/miseui/lib/utils"
import { HeaderBar } from "./site-header-bar"
import { HeaderFloating } from "./site-header-floating"
import { HeaderMega } from "./site-header-mega"
import { SkipLink, type HeaderContent, type SiteHeaderPosition } from "./site-header-parts"
import { SAMPLE_LINKS, SAMPLE_MENU_LINKS, SAMPLE_MENUS, type SiteHeaderAction, type SiteHeaderLink, type SiteHeaderMenu } from "./site-header-sample"

type SiteHeaderVariant = "bar" | "mega" | "floating"

const DEFAULT_PRIMARY: SiteHeaderAction = { label: "Get started", href: "#get-started" }
const DEFAULT_SECONDARY: SiteHeaderAction = { label: "Log in", href: "#log-in" }

interface SiteHeaderProps extends React.ComponentProps<"div"> {
  /** `bar` a plain row of links, `mega` panels of grouped links, `floating` pieces over the page. All become a drawer on phones. Anything else is `bar`. */
  variant?: SiteHeaderVariant
  brandName?: React.ReactNode
  brandHref?: string
  /** The links in the row. On `mega` they sit after the menus. */
  links?: readonly SiteHeaderLink[]
  /** `mega` and its drawer only. */
  menus?: readonly SiteHeaderMenu[]
  /** Null removes it. */
  primaryAction?: SiteHeaderAction | null
  /** Null removes it. */
  secondaryAction?: SiteHeaderAction | null
  /** The href of the current page, usually from your router. Left out, the last link pressed is marked. */
  currentHref?: string
  defaultCurrentHref?: string
  onNavigate?: (href: string) => void
  /** `sticky` stays inside this block, which is what you want in a frame or a preview. `fixed` attaches to the window. */
  position?: SiteHeaderPosition
  navLabel?: string
  skipLabel?: string
  /** The id of your main element, when it is not inside this block. Children get a main with this id. */
  mainId?: string
  /** The page. It is wrapped in a main, the target of the skip link. */
  children?: React.ReactNode
}

const MAIN_CLASS: Record<SiteHeaderVariant, Record<SiteHeaderPosition, string>> = {
  bar: { sticky: "", fixed: "pt-14" },
  mega: { sticky: "", fixed: "pt-14" },
  // Nothing holds the row's height, so the page starts clear of it unless the owner wants to run underneath.
  floating: { sticky: "pt-16", fixed: "pt-16" },
}

function chooseView(variant: string | undefined): SiteHeaderVariant {
  return variant === "mega" || variant === "floating" ? variant : "bar"
}

function SiteHeader({ variant, brandName = "Paperline", brandHref = "#top", links, menus = SAMPLE_MENUS, primaryAction = DEFAULT_PRIMARY, secondaryAction = DEFAULT_SECONDARY, currentHref, defaultCurrentHref, onNavigate, position = "sticky", navLabel = "Main", skipLabel = "Skip to content", mainId, className, children, ...props }: SiteHeaderProps) {
  const generatedId = useId()
  const targetId = mainId ?? generatedId
  const [lastPressed, setLastPressed] = useState(defaultCurrentHref)
  const view = chooseView(variant)
  const content: HeaderContent = {
    brandName,
    brandHref,
    links: links ?? (view === "mega" ? SAMPLE_MENU_LINKS : SAMPLE_LINKS),
    menus: view === "mega" ? menus : [],
    primaryAction,
    secondaryAction,
    current: currentHref ?? lastPressed,
    onSelect: (href) => {
      setLastPressed(href)
      onNavigate?.(href)
    },
    navLabel,
    position,
  }
  const hasPage = children !== undefined && children !== null
  return (
    <div data-slot="site-header" data-variant={view} data-position={position} className={cn("@container relative isolate w-full bg-background", className)} {...props}>
      {/* Only when there is something to skip to. */}
      {hasPage || mainId ? <SkipLink targetId={targetId} label={skipLabel} /> : null}
      {view === "bar" ? <HeaderBar content={content} /> : null}
      {view === "mega" ? <HeaderMega content={content} /> : null}
      {view === "floating" ? <HeaderFloating content={content} /> : null}
      {hasPage ? (
        <main id={targetId} tabIndex={-1} data-slot="site-header-main" className={cn("outline-none", MAIN_CLASS[view][position])}>
          {children}
        </main>
      ) : null}
    </div>
  )
}

export { SiteHeader, type SiteHeaderAction, type SiteHeaderLink, type SiteHeaderMenu, type SiteHeaderPosition, type SiteHeaderProps, type SiteHeaderVariant }
```

### `registry/miseui/blocks/site-header-bar.tsx`

```tsx
import { cn } from "@/registry/miseui/lib/utils"
import { GLASS_CLASS } from "@/registry/miseui/ui/floating-nav-views"
import { HeaderAction, HeaderBrand, HeaderDrawer, positionClass, type HeaderContent } from "./site-header-parts"

const LINK_CLASS = "inline-flex h-9 items-center rounded-md px-3 text-[13px] font-medium whitespace-nowrap outline-none transition-colors duration-(--duration-fast) ease-(--ease-mise) focus-visible:ring-[3px] focus-visible:ring-ring/50 pointer-coarse:h-10"

/** A plain row, for a site with a handful of pages. Every link stays one press away, and nothing opens over the page. */
function HeaderBar({ content }: { content: HeaderContent }) {
  const { brandName, brandHref, links, primaryAction, secondaryAction, current, onSelect, navLabel, position } = content
  return (
    <header data-slot="site-header-bar" className={cn("z-40 border-b", GLASS_CLASS, positionClass(position))}>
      <div className="mx-auto flex h-14 max-w-6xl items-center gap-4 px-4 @3xl:gap-6 @3xl:px-6">
        <HeaderBrand name={brandName} href={brandHref} />
        <nav aria-label={navLabel} className="hidden min-w-0 @3xl:block">
          <ul className="flex items-center gap-1">
            {links.map((link) => (
              <li key={link.href}>
                <a
                  href={link.href}
                  aria-current={current === link.href ? "page" : undefined}
                  onClick={() => onSelect(link.href)}
                  className={cn(LINK_CLASS, current === link.href ? "text-foreground" : "text-muted-foreground hover:text-foreground")}
                >
                  {link.label}
                </a>
              </li>
            ))}
          </ul>
        </nav>
        <div className="ms-auto flex items-center gap-2">
          {secondaryAction ? <HeaderAction action={secondaryAction} variant="ghost" className="hidden @3xl:inline-flex" /> : null}
          {primaryAction ? <HeaderAction action={primaryAction} variant="mise" /> : null}
          <HeaderDrawer content={content} className="-me-2 @3xl:hidden" />
        </div>
      </div>
    </header>
  )
}

export { HeaderBar }
```

### `registry/miseui/blocks/site-header-floating.tsx`

```tsx
import { cn } from "@/registry/miseui/lib/utils"
import { FloatingNav } from "@/registry/miseui/ui/floating-nav"
import { GLASS_CLASS } from "@/registry/miseui/ui/floating-nav-views"
import { HeaderAction, HeaderBrand, HeaderDrawer, positionClass, type HeaderContent } from "./site-header-parts"

const CHIP_CLASS = "pointer-events-auto rounded-full shadow-md ring-1 shadow-black/5 ring-foreground/10 dark:shadow-black/40"

/**
 * Pieces on the page instead of a bar across it, for a site whose first screen is a picture worth keeping whole.
 * The row has no height of its own, so the page runs under it, and it sticks inside the block rather than the window.
 * The accent is the active link's highlight, so the action stays in ink.
 */
function HeaderFloating({ content }: { content: HeaderContent }) {
  const { brandName, brandHref, links, primaryAction, secondaryAction, current, onSelect, navLabel, position } = content
  const items = links.map((link) => ({ value: link.href, label: link.label, href: link.href }))
  return (
    <header data-slot="site-header-floating" className={cn("pointer-events-none z-40 h-0", positionClass(position))}>
      <div className="relative mx-auto flex max-w-6xl items-start justify-between gap-2 px-3 pt-3 @3xl:px-4">
        <HeaderBrand name={brandName} href={brandHref} className={cn("px-4 pointer-coarse:min-h-12", CHIP_CLASS, GLASS_CLASS)} />
        {/* Above any page, in the middle of the row. On a phone the drawer takes over, so the pill does not have to scroll. */}
        <FloatingNav label={navLabel} variant="pill" position="absolute" items={items} value={current ?? ""} onValueChange={onSelect} className="top-3 hidden @3xl:flex" />
        <div className={cn("flex items-center gap-1 p-1 @3xl:gap-2", CHIP_CLASS, GLASS_CLASS)}>
          {secondaryAction ? <HeaderAction action={secondaryAction} variant="ghost" className="hidden h-8 rounded-full @5xl:inline-flex" /> : null}
          {primaryAction ? <HeaderAction action={primaryAction} variant="default" className="h-8 rounded-full" /> : null}
          <HeaderDrawer content={content} className="-my-1 rounded-full @3xl:hidden" />
        </div>
      </div>
    </header>
  )
}

export { HeaderFloating }
```

### `registry/miseui/blocks/site-header-mega.tsx`

```tsx
import { cn } from "@/registry/miseui/lib/utils"
import { GLASS_CLASS } from "@/registry/miseui/ui/floating-nav-views"
import {
  NavigationMenu,
  NavigationMenuContent,
  NavigationMenuGroup,
  NavigationMenuItem,
  NavigationMenuLink,
  NavigationMenuList,
  NavigationMenuPanelLink,
  NavigationMenuTrigger,
} from "@/registry/miseui/ui/navigation-menu"
import { HeaderAction, HeaderBrand, HeaderDrawer, positionClass, type HeaderContent } from "./site-header-parts"

/** The positioner spans the whole row, so a panel shares the header's width and keeps its own margin from the edge. */
const PANEL_ROOM_CLASS = "[&_[data-slot=navigation-menu-positioner]]:px-2 @3xl:[&_[data-slot=navigation-menu-positioner]]:px-4"

function MegaMenus({ content }: { content: HeaderContent }) {
  const { menus, links, current, onSelect } = content
  return (
    <NavigationMenuList className="hidden flex-nowrap @3xl:flex">
      {menus.map((menu) => (
        <NavigationMenuItem key={menu.label} value={menu.label}>
          <NavigationMenuTrigger>{menu.label}</NavigationMenuTrigger>
          <NavigationMenuContent>
            {menu.groups.map((group) => (
              <NavigationMenuGroup key={group.title} title={group.title}>
                {group.links.map((link) => (
                  <NavigationMenuPanelLink key={link.href} href={link.href} title={link.label} description={link.description} active={current === link.href} onClick={() => onSelect(link.href)} />
                ))}
              </NavigationMenuGroup>
            ))}
          </NavigationMenuContent>
        </NavigationMenuItem>
      ))}
      {links.map((link) => (
        <NavigationMenuItem key={link.href}>
          <NavigationMenuLink href={link.href} active={current === link.href} onClick={() => onSelect(link.href)}>
            {link.label}
          </NavigationMenuLink>
        </NavigationMenuItem>
      ))}
    </NavigationMenuList>
  )
}

/** For a product with more pages than a row can hold. The panels name where each link goes, so nobody has to open a page to find out. */
function HeaderMega({ content }: { content: HeaderContent }) {
  const { brandName, brandHref, primaryAction, secondaryAction, navLabel, position } = content
  return (
    <header data-slot="site-header-mega" className={cn("z-40 border-b", GLASS_CLASS, positionClass(position))}>
      <NavigationMenu variant="mega" effect="materialize" aria-label={navLabel} className={cn("mx-auto flex h-14 max-w-6xl items-center gap-4 px-4 @3xl:gap-6 @3xl:px-6", PANEL_ROOM_CLASS)}>
        <HeaderBrand name={brandName} href={brandHref} />
        <MegaMenus content={content} />
        <div className="ms-auto flex items-center gap-2">
          {secondaryAction ? <HeaderAction action={secondaryAction} variant="ghost" className="hidden @3xl:inline-flex" /> : null}
          {primaryAction ? <HeaderAction action={primaryAction} variant="mise" /> : null}
          <HeaderDrawer content={content} className="-me-2 @3xl:hidden" />
        </div>
      </NavigationMenu>
    </header>
  )
}

export { HeaderMega }
```

### `registry/miseui/blocks/site-header-parts.tsx`

```tsx
import type * as React from "react"
import { useState } from "react"
import { Menu } from "lucide-react"
import { cn } from "@/registry/miseui/lib/utils"
import { Button } from "@/registry/miseui/ui/button"
import { Drawer, DrawerBody, DrawerContent, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger } from "@/registry/miseui/ui/drawer"
import type { SiteHeaderAction, SiteHeaderLink, SiteHeaderMenu } from "./site-header-sample"

/** Sticks or stays fixed inside the block by default, so a frame around it contains the header. */
type SiteHeaderPosition = "sticky" | "fixed"

interface HeaderContent {
  brandName: React.ReactNode
  brandHref: string
  links: readonly SiteHeaderLink[]
  menus: readonly SiteHeaderMenu[]
  primaryAction: SiteHeaderAction | null
  secondaryAction: SiteHeaderAction | null
  current: string | undefined
  onSelect: (href: string) => void
  navLabel: string
  position: SiteHeaderPosition
}

function HeaderBrand({ name, href, className }: { name: React.ReactNode; href: string; className?: string }) {
  return (
    <a href={href} className={cn("inline-flex min-h-10 shrink-0 items-center rounded-md text-[15px] font-medium outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", className)}>
      {name}
    </a>
  )
}

function HeaderAction({ action, variant, className }: { action: SiteHeaderAction; variant: React.ComponentProps<typeof Button>["variant"]; className?: string }) {
  return (
    <Button asChild variant={variant} className={cn("pointer-coarse:h-10", className)}>
      <a href={action.href}>{action.label}</a>
    </Button>
  )
}

const DRAWER_LINK_CLASS = "flex min-h-11 items-center rounded-md px-3 text-[15px] outline-none transition-colors duration-(--duration-fast) ease-(--ease-mise) hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-inset aria-[current=page]:bg-accent aria-[current=page]:font-medium"

function DrawerLink({ link, current, onPress }: { link: SiteHeaderLink; current: string | undefined; onPress: (href: string) => void }) {
  return (
    <li>
      <a href={link.href} aria-current={current === link.href ? "page" : undefined} onClick={() => onPress(link.href)} className={DRAWER_LINK_CLASS}>
        {link.label}
      </a>
    </li>
  )
}

/** Everything the header hides on a phone, all open at once: tapping through folds to find one link costs more than a short scroll. */
function HeaderDrawer({ content, className }: { content: HeaderContent; className?: string }) {
  const [open, setOpen] = useState(false)
  const { brandName, links, menus, primaryAction, secondaryAction, current, onSelect, navLabel } = content
  const press = (href: string) => {
    onSelect(href)
    setOpen(false)
  }
  return (
    <Drawer open={open} onOpenChange={setOpen} side="right">
      <DrawerTrigger asChild>
        <Button variant="ghost" size="icon" aria-label="Open menu" className={cn("size-10", className)}>
          <Menu aria-hidden="true" />
        </Button>
      </DrawerTrigger>
      <DrawerContent aria-describedby={undefined}>
        <DrawerHeader>
          <DrawerTitle>{brandName}</DrawerTitle>
        </DrawerHeader>
        <DrawerBody>
          <nav aria-label={navLabel} className="flex flex-col gap-4">
            {menus.flatMap((menu) => menu.groups).map((group) => (
              <div key={group.title} className="flex flex-col gap-1">
                <p className="px-3 text-[13px] text-muted-foreground">{group.title}</p>
                <ul>
                  {group.links.map((link) => (
                    <DrawerLink key={link.href} link={link} current={current} onPress={press} />
                  ))}
                </ul>
              </div>
            ))}
            {links.length > 0 ? (
              <ul>
                {links.map((link) => (
                  <DrawerLink key={link.href} link={link} current={current} onPress={press} />
                ))}
              </ul>
            ) : null}
          </nav>
        </DrawerBody>
        {primaryAction || secondaryAction ? (
          <DrawerFooter className="flex-col sm:flex-col">
            {primaryAction ? <HeaderAction action={primaryAction} variant="mise" className="w-full" /> : null}
            {secondaryAction ? <HeaderAction action={secondaryAction} variant="outline" className="w-full" /> : null}
          </DrawerFooter>
        ) : null}
      </DrawerContent>
    </Drawer>
  )
}

/** A hash link would fight a hash router, and Safari does not move focus for it anyway. */
function SkipLink({ targetId, label }: { targetId: string; label: string }) {
  return (
    <a
      data-slot="site-header-skip"
      href={`#${targetId}`}
      onClick={(event) => {
        event.preventDefault()
        const target = document.getElementById(targetId)
        if (target && !target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1")
        target?.focus()
      }}
      className="sr-only focus:not-sr-only focus:absolute focus:start-2 focus:top-2 focus:z-50 focus:inline-flex focus:h-10 focus:items-center focus:rounded-md focus:bg-background focus:px-3 focus:text-sm focus:shadow-md focus:ring-[3px] focus:ring-ring/50 focus:outline-none"
    >
      {label}
    </a>
  )
}

function positionClass(position: SiteHeaderPosition) {
  return position === "fixed" ? "fixed inset-x-0 top-0" : "sticky top-0"
}

export { HeaderAction, HeaderBrand, HeaderDrawer, positionClass, SkipLink, type HeaderContent, type SiteHeaderPosition }
```

### `registry/miseui/blocks/site-header-sample.ts`

```ts
interface SiteHeaderLink {
  label: string
  href: string
  /** One line saying where it goes, shown in `mega` panels. */
  description?: string
}

interface SiteHeaderGroup {
  title: string
  links: readonly SiteHeaderLink[]
}

interface SiteHeaderMenu {
  label: string
  groups: readonly SiteHeaderGroup[]
}

interface SiteHeaderAction {
  label: string
  href: string
}

const SAMPLE_LINKS: readonly SiteHeaderLink[] = [
  { label: "Features", href: "#features" },
  { label: "Pricing", href: "#pricing" },
  { label: "Docs", href: "#docs" },
  { label: "Changelog", href: "#changelog" },
]

const SAMPLE_MENU_LINKS: readonly SiteHeaderLink[] = [{ label: "Pricing", href: "#pricing" }]

const SAMPLE_MENUS: readonly SiteHeaderMenu[] = [
  {
    label: "Product",
    groups: [
      {
        title: "Invoicing",
        links: [
          { label: "Invoices", href: "#invoices", description: "Send, track and resend." },
          { label: "Reminders", href: "#reminders", description: "Follow up without writing it." },
        ],
      },
      {
        title: "Getting paid",
        links: [
          { label: "Payment links", href: "#links", description: "A page anyone can pay on." },
          { label: "Bank transfers", href: "#transfers", description: "Matched to the right invoice." },
        ],
      },
      {
        title: "Books",
        links: [
          { label: "Reports", href: "#reports", description: "What came in, by month." },
          { label: "Exports", href: "#exports", description: "Files your accountant can open." },
        ],
      },
    ],
  },
  {
    label: "Resources",
    groups: [
      {
        title: "Learn",
        links: [
          { label: "Guides", href: "#guides", description: "From first invoice to year end." },
          { label: "API docs", href: "#docs", description: "For building it into your tools." },
        ],
      },
      {
        title: "Stay current",
        links: [
          { label: "Changelog", href: "#changelog", description: "What shipped this month." },
          { label: "Status", href: "#status", description: "Whether everything is up." },
        ],
      },
    ],
  },
]

export { SAMPLE_LINKS, SAMPLE_MENUS, SAMPLE_MENU_LINKS, type SiteHeaderAction, type SiteHeaderGroup, type SiteHeaderLink, type SiteHeaderMenu }
```
