Navbar

Top-level navigation for marketing pages and product shells with few destinations.

Navigation Navbar.jsx Navbar.d.ts Navbar.md

Playground

Change a prop and watch it. The code below follows along.

Install 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 in actions turn to --dt-text-on-brand; a primary Button turns light with the brand as its label, the same as on a brand Section.
  • brand-muted: the pale tint. Text keeps the brand's ink (--dt-text-on-brand-muted), and a primary Button takes 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.
TokenTierDeclared as
--dt-backdrop-glasssemanticsaturate(1.6) blur(var(--dt-blur-glass))
--dt-border-glasssemanticcolor-mix(in oklab, var(--dt-text-primary) 10%, transparent)
--dt-border-selectedsemanticvar(--dt-color-neutral-900)
--dt-border-subtlesemanticvar(--dt-color-neutral-100)
--dt-border-width-defaultsemanticvar(--dt-dim-hair)
--dt-radius-controlsemanticvar(--dt-radius-raw-8)
--dt-size-control-mdsemanticvar(--dt-dim-10)
--dt-size-icon-mdsemanticvar(--dt-dim-5)
--dt-space-inline-lgsemanticvar(--dt-dim-6)
--dt-space-inline-mdsemanticvar(--dt-dim-4)
--dt-space-inline-smsemanticvar(--dt-dim-3)
--dt-space-inset-2xssemanticvar(--dt-dim-1)
--dt-space-inset-lgsemanticvar(--dt-dim-6)
--dt-space-inset-smsemanticvar(--dt-dim-3)
--dt-surface-basesemanticvar(--dt-color-white)
--dt-surface-glasssemanticcolor-mix(in oklab, var(--dt-surface-overlay) 72%, transparent)
--dt-text-label-lg-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-lg-sizesemanticvar(--dt-font-size-md)
--dt-text-label-md-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-md-sizesemanticvar(--dt-font-size-sm)
--dt-text-primarysemanticvar(--dt-color-neutral-900)
--dt-text-secondarysemanticvar(--dt-color-neutral-600)
--dt-z-stickysemantic100
--dt-font-weight-mediumprimitive500

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.