Skip to content

Split navbar

Two link groups flanking a centred brand, for products with a real product/resources divide.

Navbarsstarternavbarsplitsymmetrygrouping

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 { Divider } from '@/components/ui/layout'
import { productLinks, resourceLinks } from '@/content/nav-demo'

/**
 * Split navbar
 *
 * Two link groups flanking a centred brand — the layout for products with a
 * genuine split between "what it is" and "how to learn it". Below `lg` the two
 * groups stack in source order, which keeps the reading sequence intact.
 */
export default function SplitNavbar() {
  const [open, setOpen] = useState(false)

  return (
    <div className="w-full border-b border-line bg-surface">
      <div className="mx-auto flex h-16 w-full max-w-6xl items-center px-4 sm:px-6">
        <nav aria-label="Split example, product" className="hidden flex-1 lg:block">
          <ul className="flex items-center gap-1">
            {productLinks.slice(0, 3).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>

        <Link
          href="/"
          className="flex items-center gap-2 text-ink-strong lg:absolute lg:left-1/2 lg:-translate-x-1/2"
        >
          <BrandMark className="size-5 text-accent" />
          <span className="display-type text-md font-semibold tracking-tight">Foundry</span>
        </Link>

        <nav aria-label="Split example, resources" className="hidden flex-1 justify-end lg:flex">
          <ul className="flex items-center gap-1">
            {resourceLinks.slice(0, 2).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>
            ))}
            <li className="ml-2">
              <ButtonLink href="/docs/getting-started" size="sm" variant="outline">
                Get started
              </ButtonLink>
            </li>
          </ul>
        </nav>

        <button
          type="button"
          onClick={() => setOpen((value) => !value)}
          aria-expanded={open}
          aria-controls="split-nav-panel"
          className="ml-auto 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 id="split-nav-panel" hidden={!open} className="border-t border-line-subtle lg:hidden">
        <div className="flex flex-col gap-4 px-4 py-4">
          <nav aria-label="Split example, product, mobile">
            <h2 className="label-caps mb-1 px-3 text-ink-subtle">Product</h2>
            <ul className="flex flex-col gap-0.5">
              {productLinks.slice(0, 3).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>
          </nav>
          <Divider weight="subtle" />
          <nav aria-label="Split example, resources, mobile">
            <h2 className="label-caps mb-1 px-3 text-ink-subtle">Resources</h2>
            <ul className="flex flex-col gap-0.5">
              {resourceLinks.slice(0, 2).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>
          </nav>
          <ButtonLink href="/docs/getting-started" size="sm" block>
            Get started
          </ButtonLink>
        </div>
      </div>
    </div>
  )
}

components/blocks/navigation/split.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 { Divider } from '@/components/ui/layout'
import { productLinks, resourceLinks } from '@/content/nav-demo'

/**
 * Split navbar
 *
 * Two link groups flanking a centred brand — the layout for products with a
 * genuine split between "what it is" and "how to learn it". Below `lg` the two
 * groups stack in source order, which keeps the reading sequence intact.
 */
export default function SplitNavbar() {
  const [open, setOpen] = useState(false)

  return (
    <div className="w-full border-b border-line bg-surface">
      <div className="mx-auto flex h-16 w-full max-w-6xl items-center px-4 sm:px-6">
        <nav aria-label="Split example, product" className="hidden flex-1 lg:block">
          <ul className="flex items-center gap-1">
            {productLinks.slice(0, 3).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>

        <Link
          href="/"
          className="flex items-center gap-2 text-ink-strong lg:absolute lg:left-1/2 lg:-translate-x-1/2"
        >
          <BrandMark className="size-5 text-accent" />
          <span className="display-type text-md font-semibold tracking-tight">Foundry</span>
        </Link>

        <nav aria-label="Split example, resources" className="hidden flex-1 justify-end lg:flex">
          <ul className="flex items-center gap-1">
            {resourceLinks.slice(0, 2).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>
            ))}
            <li className="ml-2">
              <ButtonLink href="/docs/getting-started" size="sm" variant="outline">
                Get started
              </ButtonLink>
            </li>
          </ul>
        </nav>

        <button
          type="button"
          onClick={() => setOpen((value) => !value)}
          aria-expanded={open}
          aria-controls="split-nav-panel"
          className="ml-auto 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 id="split-nav-panel" hidden={!open} className="border-t border-line-subtle lg:hidden">
        <div className="flex flex-col gap-4 px-4 py-4">
          <nav aria-label="Split example, product, mobile">
            <h2 className="label-caps mb-1 px-3 text-ink-subtle">Product</h2>
            <ul className="flex flex-col gap-0.5">
              {productLinks.slice(0, 3).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>
          </nav>
          <Divider weight="subtle" />
          <nav aria-label="Split example, resources, mobile">
            <h2 className="label-caps mb-1 px-3 text-ink-subtle">Resources</h2>
            <ul className="flex flex-col gap-0.5">
              {resourceLinks.slice(0, 2).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>
          </nav>
          <ButtonLink href="/docs/getting-started" size="sm" block>
            Get started
          </ButtonLink>
        </div>
      </div>
    </div>
  )
}

components/ui/layout.tsx

import type { ElementType, HTMLAttributes, ReactNode } from 'react'
import { cn } from '@/lib/cn'

/**
 * Layout primitives
 *
 * Five zero-JavaScript building blocks that account for the majority of
 * structure in the library. They exist so that spacing decisions are made once,
 * against density tokens, instead of being re-typed as ad-hoc utilities in
 * every section.
 */

export interface ContainerProps extends HTMLAttributes<HTMLDivElement> {
  /** `prose` narrows to a comfortable reading measure. */
  size?: 'prose' | 'narrow' | 'default' | 'wide' | 'full'
  as?: ElementType
  /** Removes the responsive horizontal gutter. */
  bleed?: boolean
}

const containerSizes = {
  prose: 'max-w-[var(--layout-prose-max)]',
  narrow: 'max-w-3xl',
  default: 'max-w-[var(--layout-content-max)]',
  wide: 'max-w-[110rem]',
  full: 'max-w-none',
} as const

export function Container({
  size = 'default',
  as: Tag = 'div',
  bleed = false,
  className,
  children,
  ...props
}: ContainerProps) {
  return (
    <Tag
      className={cn(
        'mx-auto w-full',
        containerSizes[size],
        !bleed && 'px-4 sm:px-6 lg:px-8',
        className,
      )}
      {...props}
    >
      {children}
    </Tag>
  )
}

export interface StackProps extends HTMLAttributes<HTMLDivElement> {
  direction?: 'row' | 'column'
  gap?: 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl'
  align?: 'start' | 'center' | 'end' | 'stretch' | 'baseline'
  justify?: 'start' | 'center' | 'end' | 'between' | 'around'
  wrap?: boolean
  as?: ElementType
}

const gapMap = {
  none: 'gap-0',
  xs: 'gap-1',
  sm: 'gap-2',
  md: 'gap-gap',
  lg: 'gap-stack',
  xl: 'gap-8',
} as const

const alignMap = {
  start: 'items-start',
  center: 'items-center',
  end: 'items-end',
  stretch: 'items-stretch',
  baseline: 'items-baseline',
} as const

const justifyMap = {
  start: 'justify-start',
  center: 'justify-center',
  end: 'justify-end',
  between: 'justify-between',
  around: 'justify-around',
} as const

export function Stack({
  direction = 'column',
  gap = 'md',
  align,
  justify,
  wrap = false,
  as: Tag = 'div',
  className,
  children,
  ...props
}: StackProps) {
  return (
    <Tag
      className={cn(
        'flex',
        direction === 'column' ? 'flex-col' : 'flex-row',
        gapMap[gap],
        align && alignMap[align],
        justify && justifyMap[justify],
        wrap && 'flex-wrap',
        className,
      )}
      {...props}
    >
      {children}
    </Tag>
  )
}

export interface GridProps extends HTMLAttributes<HTMLDivElement> {
  /** Column count at the largest breakpoint; smaller breakpoints step down. */
  cols?: 1 | 2 | 3 | 4 | 5 | 6
  gap?: StackProps['gap']
  as?: ElementType
  /** Auto-fit tracks with a minimum width instead of a fixed column count. */
  minItemWidth?: string
}

const colMap = {
  1: 'grid-cols-1',
  2: 'grid-cols-1 sm:grid-cols-2',
  3: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
  4: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
  5: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5',
  6: 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6',
} as const

export function Grid({
  cols = 3,
  gap = 'lg',
  as: Tag = 'div',
  minItemWidth,
  className,
  style,
  children,
  ...props
}: GridProps) {
  return (
    <Tag
      className={cn('grid', minItemWidth ? undefined : colMap[cols], gapMap[gap], className)}
      style={
        minItemWidth
          ? {
              ...style,
              gridTemplateColumns: `repeat(auto-fit, minmax(min(${minItemWidth}, 100%), 1fr))`,
            }
          : style
      }
      {...props}
    >
      {children}
    </Tag>
  )
}

export interface DividerProps extends HTMLAttributes<HTMLDivElement> {
  orientation?: 'horizontal' | 'vertical'
  /** Renders a centred text label interrupting the rule. */
  label?: ReactNode
  weight?: 'subtle' | 'default' | 'strong'
}

const dividerWeight = {
  subtle: 'border-line-subtle',
  default: 'border-line',
  strong: 'border-line-strong',
} as const

export function Divider({
  orientation = 'horizontal',
  label,
  weight = 'default',
  className,
  ...props
}: DividerProps) {
  if (orientation === 'vertical') {
    return (
      <div
        role="separator"
        aria-orientation="vertical"
        className={cn('h-full w-px self-stretch border-l', dividerWeight[weight], className)}
        {...props}
      />
    )
  }

  if (label) {
    return (
      <div className={cn('flex items-center gap-3', className)} {...props}>
        <span className={cn('h-px flex-1 border-t', dividerWeight[weight])} role="separator" />
        <span className="label-caps text-ink-subtle">{label}</span>
        <span className={cn('h-px flex-1 border-t', dividerWeight[weight])} aria-hidden="true" />
      </div>
    )
  }

  return (
    <div
      role="separator"
      className={cn('w-full border-t', dividerWeight[weight], className)}
      {...props}
    />
  )
}

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

Usage

When the navigation genuinely has two halves. Below `lg` the groups stack in source order with their labels intact, so the grouping survives the collapse.

  • Only use this when the split is meaningful; arbitrary halves read as indecision.
  • The centred brand is absolutely positioned above `lg` and returns to flow below it.

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 split
  • Mobile grouped panel
  • Absolute-centred brand

Accessibility

Two landmarks
Each group is its own labelled `<nav>` so the halves are distinguishable in a landmark list.
Grouping survives collapse
Below `lg` both groups keep their headings, so the split is still legible as two categories.

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.