Navigation
Top bar
A site or app header with brand, primary links, search and actions that folds into a menu when narrow.
- Built on
<header><nav>Container queriesaria-expanded@starting-style- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 3.3 kB
Code
import { Button } from "@/components/equip/button";
import { TopBar } from "@/components/equip/top-bar";
export default function Demo() {
return (
<TopBar
style={{ width: "100%", minWidth: 216 }}
brand={<a href="#home">Larder</a>}
links={[
{ label: "Product", href: "#product", current: true },
{ label: "Pricing", href: "#pricing" },
{ label: "Docs", href: "#docs" },
]}
actions={<Button size="s">Sign in</Button>}
/>
);
}Examples
Folding on a narrow bar
Under 640px of its own width the links and search fold into a panel behind the menu button; actions stay on the bar. Escape closes the panel and returns focus to the button.
Code
import { Button } from "@/components/equip/button";
import { SearchField } from "@/components/equip/search-field";
import { TopBar } from "@/components/equip/top-bar";
export default function Example() {
return (
<div style={{ width: "100%", maxWidth: 340, border: "1px solid var(--eq-line)", borderRadius: "var(--eq-radius-l)", overflow: "hidden" }}>
<TopBar
brand={<a href="#home">Field notes</a>}
links={[
{ label: "Latest", href: "#latest", current: true },
{ label: "Archive", href: "#archive" },
{ label: "Authors", href: "#authors" },
{ label: "About", href: "#about" },
]}
search={<SearchField label="Search articles" hideLabel placeholder="Search articles" />}
actions={<Button size="s" variant="accent">Subscribe</Button>}
/>
<p style={{ margin: 0, padding: 16, fontSize: "var(--eq-text-s)", color: "var(--eq-ink-2)" }}>
The panel pushes the page down rather than covering it, so nothing is hidden behind it.
</p>
</div>
);
}Sticky, with a skip link
sticky keeps the bar at the top of its scroll container; skipTo adds a skip link as its first stop, so Tab into the box to see it.
8f3c2a promoted to production
7d19e0 ready, preview only
6b02f1 failed at build step
5a7c11 promoted to production
4e90b3 ready, preview only
3c55d8 cancelled
Code
"use client";
import { useId } from "react";
import { Button } from "@/components/equip/button";
import { TopBar } from "@/components/equip/top-bar";
export default function Example() {
const main = `main-${useId().replace(/[^\w-]/g, "")}`;
return (
<div
style={{
width: "100%",
height: 240,
overflowY: "auto",
border: "1px solid var(--eq-line)",
borderRadius: "var(--eq-radius-l)",
background: "var(--eq-bg)",
}}
>
<TopBar
sticky
skipTo={`#${main}`}
brand={<a href="#dashboard">Larder</a>}
links={[
{ label: "Overview", href: "#overview" },
{ label: "Deployments", href: "#deployments", current: true },
{ label: "Domains", href: "#domains" },
{ label: "Settings", href: "#settings" },
]}
actions={
<>
<Button size="s" variant="ghost">Feedback</Button>
<Button size="s" variant="accent">New project</Button>
</>
}
/>
<div id={main} tabIndex={-1} style={{ padding: 16, fontSize: "var(--eq-text-s)", lineHeight: 1.7, color: "var(--eq-ink-2)" }}>
{["8f3c2a promoted to production", "7d19e0 ready, preview only", "6b02f1 failed at build step", "5a7c11 promoted to production", "4e90b3 ready, preview only", "3c55d8 cancelled"].map(
(line) => (
<p key={line} style={{ margin: "0 0 20px" }}>
{line}
</p>
),
)}
</div>
</div>
);
}With a nav menu
Pass a NavMenu as nav and the bar uses it in place of links, folding it into the menu panel on a narrow bar like the built-in links.
Code
import { Button } from "@/components/equip/button";
import { NavMenu, NavMenuItem, NavMenuLink, NavMenuSection } from "@/components/equip/nav-menu";
import { TopBar } from "@/components/equip/top-bar";
export default function Example() {
return (
<div style={{ width: "100%", border: "1px solid var(--eq-line)", borderRadius: "var(--eq-radius-l)" }}>
<TopBar
brand={<a href="#home">Larder</a>}
nav={
<NavMenu aria-label="Main">
<NavMenuItem label="Platform">
<NavMenuSection title="Ship">
<NavMenuLink href="#previews" icon="git-branch" title="Preview deployments" description="A live link for every pull request." />
<NavMenuLink href="#rollbacks" icon="history" title="Instant rollback" description="Go back to any earlier build." />
</NavMenuSection>
<NavMenuSection title="Store">
<NavMenuLink href="#postgres" icon="database" title="Postgres" description="Branches for every preview." />
<NavMenuLink href="#storage" icon="hard-drive" title="Object storage" description="Files close to your functions." />
</NavMenuSection>
</NavMenuItem>
<NavMenuItem label="Docs" href="#docs" />
<NavMenuItem label="Pricing" href="#pricing" current />
</NavMenu>
}
actions={
<>
<Button size="s" variant="ghost" href="#sign-in">Sign in</Button>
<Button size="s" variant="accent" href="#sign-up">Start free</Button>
</>
}
/>
</div>
);
}A column width and a folded-only action
maxWidth lines the contents up with a 60rem column, and under 640px the auto NavMenu stacks into rows with Contact sales shown only at the foot of the open panel.
Code
import { Button } from "@/components/equip/button";
import { NavMenu, NavMenuItem, NavMenuLink, NavMenuSection } from "@/components/equip/nav-menu";
import { TopBar } from "@/components/equip/top-bar";
export default function Example() {
return (
<div style={{ width: "100%", border: "1px solid var(--eq-line)", borderRadius: "var(--eq-radius-l)" }}>
<TopBar
maxWidth="60rem"
brand={<a href="#home">Larder</a>}
nav={
<NavMenu aria-label="Main" orientation="auto" panelWidth="36rem">
<NavMenuItem label="Platform">
<NavMenuSection title="Ship">
<NavMenuLink href="#previews" icon="git-branch" title="Preview deployments" description="A live link for every pull request." />
<NavMenuLink href="#rollbacks" icon="history" title="Instant rollback" description="Go back to any earlier build." />
</NavMenuSection>
<NavMenuSection title="Store">
<NavMenuLink href="#postgres" icon="database" title="Postgres" description="Branches for every preview." />
<NavMenuLink href="#storage" icon="hard-drive" title="Object storage" description="Files close to your functions." />
</NavMenuSection>
</NavMenuItem>
<NavMenuItem label="Docs" href="#docs" />
<NavMenuItem label="Pricing" href="#pricing" />
</NavMenu>
}
foldedActions={
<Button variant="outline" href="#sales">
Contact sales
</Button>
}
actions={
<Button size="s" variant="accent" href="#sign-up">
Start free
</Button>
}
/>
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
brand | ReactNode | none | A logo or wordmark, usually a link home. |
links | { label: ReactNode; href: string; current?: boolean }[] | none | The primary links. current sets aria-current="page". Any other anchor attribute on a link goes to it. |
nav | ReactNode | none | Your own navigation in place of links, such as a NavMenu. It folds into the panel the same way. |
search | ReactNode | none | A search field. Sits before the actions when wide and folds into the panel when narrow. |
actions | ReactNode | none | Buttons at the end, such as sign in. They stay on the bar at every width. |
foldedActions | ReactNode | none | Shown only at the foot of the folded menu panel, never on the wide bar, such as a sign in button that leaves the actions on a very narrow screen. |
maxWidth | string | number | none | Caps the width of the bar's contents and centres them, to line up with the page column. The border and background still span. Sets --eq-top-bar-max-width. |
sticky | boolean | false | Sticks to the top of its scroll container, over a slightly see-through surface. |
skipTo | string | none | Renders a SkipLink to this fragment, such as "#main", as the bar's first stop. |
label | string | "Main" | Names the navigation landmark around links. |
menuLabel | string | "Menu" | Accessible name of the menu button. |
...rest | ComponentProps<"header"> | none | Every native header attribute. |
Anatomy
| root | .eq-top-bar | The header, and a size container named eq-top-bar. data-open while the panel is open. |
|---|---|---|
| bar | .eq-top-bar-bar | The row of contents, capped by maxWidth. |
| brand | .eq-top-bar-brand | |
| menu | .eq-top-bar-menu | Holds the links and search. Inline when wide; the folding panel under 640px. |
| nav | .eq-top-bar-nav | The nav landmark around links. |
| links | .eq-top-bar-links | |
| link | .eq-top-bar-link | |
| search | .eq-top-bar-search | |
| folded actions | .eq-top-bar-folded | Holds foldedActions in the panel; display: none on the wide bar. |
| actions | .eq-top-bar-actions | |
| toggle | .eq-top-bar-toggle | The menu button, shown only when narrow. aria-expanded and aria-controls point at the menu. |
| toggle icon | .eq-top-bar-toggle-icon | Turns in as it swaps between menu and close. |
Keyboard
| Tab | Moves through the skip link, brand, links, search and actions; on a narrow bar the menu button comes just before the open panel. |
|---|---|
| EnterSpace | On the menu button, opens or closes the panel. |
| Escape | Closes the open panel and returns focus to the menu button. |
Notes
- It folds by its own width with a container query, at 640px, so it also folds inside a narrow frame on a wide screen.
- Links and search are rendered once and restyled between the bar and the panel, so no ids or field values are duplicated.
- The panel pushes the page down instead of covering it, and focus is never trapped, so it is a disclosure rather than a dialog.
- Following a link from the open panel closes it, and the menu button sits just before the panel in the source so Tab goes straight into it.
- A header at the top level of the page is the banner landmark; put the skip link first in it with skipTo and give the main content a matching id and tabIndex={-1}.
- To use a NavMenu or your own links, pass them as nav; they move into the panel on a narrow bar like the built-in links. Give a NavMenu orientation="auto" and its items become full-width rows there.
- foldedActions are rendered once, inside the panel, so a button that also sits in actions appears twice in the DOM; hide the copy in actions at the width where the panel one takes over.
- On a narrow bar the actions are drawn before the menu button but come after the open panel in the tab order, because the button sits just before the panel in the source.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/top-bar.tsx144 lines
"use client";
import { useId, useState, type ComponentProps, type CSSProperties, type KeyboardEvent, type MouseEvent, type ReactNode } from "react";
import { Icon } from "./icon";
import { SkipLink } from "./skip-link";
import "./top-bar.css";
export type TopBarLink = Omit<ComponentProps<"a">, "children"> & {
label: ReactNode;
href: string;
/** Marks the page the person is on with aria-current="page". */
current?: boolean;
};
export type TopBarProps = ComponentProps<"header"> & {
/** A logo or wordmark, usually a link home. */
brand: ReactNode;
/** The primary links. Ignored when nav is given. */
links?: TopBarLink[];
/** Your own navigation in place of links, such as a NavMenu. */
nav?: ReactNode;
/** A search field. Folds into the menu panel with the links on a narrow bar. */
search?: ReactNode;
/** Buttons at the end, such as sign in. They stay on the bar at every width. */
actions?: ReactNode;
/**
* Shown only at the foot of the folded menu panel on a narrow bar, never on the wide bar,
* such as a sign in button that leaves the actions when space runs out.
*/
foldedActions?: ReactNode;
/** Caps the width of the bar's contents and centres them, as a CSS length or pixels. The border and background still span. */
maxWidth?: string | number;
/** Sticks to the top of its scroll container. */
sticky?: boolean;
/** Renders a skip link to this fragment, such as "#main", as the bar's first stop. */
skipTo?: string;
/** Names the navigation landmark around links. */
label?: string;
/** Accessible name of the menu button on a narrow bar. */
menuLabel?: string;
};
export function TopBar({
brand,
links = [],
nav,
search,
actions,
foldedActions,
maxWidth,
sticky = false,
skipTo,
label = "Main",
menuLabel = "Menu",
className,
style,
onKeyDown,
...rest
}: TopBarProps) {
const menuId = useId();
const [open, setOpen] = useState(false);
// The icon turns in only once someone has used the button, never on first paint.
const [toggled, setToggled] = useState(false);
const hasMenu = nav != null || links.length > 0 || search != null || foldedActions != null;
function handleKeyDown(event: KeyboardEvent<HTMLElement>) {
onKeyDown?.(event);
if (event.defaultPrevented || event.key !== "Escape" || !open) return;
const toggle = event.currentTarget.querySelector<HTMLButtonElement>(".eq-top-bar-toggle");
if (!toggle || toggle.getClientRects().length === 0) return;
// Escape folds the panel away and hands focus back to the button that opened it.
event.preventDefault();
setOpen(false);
toggle.focus();
}
function handleMenuClick(event: MouseEvent<HTMLDivElement>) {
// Following a link from the open panel folds it away behind you.
if (open && event.target instanceof Element && event.target.closest("a[href]")) setOpen(false);
}
return (
<header
{...rest}
className={["eq-top-bar", className].filter(Boolean).join(" ")}
data-sticky={sticky || undefined}
data-open={open || undefined}
data-toggled={toggled || undefined}
style={
maxWidth === undefined
? style
: ({ "--eq-top-bar-max-width": typeof maxWidth === "number" ? `${maxWidth}px` : maxWidth, ...style } as CSSProperties)
}
onKeyDown={handleKeyDown}
>
{skipTo != null && <SkipLink href={skipTo} />}
<div className="eq-top-bar-bar">
<div className="eq-top-bar-brand">{brand}</div>
{/* Before the menu in the source, so Tab goes from the button straight into the open panel. */}
{hasMenu && (
<button
type="button"
className="eq-top-bar-toggle"
aria-label={menuLabel}
aria-expanded={open}
aria-controls={menuId}
onClick={() => {
setToggled(true);
setOpen((value) => !value);
}}
>
<Icon key={open ? "close" : "menu"} className="eq-top-bar-toggle-icon" name={open ? "close" : "menu"} size="m" />
</button>
)}
{hasMenu && (
<div id={menuId} className="eq-top-bar-menu" onClick={handleMenuClick}>
{nav ??
(links.length > 0 && (
<nav className="eq-top-bar-nav" aria-label={label}>
<ul className="eq-top-bar-links">
{links.map(({ label: text, href, current = false, className: linkClass, ...link }) => (
<li key={href}>
<a
{...link}
href={href}
className={["eq-top-bar-link", linkClass].filter(Boolean).join(" ")}
aria-current={current ? "page" : undefined}
>
{text}
</a>
</li>
))}
</ul>
</nav>
))}
{search != null && <div className="eq-top-bar-search">{search}</div>}
{foldedActions != null && <div className="eq-top-bar-folded">{foldedActions}</div>}
</div>
)}
{actions != null && <div className="eq-top-bar-actions">{actions}</div>}
</div>
</header>
);
}components/equip/top-bar.css231 lines
@layer equip {
/* The bar measures itself, so it folds when its own box is narrow, not only on a small screen. */
.eq-top-bar {
container: eq-top-bar / inline-size;
position: relative;
box-sizing: border-box;
min-width: 0;
border-bottom: 1px solid var(--eq-line);
background: var(--eq-surface);
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1.3 var(--eq-font);
}
.eq-top-bar[data-sticky] {
position: sticky;
top: 0;
z-index: 40;
background: color-mix(in srgb, var(--eq-surface) 88%, transparent);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
}
.eq-top-bar-bar {
box-sizing: border-box;
display: flex;
align-items: center;
gap: var(--eq-space-4);
max-width: var(--eq-top-bar-max-width, none);
min-height: calc(var(--eq-h-m) + 2 * var(--eq-space-3));
margin-inline: auto;
padding: var(--eq-space-3) var(--eq-space-4);
}
.eq-top-bar-brand {
flex: none;
display: flex;
align-items: center;
gap: var(--eq-space-2);
margin-inline-end: auto;
font-weight: var(--eq-weight-strong);
white-space: nowrap;
}
.eq-top-bar-brand a {
display: inline-flex;
align-items: center;
gap: var(--eq-space-2);
min-height: 24px;
border-radius: var(--eq-radius-s);
color: inherit;
text-decoration: none;
}
.eq-top-bar-brand a:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-top-bar-menu {
flex: 1;
display: flex;
align-items: center;
gap: var(--eq-space-4);
min-width: 0;
}
.eq-top-bar-nav {
min-width: 0;
}
.eq-top-bar-links {
display: flex;
flex-wrap: wrap;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
.eq-top-bar-link {
display: flex;
align-items: center;
height: var(--eq-h-s);
padding-inline: var(--eq-space-2);
border-radius: var(--eq-radius-s);
color: var(--eq-ink-2);
font-size: var(--eq-text-s);
font-weight: var(--eq-weight-strong);
text-decoration: none;
white-space: nowrap;
transition:
background-color var(--eq-dur) var(--eq-ease),
color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-top-bar-link[aria-current="page"] {
background: var(--eq-tint-strong);
color: var(--eq-ink);
}
@media (hover: hover) {
.eq-top-bar-link:hover:not([aria-current="page"]),
.eq-top-bar-toggle:hover {
background: var(--eq-tint);
color: var(--eq-ink);
}
}
.eq-top-bar-link:active,
.eq-top-bar-toggle:active {
scale: var(--eq-press);
}
.eq-top-bar-link:focus-visible,
.eq-top-bar-toggle:focus-visible {
outline: 2px solid var(--eq-focus);
outline-offset: 2px;
}
.eq-top-bar-search {
flex: 0 1 240px;
min-width: 0;
margin-inline-start: auto;
}
/* Only in the folded panel; its children join the panel's column as if they were its own. */
.eq-top-bar-folded {
display: none;
}
.eq-top-bar-actions {
flex: none;
display: flex;
align-items: center;
gap: var(--eq-space-2);
}
.eq-top-bar-toggle {
flex: none;
display: none;
place-items: center;
width: var(--eq-h-m);
height: var(--eq-h-m);
padding: 0;
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-m);
background: transparent;
color: var(--eq-ink);
cursor: pointer;
transition:
background-color var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease);
}
.eq-top-bar-toggle-icon {
transition: rotate var(--eq-dur-move) var(--eq-spring);
}
@starting-style {
.eq-top-bar[data-toggled] .eq-top-bar-toggle-icon {
rotate: -90deg;
}
}
/* ---- Narrow: links and search fold into a panel under the bar ---- */
@container eq-top-bar (width < 640px) {
.eq-top-bar-bar {
flex-wrap: wrap;
gap: 0 var(--eq-space-2);
padding-inline: var(--eq-space-3);
}
/* Brand, actions and the button on the first line; the panel on its own line under them. */
.eq-top-bar-actions {
order: 1;
}
.eq-top-bar-toggle {
order: 2;
display: grid;
}
.eq-top-bar-menu {
order: 3;
flex: 1 0 100%;
display: none;
flex-direction: column;
align-items: stretch;
gap: var(--eq-space-3);
margin-top: var(--eq-space-3);
padding-top: var(--eq-space-3);
border-top: 1px solid var(--eq-line);
}
.eq-top-bar[data-open] .eq-top-bar-menu {
display: flex;
opacity: 1;
translate: none;
transition:
opacity var(--eq-dur) var(--eq-ease),
translate var(--eq-dur-move) var(--eq-spring);
}
@starting-style {
.eq-top-bar[data-open] .eq-top-bar-menu {
opacity: 0;
translate: 0 -4px;
}
}
.eq-top-bar-links {
flex-direction: column;
}
.eq-top-bar-link {
height: var(--eq-h-m);
padding-inline: var(--eq-space-3);
font-size: var(--eq-text-m);
}
.eq-top-bar-search {
flex: none;
margin-inline-start: 0;
}
.eq-top-bar-folded {
display: contents;
}
}
}