Split navbar
Two link groups flanking a centred brand, for products with a real product/resources divide.
Live preview
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.
Related
All navigationCentered navbar
Three equal grid tracks keep the link group optically centred whatever the brand or actions weigh.
starter3 variantsMega menu
A wide three-column panel with a promoted card, built as a disclosure rather than an ARIA menu.
advancedFeatured4 variantsMinimal navbar
Wordmark, four links, one action — with an in-flow mobile disclosure rather than a drawer.
starterFeatured3 variants