Navbar
Top-level navigation for marketing pages and product shells with few destinations.
Playground
Change a prop and watch it. The code below follows along.
Live
Open full cardInstall and import
Navbar is in @dovetail-ds/react. Install it with React, if you haven't:
npm install @dovetail-ds/react react react-dom
import { Navbar } from "@dovetail-ds/react";
Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.
Guidelines
Top-level navigation for marketing pages and product shells with few destinations.
Rules
- Five links or fewer. A navbar is a shortlist, not a sitemap; deeper structures belong in a Sidebar or a footer.
- The active link carries
aria-current="page"and a visible underline. Colour alone does not mark position. - One primary action in
actions, at most. Two filled buttons side by side means neither is primary. - Do not put the current page's own section links here. Those are Tabs.
Surfaces
surface sets the bar's fill, and the menu that opens on a narrow screen takes the same one.
base(default): the page surface with a subtle bottom border.brand: the strong brand fill. Text, links, the current link's mark and the buttons inactionsturn to--dt-text-on-brand; a primaryButtonturns light with the brand as its label, the same as on a brandSection.brand-muted: the pale tint. Text keeps the brand's ink (--dt-text-on-brand-muted), and a primaryButtontakes the brand colour.glass: the page shows through, blurred. Use it for a sticky bar over a picture or a long page.
<Navbar surface="brand" brand="Kiln & Co." links={links} current="shop"
actions={<><Badge tone="brand">New</Badge><Button size="sm">Sign in</Button></>} />
Tradeoffs
Horizontal bars run out of room fast. Below collapseBelow (640px by default) the link row becomes a menu button that opens the same links in a Drawer, with actions moved to the drawer's footer, so a phone never gets a sideways-scrolling strip of links. Pass collapseBelow={0} only when the bar holds so few links it fits at every width.
Props
The typed contract, from Navbar.d.ts.
import * as React from "react";
export interface NavLink {
id: string;
label: React.ReactNode;
href?: string;
}
/** Horizontal top-level navigation bar. */
export interface NavbarProps extends React.HTMLAttributes<HTMLElement> {
/** Logo or wordmark slot. */
brand?: React.ReactNode;
links: NavLink[];
/** Id of the active link. */
current?: string;
/** When set, link clicks are intercepted and routed through this. */
onNavigate?: (id: string) => void;
/** Right-hand slot for buttons, search, or an Avatar. */
actions?: React.ReactNode;
/** @default "Main" */
label?: string;
/** @default false */
sticky?: boolean;
/**
* Below this width in pixels the link row becomes a menu button that opens
* the links in a Drawer, with `actions` in the drawer's footer. Pass 0 to keep
* the row at every width. @default 640
*/
collapseBelow?: number;
/** The bar's fill. "brand" is the strong brand fill: text, links, the current link's mark and buttons on it turn to --dt-text-on-brand, and a primary Button turns light with brand text. "brand-muted" is the pale tint: text keeps the brand's ink and buttons take the brand colours. "glass" lets the page show through, blurred. The menu that opens on a narrow screen takes the same fill. @default "base" */
surface?: "base" | "glass" | "brand" | "brand-muted";
}
export declare function Navbar(props: NavbarProps): React.JSX.Element;
Tokens it reads
Every custom property Navbar.jsx resolves, read from the source: 23 semantic, 1 primitive. Component tier first, because that is the one to reach for.
- semantic Shared roles. Override one and everything using that role moves with it.
- primitive Raw values. A component reading one directly is a deliberate exception.
| Token | Tier | Declared as |
|---|---|---|
--dt-backdrop-glass | semantic | saturate(1.6) blur(var(--dt-blur-glass)) |
--dt-border-glass | semantic | color-mix(in oklab, var(--dt-text-primary) 10%, transparent) |
--dt-border-selected | semantic | var(--dt-color-neutral-900) |
--dt-border-subtle | semantic | var(--dt-color-neutral-100) |
--dt-border-width-default | semantic | var(--dt-dim-hair) |
--dt-radius-control | semantic | var(--dt-radius-raw-8) |
--dt-size-control-md | semantic | var(--dt-dim-10) |
--dt-size-icon-md | semantic | var(--dt-dim-5) |
--dt-space-inline-lg | semantic | var(--dt-dim-6) |
--dt-space-inline-md | semantic | var(--dt-dim-4) |
--dt-space-inline-sm | semantic | var(--dt-dim-3) |
--dt-space-inset-2xs | semantic | var(--dt-dim-1) |
--dt-space-inset-lg | semantic | var(--dt-dim-6) |
--dt-space-inset-sm | semantic | var(--dt-dim-3) |
--dt-surface-base | semantic | var(--dt-color-white) |
--dt-surface-glass | semantic | color-mix(in oklab, var(--dt-surface-overlay) 72%, transparent) |
--dt-text-label-lg-family | semantic | var(--dt-font-family-secondary) |
--dt-text-label-lg-size | semantic | var(--dt-font-size-md) |
--dt-text-label-md-family | semantic | var(--dt-font-family-secondary) |
--dt-text-label-md-size | semantic | var(--dt-font-size-sm) |
--dt-text-primary | semantic | var(--dt-color-neutral-900) |
--dt-text-secondary | semantic | var(--dt-color-neutral-600) |
--dt-z-sticky | semantic | 100 |
--dt-font-weight-medium | primitive | 500 |
Source
React only, no dependencies, every value a token.
import React from "react";
import { Drawer } from "../feedback/Drawer.jsx";
import { fillTone } from "../primitives/Section.jsx";
/* The bar's surface. A brand fill re-points the text, links, the current
link's mark and the buttons on it, the same way a brand Section does, and
the menu that opens on a narrow screen takes the same fill. Glass lets the
page show through, blurred, for a bar over a picture or a sticky header. */
const MENU_SURFACE = { base: "raised", glass: "glass-strong", brand: "brand", "brand-muted": "brand-muted" };
function MenuIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" style={{ width: "var(--dt-size-icon-md)", height: "var(--dt-size-icon-md)" }} aria-hidden="true">
<path d="M4 7h16M4 12h16M4 17h16" />
</svg>
);
}
/* False while server rendering and hydrating, so the markup matches what the
server sent; matchMedia is read after that, and at once in a client-only
render. */
function useNarrow(below) {
const query = below ? `(max-width: ${below - 1}px)` : null;
const subscribe = React.useCallback((sync) => {
if (!query || !window.matchMedia) return () => {};
const mq = window.matchMedia(query);
mq.addEventListener("change", sync);
return () => mq.removeEventListener("change", sync);
}, [query]);
return React.useSyncExternalStore(
subscribe,
() => !!(query && window.matchMedia && window.matchMedia(query).matches),
() => false,
);
}
export function Navbar({ brand, links = [], actions, current, onNavigate, label = "Main", sticky = false, collapseBelow = 640, surface = "base", style, ...rest }) {
const narrow = useNarrow(collapseBelow) && links.length > 0;
const [open, setOpen] = React.useState(false);
const go = (id) => { setOpen(false); if (onNavigate) onNavigate(id); };
const fill = surface === "brand" || surface === "brand-muted" ? fillTone(surface) : null;
const glass = surface === "glass";
return (
<nav aria-label={label} style={{
display: "flex", alignItems: "center", gap: "var(--dt-space-inline-lg)",
padding: "var(--dt-space-inset-sm) var(--dt-space-inset-lg)",
background: "var(--dt-surface-base)",
...(fill || {}),
...(glass ? { background: "var(--dt-surface-glass)", backdropFilter: "var(--dt-backdrop-glass)", WebkitBackdropFilter: "var(--dt-backdrop-glass)" } : null),
borderBottom: `var(--dt-border-width-default) solid ${glass ? "var(--dt-border-glass)" : "var(--dt-border-subtle)"}`,
position: sticky ? "sticky" : "static", top: 0, zIndex: sticky ? "var(--dt-z-sticky)" : undefined, ...style,
}} {...rest}>
{brand && <span style={{ display: "flex", alignItems: "center", flex: "none" }}>{brand}</span>}
{!narrow && <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", gap: "var(--dt-space-inline-md)", alignItems: "center", flex: 1, minWidth: 0, overflowX: "auto" }}>
{links.map(l => {
const on = l.id === current;
return (
<li key={l.id}>
<a
href={l.href || "#"}
aria-current={on ? "page" : undefined}
onClick={e => { if (onNavigate) { e.preventDefault(); onNavigate(l.id); } }}
style={{
fontFamily: "var(--dt-text-label-md-family)", fontSize: "var(--dt-text-label-md-size)",
fontWeight: "var(--dt-font-weight-medium)", textDecoration: "none", whiteSpace: "nowrap",
color: on ? "var(--dt-text-primary)" : "var(--dt-text-secondary)",
padding: "var(--dt-space-inset-2xs, 4px) 0",
borderBottom: `2px solid ${on ? "var(--dt-border-selected)" : "transparent"}`,
}}
>{l.label}</a>
</li>
);
})}
</ul>}
{actions && !narrow && <span style={{ display: "flex", alignItems: "center", gap: "var(--dt-space-inline-sm)", flex: "none" }}>{actions}</span>}
{narrow && (
<button
type="button"
aria-label="Open menu"
aria-expanded={open}
onClick={() => setOpen(true)}
style={{
marginLeft: "auto", display: "inline-flex", alignItems: "center", justifyContent: "center",
width: "var(--dt-size-control-md)", height: "var(--dt-size-control-md)",
border: 0, borderRadius: "var(--dt-radius-control)", background: "transparent",
color: "var(--dt-text-primary)", cursor: "pointer",
}}
><MenuIcon /></button>
)}
{narrow && (
<Drawer open={open} onClose={() => setOpen(false)} side="right" title="Menu" label={label + " menu"} footer={actions} surface={MENU_SURFACE[surface] || "raised"}>
<ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column" }}>
{links.map(l => {
const on = l.id === current;
return (
<li key={l.id}>
<a
href={l.href || "#"}
aria-current={on ? "page" : undefined}
onClick={e => { if (onNavigate) e.preventDefault(); go(l.id); }}
style={{
display: "block", padding: "var(--dt-space-inset-sm) 0",
fontFamily: "var(--dt-text-label-lg-family)", fontSize: "var(--dt-text-label-lg-size)",
fontWeight: "var(--dt-font-weight-medium)", textDecoration: "none",
color: on ? "var(--dt-text-primary)" : "var(--dt-text-secondary)",
borderBottom: "var(--dt-border-width-default) solid var(--dt-border-subtle)",
}}
>{l.label}</a>
</li>
);
})}
</ul>
</Drawer>
)}
</nav>
);
}
In context
Navbar also appears on the navigation reference cards, alongside the rest of its family.