SaaS navbar
Product dropdown, flat secondary links, and a separated sign-in / sign-up pair.
Live preview
Source
This exact file renders the preview above.
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { ChevronDown, Menu, X } from 'lucide-react'
import { BrandMark } from '@/components/library/brand'
import { Badge } from '@/components/ui/badge'
import { ButtonLink } from '@/components/ui/button'
import { Dropdown } from '@/components/ui/dropdown'
import { productLinks, resourceLinks } from '@/content/nav-demo'
/**
* SaaS navbar
*
* The conventional shape for a subscription product: a product dropdown, flat
* secondary links, and a sign-in / sign-up pair separated so the primary
* action is unambiguous. The dropdown is the library's Dropdown primitive, so
* it comes with roving focus and Escape handling.
*/
export default function SaasNavbar() {
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-7xl items-center gap-6 px-4 sm:px-6">
<Link href="/" className="flex items-center gap-2 text-ink-strong">
<BrandMark className="size-5 text-accent" />
<span className="display-type text-md font-semibold">Foundry</span>
</Link>
<nav aria-label="SaaS example" className="hidden lg:block">
<ul className="flex items-center gap-1">
<li>
<Dropdown
label="Product"
groups={[
{
items: productLinks.map((link) => ({
id: link.href,
label: link.label,
href: link.href,
meta: link.description ? undefined : undefined,
})),
},
]}
trigger={(props) => (
<button
type="button"
className="flex h-8 items-center gap-1 rounded-md px-3 text-sm font-medium text-ink-muted transition-colors hover:bg-surface-sunken hover:text-ink"
{...props}
>
Product
<ChevronDown className="size-3.5" aria-hidden="true" />
</button>
)}
/>
</li>
{resourceLinks.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>
))}
<li>
<Link
href="/changelog"
className="flex h-8 items-center gap-2 rounded-md px-3 text-sm font-medium text-ink-muted transition-colors hover:bg-surface-sunken hover:text-ink"
>
Changelog
<Badge size="sm" tone="accent">
1.0
</Badge>
</Link>
</li>
</ul>
</nav>
<div className="ml-auto hidden items-center gap-2 lg:flex">
<ButtonLink href="/starters/saas/preview/login" variant="ghost" size="sm">
Sign in
</ButtonLink>
<ButtonLink href="/starters/saas/preview/register" size="sm">
Start free trial
</ButtonLink>
</div>
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-controls="saas-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="saas-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="SaaS example, mobile">
<h2 className="label-caps mb-1 px-3 text-ink-subtle">Product</h2>
<ul className="flex flex-col gap-0.5">
{productLinks.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="flex min-h-11 flex-col justify-center rounded-md px-3 py-2 hover:bg-surface-sunken"
>
<span className="text-sm font-medium text-ink">{link.label}</span>
{link.description ? (
<span className="text-xs text-ink-muted">{link.description}</span>
) : null}
</Link>
</li>
))}
</ul>
<h2 className="label-caps mt-4 mb-1 px-3 text-ink-subtle">Resources</h2>
<ul className="flex flex-col gap-0.5">
{resourceLinks.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>
<div className="flex flex-col gap-2 border-t border-line-subtle pt-4">
<ButtonLink href="/starters/saas/preview/login" variant="outline" size="sm" block>
Sign in
</ButtonLink>
<ButtonLink href="/starters/saas/preview/register" size="sm" block>
Start free trial
</ButtonLink>
</div>
</div>
</div>
</div>
)
}
components/blocks/navigation/saas.tsx
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { ChevronDown, Menu, X } from 'lucide-react'
import { BrandMark } from '@/components/library/brand'
import { Badge } from '@/components/ui/badge'
import { ButtonLink } from '@/components/ui/button'
import { Dropdown } from '@/components/ui/dropdown'
import { productLinks, resourceLinks } from '@/content/nav-demo'
/**
* SaaS navbar
*
* The conventional shape for a subscription product: a product dropdown, flat
* secondary links, and a sign-in / sign-up pair separated so the primary
* action is unambiguous. The dropdown is the library's Dropdown primitive, so
* it comes with roving focus and Escape handling.
*/
export default function SaasNavbar() {
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-7xl items-center gap-6 px-4 sm:px-6">
<Link href="/" className="flex items-center gap-2 text-ink-strong">
<BrandMark className="size-5 text-accent" />
<span className="display-type text-md font-semibold">Foundry</span>
</Link>
<nav aria-label="SaaS example" className="hidden lg:block">
<ul className="flex items-center gap-1">
<li>
<Dropdown
label="Product"
groups={[
{
items: productLinks.map((link) => ({
id: link.href,
label: link.label,
href: link.href,
meta: link.description ? undefined : undefined,
})),
},
]}
trigger={(props) => (
<button
type="button"
className="flex h-8 items-center gap-1 rounded-md px-3 text-sm font-medium text-ink-muted transition-colors hover:bg-surface-sunken hover:text-ink"
{...props}
>
Product
<ChevronDown className="size-3.5" aria-hidden="true" />
</button>
)}
/>
</li>
{resourceLinks.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>
))}
<li>
<Link
href="/changelog"
className="flex h-8 items-center gap-2 rounded-md px-3 text-sm font-medium text-ink-muted transition-colors hover:bg-surface-sunken hover:text-ink"
>
Changelog
<Badge size="sm" tone="accent">
1.0
</Badge>
</Link>
</li>
</ul>
</nav>
<div className="ml-auto hidden items-center gap-2 lg:flex">
<ButtonLink href="/starters/saas/preview/login" variant="ghost" size="sm">
Sign in
</ButtonLink>
<ButtonLink href="/starters/saas/preview/register" size="sm">
Start free trial
</ButtonLink>
</div>
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-expanded={open}
aria-controls="saas-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="saas-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="SaaS example, mobile">
<h2 className="label-caps mb-1 px-3 text-ink-subtle">Product</h2>
<ul className="flex flex-col gap-0.5">
{productLinks.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="flex min-h-11 flex-col justify-center rounded-md px-3 py-2 hover:bg-surface-sunken"
>
<span className="text-sm font-medium text-ink">{link.label}</span>
{link.description ? (
<span className="text-xs text-ink-muted">{link.description}</span>
) : null}
</Link>
</li>
))}
</ul>
<h2 className="label-caps mt-4 mb-1 px-3 text-ink-subtle">Resources</h2>
<ul className="flex flex-col gap-0.5">
{resourceLinks.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>
<div className="flex flex-col gap-2 border-t border-line-subtle pt-4">
<ButtonLink href="/starters/saas/preview/login" variant="outline" size="sm" block>
Sign in
</ButtonLink>
<ButtonLink href="/starters/saas/preview/register" size="sm" block>
Start free trial
</ButtonLink>
</div>
</div>
</div>
</div>
)
}
components/ui/dropdown.tsx
'use client'
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
import Link from 'next/link'
import { cn } from '@/lib/cn'
import { useDismiss } from '@/hooks/use-dismiss'
/**
* Dropdown menu
*
* The ARIA menu-button pattern: the trigger owns `aria-haspopup="menu"` and
* `aria-expanded`, the panel is a `role="menu"` and roving focus moves between
* `role="menuitem"` children with the arrow keys.
*
* Opening with ArrowUp focuses the last item, opening with ArrowDown or Enter
* focuses the first — the small detail that makes a menu feel native.
*/
export interface DropdownItem {
id: string
label: ReactNode
href?: string
onSelect?: () => void
icon?: ReactNode
/** Right-aligned hint, e.g. a keyboard shortcut. */
meta?: ReactNode
disabled?: boolean
tone?: 'default' | 'danger'
}
export interface DropdownGroup {
label?: string
items: DropdownItem[]
}
export interface DropdownProps {
trigger: (props: {
'aria-expanded': boolean
'aria-haspopup': 'menu'
'aria-controls': string
onClick: () => void
onKeyDown: (event: KeyboardEvent) => void
ref: React.Ref<HTMLButtonElement>
}) => ReactNode
groups: DropdownGroup[]
align?: 'start' | 'end'
label?: string
className?: string
menuClassName?: string
}
export function Dropdown({
trigger,
groups,
align = 'start',
label = 'Menu',
className,
menuClassName,
}: DropdownProps) {
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const itemRefs = useRef<Array<HTMLAnchorElement | HTMLButtonElement | null>>([])
const uid = useId()
const menuId = `${uid}-menu`
const flatItems = groups.flatMap((group) => group.items).filter((item) => !item.disabled)
const close = (focusTrigger = true) => {
setOpen(false)
if (focusTrigger) triggerRef.current?.focus()
}
useDismiss([rootRef], open, () => close(false))
const openAt = (position: 'first' | 'last') => {
setOpen(true)
requestAnimationFrame(() => {
const index = position === 'first' ? 0 : flatItems.length - 1
itemRefs.current[index]?.focus()
})
}
const onTriggerKeyDown = (event: KeyboardEvent) => {
if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
openAt('first')
} else if (event.key === 'ArrowUp') {
event.preventDefault()
openAt('last')
}
}
const onItemKeyDown = (event: KeyboardEvent, index: number) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
itemRefs.current[(index + 1) % flatItems.length]?.focus()
} else if (event.key === 'ArrowUp') {
event.preventDefault()
itemRefs.current[(index - 1 + flatItems.length) % flatItems.length]?.focus()
} else if (event.key === 'Home') {
event.preventDefault()
itemRefs.current[0]?.focus()
} else if (event.key === 'End') {
event.preventDefault()
itemRefs.current[flatItems.length - 1]?.focus()
} else if (event.key === 'Tab') {
close(false)
}
}
// Index every enabled item up front. Mutating a counter while rendering
// would work today and break the moment rendering is interrupted.
const itemIndex = new Map<string, number>()
flatItems.forEach((item, index) => itemIndex.set(item.id, index))
return (
<div ref={rootRef} className={cn('relative inline-block', className)}>
{trigger({
'aria-expanded': open,
'aria-haspopup': 'menu',
'aria-controls': menuId,
onClick: () => (open ? close() : openAt('first')),
onKeyDown: onTriggerKeyDown,
ref: triggerRef,
})}
{open ? (
<div
id={menuId}
role="menu"
aria-label={label}
className={cn(
'animate-scale-in absolute z-50 mt-1.5 min-w-56 rounded-lg border border-line bg-surface-raised p-1 shadow-md',
align === 'end' ? 'right-0' : 'left-0',
menuClassName,
)}
>
{groups.map((group, groupIndex) => (
<div
key={group.label ?? `group-${groupIndex}`}
role="group"
aria-label={group.label}
className={cn(groupIndex > 0 && 'mt-1 border-t border-line-subtle pt-1')}
>
{group.label ? (
<p className="label-caps px-2.5 py-1.5 text-ink-subtle">{group.label}</p>
) : null}
{group.items.map((item) => {
const index = itemIndex.get(item.id) ?? -1
const itemClass = cn(
'flex w-full items-center gap-2.5 rounded-sm px-2.5 py-1.5 text-left text-sm transition-colors duration-150',
item.disabled
? 'cursor-not-allowed text-ink-subtle opacity-60'
: item.tone === 'danger'
? 'text-danger hover:bg-danger-soft'
: 'text-ink hover:bg-surface-sunken',
)
if (item.href && !item.disabled) {
return (
<Link
key={item.id}
href={item.href}
role="menuitem"
tabIndex={-1}
ref={(el) => {
itemRefs.current[index] = el
}}
onKeyDown={(event) => onItemKeyDown(event, index)}
onClick={() => close(false)}
className={itemClass}
>
{item.icon}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.meta ? (
<span className="shrink-0 font-mono text-2xs text-ink-subtle">
{item.meta}
</span>
) : null}
</Link>
)
}
return (
<button
key={item.id}
type="button"
role="menuitem"
tabIndex={-1}
disabled={item.disabled}
ref={(el) => {
if (!item.disabled) itemRefs.current[index] = el
}}
onKeyDown={(event) => onItemKeyDown(event, index)}
onClick={() => {
item.onSelect?.()
close()
}}
className={itemClass}
>
{item.icon}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.meta ? (
<span className="shrink-0 font-mono text-2xs text-ink-subtle">
{item.meta}
</span>
) : null}
</button>
)
})}
</div>
))}
</div>
) : null}
</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
The conventional shape for a subscription product, and conventional is correct here — visitors already know where sign-in lives. The dropdown is the library primitive, so roving focus and Escape come for free.
- Separate sign-in from sign-up visually; they are different intents and the primary one must be unambiguous.
- The changelog badge is a real signal of activity — remove it rather than letting it go stale.
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.
- Product dropdown
- Changelog badge
- Dual auth actions
- Mobile grouped panel
Accessibility
- Menu button
- The product trigger uses the ARIA menu-button pattern via the Dropdown primitive.
- Mobile parity
- Every desktop destination, including descriptions, appears in the mobile panel.
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 navigationDropdown menu
The ARIA menu-button pattern with grouped items, roving focus, shortcuts and destructive styling.
intermediateFeatured6 variantsMega menu
A wide three-column panel with a promoted card, built as a disclosure rather than an ARIA menu.
advancedFeatured4 variantsSaaS starter
A twelve-route subscription product: marketing site, authentication, an in-product dashboard and account management, all from the same component set.
advancedFeatured4 variants