Skip to content

Centered navbar

Three equal grid tracks keep the link group optically centred whatever the brand or actions weigh.

Navbarsstarternavbarcenteredgridmarketing

Live preview

full widthLive preview — open it in a new tab for the full-height version.
Open the preview in a new tab

Source

This exact file renders the preview above.

'use client'

import { useState } from 'react'
import Link from 'next/link'
import { Menu, X } from 'lucide-react'
import { BrandMark } from '@/components/library/brand'
import { ButtonLink } from '@/components/ui/button'
import { productLinks, companyLinks } from '@/content/nav-demo'

/**
 * Centered navbar
 *
 * Links sit in the optical centre with the brand on the left and actions on
 * the right. The three regions are equal-width grid tracks, which is what
 * keeps the centre truly centred regardless of how long the wordmark or the
 * action labels are.
 */
export default function CenteredNavbar() {
  const [open, setOpen] = useState(false)
  const links = [...productLinks.slice(0, 3), ...companyLinks.slice(0, 1)]

  return (
    <div className="w-full border-b border-line bg-surface">
      <div className="mx-auto grid h-16 w-full max-w-6xl grid-cols-[1fr_auto_1fr] items-center gap-4 px-4 sm:px-6">
        <Link href="/" className="flex items-center gap-2 justify-self-start text-ink-strong">
          <BrandMark className="size-5 text-accent" />
          <span className="display-type text-md font-semibold">Foundry</span>
        </Link>

        <nav aria-label="Centered example" className="hidden lg:block">
          <ul className="flex items-center gap-1">
            {links.map((link) => (
              <li key={link.href}>
                <Link
                  href={link.href}
                  className="flex h-8 items-center rounded-md px-3 text-sm font-medium text-ink-muted transition-colors hover:bg-surface-sunken hover:text-ink"
                >
                  {link.label}
                </Link>
              </li>
            ))}
          </ul>
        </nav>

        <div className="col-start-3 flex items-center justify-self-end">
          <div className="hidden items-center gap-2 lg:flex">
            <ButtonLink href="/search" variant="ghost" size="sm">
              Search
            </ButtonLink>
            <ButtonLink href="/docs/getting-started" size="sm">
              Get started
            </ButtonLink>
          </div>
          <button
            type="button"
            onClick={() => setOpen((value) => !value)}
            aria-expanded={open}
            aria-controls="centered-nav-panel"
            className="flex size-9 items-center justify-center rounded-md text-ink-muted hover:bg-surface-sunken lg:hidden"
          >
            {open ? (
              <X className="size-5" aria-hidden="true" />
            ) : (
              <Menu className="size-5" aria-hidden="true" />
            )}
            <span className="sr-only">{open ? 'Close navigation' : 'Open navigation'}</span>
          </button>
        </div>
      </div>

      <div id="centered-nav-panel" hidden={!open} className="border-t border-line-subtle lg:hidden">
        <nav aria-label="Centered example, mobile" className="flex flex-col gap-3 px-4 py-4">
          <ul className="flex flex-col gap-0.5">
            {links.map((link) => (
              <li key={link.href}>
                <Link
                  href={link.href}
                  className="flex min-h-11 items-center rounded-md px-3 text-sm text-ink hover:bg-surface-sunken"
                >
                  {link.label}
                </Link>
              </li>
            ))}
          </ul>
          <div className="flex gap-2">
            <ButtonLink href="/search" variant="outline" size="sm" block>
              Search
            </ButtonLink>
            <ButtonLink href="/docs/getting-started" size="sm" block>
              Get started
            </ButtonLink>
          </div>
        </nav>
      </div>
    </div>
  )
}

components/blocks/navigation/centered.tsx

'use client'

import { useState } from 'react'
import Link from 'next/link'
import { Menu, X } from 'lucide-react'
import { BrandMark } from '@/components/library/brand'
import { ButtonLink } from '@/components/ui/button'
import { productLinks, companyLinks } from '@/content/nav-demo'

/**
 * Centered navbar
 *
 * Links sit in the optical centre with the brand on the left and actions on
 * the right. The three regions are equal-width grid tracks, which is what
 * keeps the centre truly centred regardless of how long the wordmark or the
 * action labels are.
 */
export default function CenteredNavbar() {
  const [open, setOpen] = useState(false)
  const links = [...productLinks.slice(0, 3), ...companyLinks.slice(0, 1)]

  return (
    <div className="w-full border-b border-line bg-surface">
      <div className="mx-auto grid h-16 w-full max-w-6xl grid-cols-[1fr_auto_1fr] items-center gap-4 px-4 sm:px-6">
        <Link href="/" className="flex items-center gap-2 justify-self-start text-ink-strong">
          <BrandMark className="size-5 text-accent" />
          <span className="display-type text-md font-semibold">Foundry</span>
        </Link>

        <nav aria-label="Centered example" className="hidden lg:block">
          <ul className="flex items-center gap-1">
            {links.map((link) => (
              <li key={link.href}>
                <Link
                  href={link.href}
                  className="flex h-8 items-center rounded-md px-3 text-sm font-medium text-ink-muted transition-colors hover:bg-surface-sunken hover:text-ink"
                >
                  {link.label}
                </Link>
              </li>
            ))}
          </ul>
        </nav>

        <div className="col-start-3 flex items-center justify-self-end">
          <div className="hidden items-center gap-2 lg:flex">
            <ButtonLink href="/search" variant="ghost" size="sm">
              Search
            </ButtonLink>
            <ButtonLink href="/docs/getting-started" size="sm">
              Get started
            </ButtonLink>
          </div>
          <button
            type="button"
            onClick={() => setOpen((value) => !value)}
            aria-expanded={open}
            aria-controls="centered-nav-panel"
            className="flex size-9 items-center justify-center rounded-md text-ink-muted hover:bg-surface-sunken lg:hidden"
          >
            {open ? (
              <X className="size-5" aria-hidden="true" />
            ) : (
              <Menu className="size-5" aria-hidden="true" />
            )}
            <span className="sr-only">{open ? 'Close navigation' : 'Open navigation'}</span>
          </button>
        </div>
      </div>

      <div id="centered-nav-panel" hidden={!open} className="border-t border-line-subtle lg:hidden">
        <nav aria-label="Centered example, mobile" className="flex flex-col gap-3 px-4 py-4">
          <ul className="flex flex-col gap-0.5">
            {links.map((link) => (
              <li key={link.href}>
                <Link
                  href={link.href}
                  className="flex min-h-11 items-center rounded-md px-3 text-sm text-ink hover:bg-surface-sunken"
                >
                  {link.label}
                </Link>
              </li>
            ))}
          </ul>
          <div className="flex gap-2">
            <ButtonLink href="/search" variant="outline" size="sm" block>
              Search
            </ButtonLink>
            <ButtonLink href="/docs/getting-started" size="sm" block>
              Get started
            </ButtonLink>
          </div>
        </nav>
      </div>
    </div>
  )
}

components/ui/button.tsx

import type { ButtonHTMLAttributes, ReactNode } from 'react'
import Link from 'next/link'
import { cn } from '@/lib/cn'
import { variants } from '@/lib/variants'
import { Spinner } from './spinner'

/**
 * Button
 *
 * The whole action surface of Foundry in one component. It is intentionally a
 * *shared* component (no `'use client'`): rendered from a Server Component it
 * ships zero JavaScript, and it upgrades to a client island automatically when
 * a Client Component imports it.
 *
 * Height, padding and radius resolve from density and radius tokens, so a
 * button restyles itself when the density or palette axis changes. Focus is
 * handled once, globally, by the `:focus-visible` rule in `globals.css`.
 */
const buttonVariants = variants(
  cn(
    'relative inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium',
    'transition-colors duration-150 ease-standard select-none',
    'disabled:pointer-events-none disabled:opacity-50',
    'aria-disabled:pointer-events-none aria-disabled:opacity-50',
  ),
  {
    variants: {
      variant: {
        primary: 'bg-accent text-accent-ink hover:bg-accent-hover active:bg-accent-active',
        secondary: 'bg-surface-inverse text-ink-inverse hover:opacity-90 active:opacity-80',
        outline: 'border border-line-strong bg-surface text-ink hover:bg-surface-sunken',
        ghost: 'text-ink hover:bg-surface-sunken active:bg-surface-sunken',
        destructive: 'bg-danger text-white hover:opacity-90 active:opacity-80',
        success: 'bg-success text-white hover:opacity-90 active:opacity-80',
        warning: 'bg-warning text-white hover:opacity-90 active:opacity-80',
        link: 'text-accent underline underline-offset-4 hover:text-accent-hover',
        soft: 'bg-accent-soft text-accent-soft-ink border border-accent-line hover:brightness-[0.97]',
      },
      size: {
        sm: 'h-control-sm px-3 text-xs rounded-md',
        md: 'h-control px-[var(--density-control-padding-x)] text-sm rounded-md',
        lg: 'h-control-lg px-5 text-base rounded-md',
        icon: 'h-control w-control p-0 rounded-md',
        'icon-sm': 'h-control-sm w-control-sm p-0 rounded-sm',
      },
      block: { true: 'w-full', false: '' },
    },
    defaultVariants: { variant: 'primary', size: 'md', block: false },
    compound: [
      { variant: 'link', size: 'sm', class: 'h-auto px-0' },
      { variant: 'link', size: 'md', class: 'h-auto px-0' },
      { variant: 'link', size: 'lg', class: 'h-auto px-0' },
    ],
  },
)

export type ButtonVariant =
  | 'primary'
  | 'secondary'
  | 'outline'
  | 'ghost'
  | 'destructive'
  | 'success'
  | 'warning'
  | 'link'
  | 'soft'

export type ButtonSize = 'sm' | 'md' | 'lg' | 'icon' | 'icon-sm'

export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant
  size?: ButtonSize
  block?: boolean
  /** Swaps content for a spinner while preserving the button's measured width. */
  loading?: boolean
  /** Announced by assistive tech while `loading` is true. */
  loadingLabel?: string
  leadingIcon?: ReactNode
  trailingIcon?: ReactNode
}

export function Button({
  variant = 'primary',
  size = 'md',
  block = false,
  loading = false,
  loadingLabel = 'Working',
  leadingIcon,
  trailingIcon,
  className,
  children,
  disabled,
  type = 'button',
  ...props
}: ButtonProps) {
  return (
    <button
      type={type}
      className={buttonVariants({ variant, size, block, className })}
      disabled={disabled ?? loading}
      aria-busy={loading || undefined}
      {...props}
    >
      {loading ? (
        <>
          {/* Label stays in the DOM but hidden so the control never collapses
              to spinner width halfway through an interaction. */}
          <span className="invisible flex items-center gap-2" aria-hidden="true">
            {leadingIcon}
            {children}
            {trailingIcon}
          </span>
          <span className="absolute inset-0 flex items-center justify-center">
            <Spinner size="sm" />
            <span className="sr-only">{loadingLabel}</span>
          </span>
        </>
      ) : (
        <>
          {leadingIcon}
          {children}
          {trailingIcon}
        </>
      )}
    </button>
  )
}

export interface ButtonLinkProps {
  href: string
  variant?: ButtonVariant
  size?: ButtonSize
  block?: boolean
  className?: string
  children?: ReactNode
  leadingIcon?: ReactNode
  trailingIcon?: ReactNode
  'aria-label'?: string
  'aria-current'?: 'page' | 'step' | 'true' | undefined
  target?: string
  rel?: string
  prefetch?: boolean
}

/** Anchor styled as a button, for when the action is really navigation. */
export function ButtonLink({
  href,
  variant = 'primary',
  size = 'md',
  block = false,
  className,
  children,
  leadingIcon,
  trailingIcon,
  ...props
}: ButtonLinkProps) {
  return (
    <Link href={href} className={buttonVariants({ variant, size, block, className })} {...props}>
      {leadingIcon}
      {children}
      {trailingIcon}
    </Link>
  )
}

export { buttonVariants }

Demo source — adapt to your project. Foundry is not published as a package.

Usage

For sites where the navigation should feel balanced rather than left-anchored. The three-track grid is what makes the centre true — flexbox with `justify-between` drifts as soon as the wordmark changes length.

  • Keep the link count odd or even but symmetrical; the layout exposes imbalance.
  • Actions collapse into the mobile panel rather than being dropped.

Variants and states

Every entry below is a genuine difference in behaviour or layout, and every one of them is visible in the preview above.

  • Desktop three-track grid
  • Mobile disclosure
  • Dual action

Accessibility

Landmark
A single labelled `<nav>` wraps the links; the actions sit outside it, since they are not navigation.
Mobile parity
The collapsed panel carries every desktop link plus both actions, so nothing is lost below the breakpoint.

Foundry implements published ARIA patterns and is tested against them. No WCAG certification is claimed — see the accessibility documentation for what is and is not covered.