Skip to content

SaaS navbar

Product dropdown, flat secondary links, and a separated sign-in / sign-up pair.

NavbarsintermediateFeaturednavbarsaasdropdownsignup

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 { 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.