Blocks · Navigation
Header bar
Brand, a row of links and one primary button; under 672px of its own width the links fold into a Menu disclosure (Escape closes it).
71% of the 2,073 sites in The UI Index have a header (1,473 sites).
Both previews render the same markup; the HTML tab below is that markup without a framework.
Install
- React
- npx shadcn@
latest add https:// www.themes.dev/ ui/ r/ header-bar.json - Astro
- npx shadcn@
latest add https:// www.themes.dev/ ui/ r/ astro/ header-bar.json
Or copy the files below into your project. They need clsx, tailwind-merge and the components they import, and the themes.dev UI theme. The HTML needs only the compiled CSS and the script.
/** The header bar: the brand, a row of links and one primary button. Wide enough (672px of the block's own width,
* a container query) they sit in one row; narrower, the links and the button fold into a panel under the bar behind
* a Menu button (a disclosure: Escape closes it, following a link closes it). Without JavaScript the panel simply
* shows under the bar, so every link is reachable. */
export const headerBar = {
root: 'group/header @container/header relative z-40 border-b bg-background text-foreground',
bar: 'mx-auto flex max-w-6xl flex-wrap items-center gap-x-4 px-6 py-3 @2xl/header:flex-nowrap',
brand: '-mx-2 flex h-10 shrink-0 items-center gap-2 rounded-md px-2 text-[15px] font-semibold tracking-[-0.02em] transition-opacity hover:opacity-75 active:opacity-60',
// Only shown once the script has marked the header (data-js), and only below the fold.
toggle:
'group ml-auto hidden h-10 cursor-pointer items-center gap-2 rounded-md border border-input px-3 text-sm font-medium transition-[background-color,transform] hover:bg-accent active:scale-[0.97] active:bg-pressed aria-expanded:bg-accent group-data-[js]/header:@max-2xl/header:inline-flex [&_svg]:size-4',
toggleOpen: 'group-aria-expanded:hidden',
toggleClose: 'hidden group-aria-expanded:block',
menu: 'flex basis-full flex-col gap-4 pb-3 @max-2xl/header:data-collapsed:hidden @2xl/header:basis-auto @2xl/header:flex-1 @2xl/header:flex-row @2xl/header:items-center @2xl/header:gap-6 @2xl/header:pb-0',
nav: '@2xl/header:ml-2',
list: 'm-0 flex list-none flex-col p-0 @2xl/header:flex-row @2xl/header:items-center @2xl/header:gap-1',
link: 'flex min-h-12 items-center rounded-md px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:bg-pressed active:text-foreground aria-[current=page]:text-foreground aria-[current=page]:underline aria-[current=page]:decoration-2 aria-[current=page]:underline-offset-8 @2xl/header:min-h-10 @2xl/header:px-3 @2xl/header:hover:bg-accent @2xl/header:active:bg-pressed',
action: 'flex flex-col gap-2 @2xl/header:ml-auto @2xl/header:flex-row @2xl/header:items-center',
};
export type HeaderBarLink = { label: string; href: string };
export type HeaderBarContent = {
brand: { label: string; href: string };
links?: HeaderBarLink[];
/** The one primary button. */
action?: { label: string; href: string };
/** The link whose href equals this is the current page. */
currentPath?: string;
/** The nav's name for screen readers. Default "Main". */
label?: string;
/** The Menu button's text below the fold. Default "Menu". */
menuLabel?: string;
/** Unique on the page; ids the menu. */
id?: string;
};// Header bar behaviour, shared by the Astro, React and HTML versions: the Menu button below the fold. The server HTML
// shows the menu under the bar, so without JavaScript every link is there; enhanceHeaderBar() marks the header with
// data-js (which shows the Menu button) and collapses the menu (data-collapsed). A click (or Enter/Space) opens and
// closes it; Escape closes it and, when focus is inside the header, returns focus to the button; following a link in it closes it.
declare global {
interface Window {
__caseroomHeaderBar?: boolean;
}
}
const ROOT = '[data-header-bar]';
const setMenu = (root: Element, open: boolean, focus = false) => {
const toggle = root.querySelector<HTMLElement>('[data-header-bar-toggle]');
toggle?.setAttribute('aria-expanded', String(open));
root.querySelectorAll('[data-header-bar-menu]').forEach((m) => m.toggleAttribute('data-collapsed', !open));
if (focus) toggle?.focus();
};
export function enhanceHeaderBar(root: Element) {
if (root.hasAttribute('data-js')) return;
root.setAttribute('data-js', '');
root.querySelectorAll('[data-header-bar-menu]').forEach((m) => m.setAttribute('data-collapsed', ''));
}
if (typeof window !== 'undefined' && !window.__caseroomHeaderBar) {
window.__caseroomHeaderBar = true;
document.querySelectorAll(ROOT).forEach(enhanceHeaderBar);
document.addEventListener('click', (e) => {
const target = e.target as Element;
const root = target.closest(ROOT);
if (!root?.hasAttribute('data-js')) return;
const toggle = target.closest('[data-header-bar-toggle]');
if (toggle) return setMenu(root, toggle.getAttribute('aria-expanded') !== 'true');
if (target.closest('[data-header-bar-menu] a')) setMenu(root, false);
});
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
document.querySelectorAll(`${ROOT}[data-js]`).forEach((root) => {
if (root.querySelector('[data-header-bar-toggle][aria-expanded="true"]')) setMenu(root, false, root.contains(document.activeElement));
});
});
}
export {};import { useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/ui/icon';
import { cn } from '@/lib/utils';
import { enhanceHeaderBar } from './header-bar.client';
import { headerBar as c, type HeaderBarContent } from './header-bar.classes';
export type HeaderBarProps = HeaderBarContent & { className?: string };
/** The site's top bar: brand, links and one primary button; below 672px of its own width the links fold behind a Menu button. */
export function HeaderBar({ brand, links = [], action, currentPath, label = 'Main', menuLabel = 'Menu', id = 'header-bar', className }: HeaderBarProps) {
const ref = useRef<HTMLElement>(null);
useEffect(() => {
if (ref.current) enhanceHeaderBar(ref.current);
}, []);
return (
<header ref={ref} data-header-bar="" className={cn(c.root, className)}>
<div className={c.bar}>
<a href={brand.href} className={c.brand}>{brand.label}</a>
<button type="button" aria-expanded="false" aria-controls={`${id}-menu`} data-header-bar-toggle="" className={c.toggle}>
<Icon name="menu" className={c.toggleOpen} />
<Icon name="x" className={c.toggleClose} />
{menuLabel}
</button>
<div id={`${id}-menu`} data-header-bar-menu="" className={c.menu}>
{links.length > 0 && (
<nav aria-label={label} className={c.nav}>
<ul role="list" className={c.list}>
{links.map((l) => (
<li key={l.href}>
<a href={l.href} aria-current={l.href === currentPath ? 'page' : undefined} className={c.link}>{l.label}</a>
</li>
))}
</ul>
</nav>
)}
{action && <div className={c.action}><Button href={action.href}>{action.label}</Button></div>}
</div>
</div>
</header>
);
}---
import Button from '@/components/ui/button.astro';
import Icon from '@/components/ui/icon.astro';
import { cn } from '@/lib/utils';
import { headerBar as c, type HeaderBarContent } from './header-bar.classes';
type Props = HeaderBarContent & { class?: string };
const { brand, links = [], action, currentPath, label = 'Main', menuLabel = 'Menu', id = 'header-bar', class: className } = Astro.props;
---
<header data-header-bar class={cn(c.root, className)}>
<div class={c.bar}>
<a href={brand.href} class={c.brand}>{brand.label}</a>
<button type="button" aria-expanded="false" aria-controls={`${id}-menu`} data-header-bar-toggle class={c.toggle}>
<Icon name="menu" class={c.toggleOpen} />
<Icon name="x" class={c.toggleClose} />
{menuLabel}
</button>
<div id={`${id}-menu`} data-header-bar-menu class={c.menu}>
{
links.length > 0 && (
<nav aria-label={label} class={c.nav}>
<ul role="list" class={c.list}>
{links.map((l) => (
<li>
<a href={l.href} aria-current={l.href === currentPath ? 'page' : undefined} class={c.link}>{l.label}</a>
</li>
))}
</ul>
</nav>
)
}
{action && <div class={c.action}><Button href={action.href}>{action.label}</Button></div>}
</div>
</div>
</header>
<script>
import './header-bar.client';
</script>/** The header bar: the brand, a row of links and one primary button. Wide enough (672px of the block's own width,
* a container query) they sit in one row; narrower, the links and the button fold into a panel under the bar behind
* a Menu button (a disclosure: Escape closes it, following a link closes it). Without JavaScript the panel simply
* shows under the bar, so every link is reachable. */
export const headerBar = {
root: 'group/header @container/header relative z-40 border-b bg-background text-foreground',
bar: 'mx-auto flex max-w-6xl flex-wrap items-center gap-x-4 px-6 py-3 @2xl/header:flex-nowrap',
brand: '-mx-2 flex h-10 shrink-0 items-center gap-2 rounded-md px-2 text-[15px] font-semibold tracking-[-0.02em] transition-opacity hover:opacity-75 active:opacity-60',
// Only shown once the script has marked the header (data-js), and only below the fold.
toggle:
'group ml-auto hidden h-10 cursor-pointer items-center gap-2 rounded-md border border-input px-3 text-sm font-medium transition-[background-color,transform] hover:bg-accent active:scale-[0.97] active:bg-pressed aria-expanded:bg-accent group-data-[js]/header:@max-2xl/header:inline-flex [&_svg]:size-4',
toggleOpen: 'group-aria-expanded:hidden',
toggleClose: 'hidden group-aria-expanded:block',
menu: 'flex basis-full flex-col gap-4 pb-3 @max-2xl/header:data-collapsed:hidden @2xl/header:basis-auto @2xl/header:flex-1 @2xl/header:flex-row @2xl/header:items-center @2xl/header:gap-6 @2xl/header:pb-0',
nav: '@2xl/header:ml-2',
list: 'm-0 flex list-none flex-col p-0 @2xl/header:flex-row @2xl/header:items-center @2xl/header:gap-1',
link: 'flex min-h-12 items-center rounded-md px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:bg-pressed active:text-foreground aria-[current=page]:text-foreground aria-[current=page]:underline aria-[current=page]:decoration-2 aria-[current=page]:underline-offset-8 @2xl/header:min-h-10 @2xl/header:px-3 @2xl/header:hover:bg-accent @2xl/header:active:bg-pressed',
action: 'flex flex-col gap-2 @2xl/header:ml-auto @2xl/header:flex-row @2xl/header:items-center',
};
export type HeaderBarLink = { label: string; href: string };
export type HeaderBarContent = {
brand: { label: string; href: string };
links?: HeaderBarLink[];
/** The one primary button. */
action?: { label: string; href: string };
/** The link whose href equals this is the current page. */
currentPath?: string;
/** The nav's name for screen readers. Default "Main". */
label?: string;
/** The Menu button's text below the fold. Default "Menu". */
menuLabel?: string;
/** Unique on the page; ids the menu. */
id?: string;
};// Header bar behaviour, shared by the Astro, React and HTML versions: the Menu button below the fold. The server HTML
// shows the menu under the bar, so without JavaScript every link is there; enhanceHeaderBar() marks the header with
// data-js (which shows the Menu button) and collapses the menu (data-collapsed). A click (or Enter/Space) opens and
// closes it; Escape closes it and, when focus is inside the header, returns focus to the button; following a link in it closes it.
declare global {
interface Window {
__caseroomHeaderBar?: boolean;
}
}
const ROOT = '[data-header-bar]';
const setMenu = (root: Element, open: boolean, focus = false) => {
const toggle = root.querySelector<HTMLElement>('[data-header-bar-toggle]');
toggle?.setAttribute('aria-expanded', String(open));
root.querySelectorAll('[data-header-bar-menu]').forEach((m) => m.toggleAttribute('data-collapsed', !open));
if (focus) toggle?.focus();
};
export function enhanceHeaderBar(root: Element) {
if (root.hasAttribute('data-js')) return;
root.setAttribute('data-js', '');
root.querySelectorAll('[data-header-bar-menu]').forEach((m) => m.setAttribute('data-collapsed', ''));
}
if (typeof window !== 'undefined' && !window.__caseroomHeaderBar) {
window.__caseroomHeaderBar = true;
document.querySelectorAll(ROOT).forEach(enhanceHeaderBar);
document.addEventListener('click', (e) => {
const target = e.target as Element;
const root = target.closest(ROOT);
if (!root?.hasAttribute('data-js')) return;
const toggle = target.closest('[data-header-bar-toggle]');
if (toggle) return setMenu(root, toggle.getAttribute('aria-expanded') !== 'true');
if (target.closest('[data-header-bar-menu] a')) setMenu(root, false);
});
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
document.querySelectorAll(`${ROOT}[data-js]`).forEach((root) => {
if (root.querySelector('[data-header-bar-toggle][aria-expanded="true"]')) setMenu(root, false, root.contains(document.activeElement));
});
});
}
export {};<div class="grid gap-6 pb-6">
<header data-header-bar class="group/header @container/header relative z-40 border-b bg-background text-foreground">
<div class="mx-auto flex max-w-6xl flex-wrap items-center gap-x-4 px-6 py-3 @2xl/header:flex-nowrap">
<a href="#" class="-mx-2 flex h-10 shrink-0 items-center gap-2 rounded-md px-2 text-[15px] font-semibold tracking-[-0.02em] transition-opacity hover:opacity-75 active:opacity-60">
Northwind
</a>
<button type="button" aria-expanded="false" aria-controls="ex-header-bar-menu" data-header-bar-toggle class="group ml-auto hidden h-10 cursor-pointer items-center gap-2 rounded-md border border-input px-3 text-sm font-medium transition-[background-color,transform] hover:bg-accent active:scale-[0.97] active:bg-pressed aria-expanded:bg-accent group-data-[js]/header:@max-2xl/header:inline-flex [&_svg]:size-4">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="inline-block size-4 shrink-0 group-aria-expanded:hidden"><path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"></path></svg>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="size-4 shrink-0 hidden group-aria-expanded:block"><path d="M4 4l8 8M12 4l-8 8"></path></svg>
Menu
</button>
<div id="ex-header-bar-menu" data-header-bar-menu class="flex basis-full flex-col gap-4 pb-3 @max-2xl/header:data-collapsed:hidden @2xl/header:basis-auto @2xl/header:flex-1 @2xl/header:flex-row @2xl/header:items-center @2xl/header:gap-6 @2xl/header:pb-0">
<nav aria-label="Main" class="@2xl/header:ml-2">
<ul role="list" class="m-0 flex list-none flex-col p-0 @2xl/header:flex-row @2xl/header:items-center @2xl/header:gap-1">
<li>
<a href="#product" class="flex min-h-12 items-center rounded-md px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:bg-pressed active:text-foreground aria-[current=page]:text-foreground aria-[current=page]:underline aria-[current=page]:decoration-2 aria-[current=page]:underline-offset-8 @2xl/header:min-h-10 @2xl/header:px-3 @2xl/header:hover:bg-accent @2xl/header:active:bg-pressed">
Product
</a>
</li>
<li>
<a href="#pricing" aria-current="page" class="flex min-h-12 items-center rounded-md px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:bg-pressed active:text-foreground aria-[current=page]:text-foreground aria-[current=page]:underline aria-[current=page]:decoration-2 aria-[current=page]:underline-offset-8 @2xl/header:min-h-10 @2xl/header:px-3 @2xl/header:hover:bg-accent @2xl/header:active:bg-pressed">
Pricing
</a>
</li>
<li>
<a href="#docs" class="flex min-h-12 items-center rounded-md px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:bg-pressed active:text-foreground aria-[current=page]:text-foreground aria-[current=page]:underline aria-[current=page]:decoration-2 aria-[current=page]:underline-offset-8 @2xl/header:min-h-10 @2xl/header:px-3 @2xl/header:hover:bg-accent @2xl/header:active:bg-pressed">
Docs
</a>
</li>
<li>
<a href="#changelog" class="flex min-h-12 items-center rounded-md px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:bg-pressed active:text-foreground aria-[current=page]:text-foreground aria-[current=page]:underline aria-[current=page]:decoration-2 aria-[current=page]:underline-offset-8 @2xl/header:min-h-10 @2xl/header:px-3 @2xl/header:hover:bg-accent @2xl/header:active:bg-pressed">
Changelog
</a>
</li>
</ul>
</nav>
<div class="flex flex-col gap-2 @2xl/header:ml-auto @2xl/header:flex-row @2xl/header:items-center">
<a href="#start" class="inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,transform] select-none active:scale-[0.97] aria-busy:cursor-progress disabled:pointer-events-none disabled:opacity-50 aria-busy:disabled:opacity-80 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary)_var(--hover-amount),var(--background))] active:bg-[color-mix(in_oklab,var(--primary)_var(--press-amount),var(--press-mix))] h-9 px-4">
Start free
</a>
</div>
</div>
</div>
</header>
<div class="mx-6 max-w-sm rounded-lg border">
<header data-header-bar class="group/header @container/header relative z-40 border-b bg-background text-foreground">
<div class="mx-auto flex max-w-6xl flex-wrap items-center gap-x-4 px-6 py-3 @2xl/header:flex-nowrap">
<a href="#" class="-mx-2 flex h-10 shrink-0 items-center gap-2 rounded-md px-2 text-[15px] font-semibold tracking-[-0.02em] transition-opacity hover:opacity-75 active:opacity-60">
Northwind
</a>
<button type="button" aria-expanded="false" aria-controls="ex-header-bar-narrow-menu" data-header-bar-toggle class="group ml-auto hidden h-10 cursor-pointer items-center gap-2 rounded-md border border-input px-3 text-sm font-medium transition-[background-color,transform] hover:bg-accent active:scale-[0.97] active:bg-pressed aria-expanded:bg-accent group-data-[js]/header:@max-2xl/header:inline-flex [&_svg]:size-4">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="inline-block size-4 shrink-0 group-aria-expanded:hidden"><path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11"></path></svg>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" class="size-4 shrink-0 hidden group-aria-expanded:block"><path d="M4 4l8 8M12 4l-8 8"></path></svg>
Menu
</button>
<div id="ex-header-bar-narrow-menu" data-header-bar-menu class="flex basis-full flex-col gap-4 pb-3 @max-2xl/header:data-collapsed:hidden @2xl/header:basis-auto @2xl/header:flex-1 @2xl/header:flex-row @2xl/header:items-center @2xl/header:gap-6 @2xl/header:pb-0">
<nav aria-label="Main, narrow" class="@2xl/header:ml-2">
<ul role="list" class="m-0 flex list-none flex-col p-0 @2xl/header:flex-row @2xl/header:items-center @2xl/header:gap-1">
<li>
<a href="#product" class="flex min-h-12 items-center rounded-md px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:bg-pressed active:text-foreground aria-[current=page]:text-foreground aria-[current=page]:underline aria-[current=page]:decoration-2 aria-[current=page]:underline-offset-8 @2xl/header:min-h-10 @2xl/header:px-3 @2xl/header:hover:bg-accent @2xl/header:active:bg-pressed">
Product
</a>
</li>
<li>
<a href="#pricing" aria-current="page" class="flex min-h-12 items-center rounded-md px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:bg-pressed active:text-foreground aria-[current=page]:text-foreground aria-[current=page]:underline aria-[current=page]:decoration-2 aria-[current=page]:underline-offset-8 @2xl/header:min-h-10 @2xl/header:px-3 @2xl/header:hover:bg-accent @2xl/header:active:bg-pressed">
Pricing
</a>
</li>
<li>
<a href="#docs" class="flex min-h-12 items-center rounded-md px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:bg-pressed active:text-foreground aria-[current=page]:text-foreground aria-[current=page]:underline aria-[current=page]:decoration-2 aria-[current=page]:underline-offset-8 @2xl/header:min-h-10 @2xl/header:px-3 @2xl/header:hover:bg-accent @2xl/header:active:bg-pressed">
Docs
</a>
</li>
<li>
<a href="#changelog" class="flex min-h-12 items-center rounded-md px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground active:bg-pressed active:text-foreground aria-[current=page]:text-foreground aria-[current=page]:underline aria-[current=page]:decoration-2 aria-[current=page]:underline-offset-8 @2xl/header:min-h-10 @2xl/header:px-3 @2xl/header:hover:bg-accent @2xl/header:active:bg-pressed">
Changelog
</a>
</li>
</ul>
</nav>
<div class="flex flex-col gap-2 @2xl/header:ml-auto @2xl/header:flex-row @2xl/header:items-center">
<a href="#start" class="inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,transform] select-none active:scale-[0.97] aria-busy:cursor-progress disabled:pointer-events-none disabled:opacity-50 aria-busy:disabled:opacity-80 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 bg-primary text-primary-foreground hover:bg-[color-mix(in_oklab,var(--primary)_var(--hover-amount),var(--background))] active:bg-[color-mix(in_oklab,var(--primary)_var(--press-amount),var(--press-mix))] h-9 px-4">
Start free
</a>
</div>
</div>
</div>
</header>
</div>
</div><script type="module" src="/ui/r/html/header-bar.js"></script>