Skip to content

Mega menu

A wide three-column panel with a promoted card, built as a disclosure rather than an ARIA menu.

NavbarsadvancedFeaturednavbarmega-menudisclosureenterprise

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 { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { ArrowRight, ChevronDown, Menu, X } from 'lucide-react'
import { cn } from '@/lib/cn'
import { BrandMark } from '@/components/library/brand'
import { ButtonLink } from '@/components/ui/button'
import { megaMenu } from '@/content/nav-demo'

/**
 * Mega menu
 *
 * A single wide panel holding three columns and a promoted card. It is a
 * disclosure, not an ARIA menu: the contents are links and headings, so
 * `aria-expanded` plus normal Tab order is both simpler and more usable than
 * forcing roving `menuitem` focus onto a page of navigation.
 *
 * Escape closes and returns focus to the trigger; a pointer press outside
 * closes without stealing focus.
 */
export default function MegaMenuNavbar() {
  const [open, setOpen] = useState(false)
  const [mobileOpen, setMobileOpen] = useState(false)
  const containerRef = useRef<HTMLDivElement>(null)
  const triggerRef = useRef<HTMLButtonElement>(null)

  useEffect(() => {
    if (!open) return
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'Escape') {
        setOpen(false)
        triggerRef.current?.focus()
      }
    }
    const onPointerDown = (event: PointerEvent) => {
      if (!containerRef.current?.contains(event.target as Node)) setOpen(false)
    }
    document.addEventListener('keydown', onKeyDown)
    document.addEventListener('pointerdown', onPointerDown)
    return () => {
      document.removeEventListener('keydown', onKeyDown)
      document.removeEventListener('pointerdown', onPointerDown)
    }
  }, [open])

  return (
    <div ref={containerRef} className="relative 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="Mega menu example" className="hidden lg:block">
          <ul className="flex items-center gap-1">
            <li>
              <button
                ref={triggerRef}
                type="button"
                onClick={() => setOpen((value) => !value)}
                aria-expanded={open}
                aria-controls="mega-panel"
                className={cn(
                  'flex h-8 items-center gap-1 rounded-md px-3 text-sm font-medium transition-colors',
                  open
                    ? 'bg-surface-sunken text-ink-strong'
                    : 'text-ink-muted hover:bg-surface-sunken hover:text-ink',
                )}
              >
                Platform
                <ChevronDown
                  className={cn('size-3.5 transition-transform', open && 'rotate-180')}
                  aria-hidden="true"
                />
              </button>
            </li>
            <li>
              <Link
                href="/patterns"
                className="flex h-8 items-center rounded-md px-3 text-sm font-medium text-ink-muted hover:bg-surface-sunken hover:text-ink"
              >
                Patterns
              </Link>
            </li>
            <li>
              <Link
                href="/docs/getting-started"
                className="flex h-8 items-center rounded-md px-3 text-sm font-medium text-ink-muted hover:bg-surface-sunken hover:text-ink"
              >
                Docs
              </Link>
            </li>
          </ul>
        </nav>

        <div className="ml-auto hidden lg:block">
          <ButtonLink href="/starters" size="sm">
            Browse starters
          </ButtonLink>
        </div>

        <button
          type="button"
          onClick={() => setMobileOpen((value) => !value)}
          aria-expanded={mobileOpen}
          aria-controls="mega-mobile-panel"
          className="ml-auto flex size-9 items-center justify-center rounded-md text-ink-muted hover:bg-surface-sunken lg:hidden"
        >
          {mobileOpen ? (
            <X className="size-5" aria-hidden="true" />
          ) : (
            <Menu className="size-5" aria-hidden="true" />
          )}
          <span className="sr-only">{mobileOpen ? 'Close navigation' : 'Open navigation'}</span>
        </button>
      </div>

      <div
        id="mega-panel"
        hidden={!open}
        className="animate-fade-in absolute inset-x-0 top-full z-40 hidden border-y border-line bg-surface-raised shadow-lg lg:block"
      >
        <div className="mx-auto grid w-full max-w-7xl gap-8 px-4 py-8 sm:px-6 lg:grid-cols-4">
          {megaMenu.map((group) => (
            <div key={group.label}>
              <h2 className="label-caps mb-3 text-ink-subtle">{group.label}</h2>
              <ul className="flex flex-col gap-1">
                {group.links.map((link) => (
                  <li key={link.href}>
                    <Link
                      href={link.href}
                      onClick={() => setOpen(false)}
                      className="block rounded-md p-2 transition-colors hover:bg-surface-sunken"
                    >
                      <span className="block text-sm font-medium text-ink">{link.label}</span>
                      {link.description ? (
                        <span className="mt-0.5 block text-xs text-ink-muted">
                          {link.description}
                        </span>
                      ) : null}
                    </Link>
                  </li>
                ))}
              </ul>
            </div>
          ))}

          {megaMenu
            .map((group) => group.feature)
            .filter(Boolean)
            .slice(0, 1)
            .map((feature) =>
              feature ? (
                <div
                  key={feature.href}
                  className="rounded-lg border border-accent-line bg-accent-soft p-5"
                >
                  <p className="text-sm font-semibold text-accent-soft-ink">{feature.title}</p>
                  <p className="mt-1.5 text-xs text-accent-soft-ink/85">{feature.description}</p>
                  <Link
                    href={feature.href}
                    onClick={() => setOpen(false)}
                    className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold text-accent-soft-ink underline underline-offset-4"
                  >
                    {feature.cta}
                    <ArrowRight className="size-3.5" aria-hidden="true" />
                  </Link>
                </div>
              ) : null,
            )}
        </div>
      </div>

      <div
        id="mega-mobile-panel"
        hidden={!mobileOpen}
        className="border-t border-line-subtle lg:hidden"
      >
        <div className="flex flex-col gap-5 px-4 py-4">
          {megaMenu.map((group) => (
            <nav key={group.label} aria-label={`${group.label}, mobile`}>
              <h2 className="label-caps mb-1 px-3 text-ink-subtle">{group.label}</h2>
              <ul className="flex flex-col gap-0.5">
                {group.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>
            </nav>
          ))}
          <ButtonLink href="/starters" size="sm" block>
            Browse starters
          </ButtonLink>
        </div>
      </div>
    </div>
  )
}

components/blocks/navigation/mega-menu.tsx

'use client'

import { useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { ArrowRight, ChevronDown, Menu, X } from 'lucide-react'
import { cn } from '@/lib/cn'
import { BrandMark } from '@/components/library/brand'
import { ButtonLink } from '@/components/ui/button'
import { megaMenu } from '@/content/nav-demo'

/**
 * Mega menu
 *
 * A single wide panel holding three columns and a promoted card. It is a
 * disclosure, not an ARIA menu: the contents are links and headings, so
 * `aria-expanded` plus normal Tab order is both simpler and more usable than
 * forcing roving `menuitem` focus onto a page of navigation.
 *
 * Escape closes and returns focus to the trigger; a pointer press outside
 * closes without stealing focus.
 */
export default function MegaMenuNavbar() {
  const [open, setOpen] = useState(false)
  const [mobileOpen, setMobileOpen] = useState(false)
  const containerRef = useRef<HTMLDivElement>(null)
  const triggerRef = useRef<HTMLButtonElement>(null)

  useEffect(() => {
    if (!open) return
    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'Escape') {
        setOpen(false)
        triggerRef.current?.focus()
      }
    }
    const onPointerDown = (event: PointerEvent) => {
      if (!containerRef.current?.contains(event.target as Node)) setOpen(false)
    }
    document.addEventListener('keydown', onKeyDown)
    document.addEventListener('pointerdown', onPointerDown)
    return () => {
      document.removeEventListener('keydown', onKeyDown)
      document.removeEventListener('pointerdown', onPointerDown)
    }
  }, [open])

  return (
    <div ref={containerRef} className="relative 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="Mega menu example" className="hidden lg:block">
          <ul className="flex items-center gap-1">
            <li>
              <button
                ref={triggerRef}
                type="button"
                onClick={() => setOpen((value) => !value)}
                aria-expanded={open}
                aria-controls="mega-panel"
                className={cn(
                  'flex h-8 items-center gap-1 rounded-md px-3 text-sm font-medium transition-colors',
                  open
                    ? 'bg-surface-sunken text-ink-strong'
                    : 'text-ink-muted hover:bg-surface-sunken hover:text-ink',
                )}
              >
                Platform
                <ChevronDown
                  className={cn('size-3.5 transition-transform', open && 'rotate-180')}
                  aria-hidden="true"
                />
              </button>
            </li>
            <li>
              <Link
                href="/patterns"
                className="flex h-8 items-center rounded-md px-3 text-sm font-medium text-ink-muted hover:bg-surface-sunken hover:text-ink"
              >
                Patterns
              </Link>
            </li>
            <li>
              <Link
                href="/docs/getting-started"
                className="flex h-8 items-center rounded-md px-3 text-sm font-medium text-ink-muted hover:bg-surface-sunken hover:text-ink"
              >
                Docs
              </Link>
            </li>
          </ul>
        </nav>

        <div className="ml-auto hidden lg:block">
          <ButtonLink href="/starters" size="sm">
            Browse starters
          </ButtonLink>
        </div>

        <button
          type="button"
          onClick={() => setMobileOpen((value) => !value)}
          aria-expanded={mobileOpen}
          aria-controls="mega-mobile-panel"
          className="ml-auto flex size-9 items-center justify-center rounded-md text-ink-muted hover:bg-surface-sunken lg:hidden"
        >
          {mobileOpen ? (
            <X className="size-5" aria-hidden="true" />
          ) : (
            <Menu className="size-5" aria-hidden="true" />
          )}
          <span className="sr-only">{mobileOpen ? 'Close navigation' : 'Open navigation'}</span>
        </button>
      </div>

      <div
        id="mega-panel"
        hidden={!open}
        className="animate-fade-in absolute inset-x-0 top-full z-40 hidden border-y border-line bg-surface-raised shadow-lg lg:block"
      >
        <div className="mx-auto grid w-full max-w-7xl gap-8 px-4 py-8 sm:px-6 lg:grid-cols-4">
          {megaMenu.map((group) => (
            <div key={group.label}>
              <h2 className="label-caps mb-3 text-ink-subtle">{group.label}</h2>
              <ul className="flex flex-col gap-1">
                {group.links.map((link) => (
                  <li key={link.href}>
                    <Link
                      href={link.href}
                      onClick={() => setOpen(false)}
                      className="block rounded-md p-2 transition-colors hover:bg-surface-sunken"
                    >
                      <span className="block text-sm font-medium text-ink">{link.label}</span>
                      {link.description ? (
                        <span className="mt-0.5 block text-xs text-ink-muted">
                          {link.description}
                        </span>
                      ) : null}
                    </Link>
                  </li>
                ))}
              </ul>
            </div>
          ))}

          {megaMenu
            .map((group) => group.feature)
            .filter(Boolean)
            .slice(0, 1)
            .map((feature) =>
              feature ? (
                <div
                  key={feature.href}
                  className="rounded-lg border border-accent-line bg-accent-soft p-5"
                >
                  <p className="text-sm font-semibold text-accent-soft-ink">{feature.title}</p>
                  <p className="mt-1.5 text-xs text-accent-soft-ink/85">{feature.description}</p>
                  <Link
                    href={feature.href}
                    onClick={() => setOpen(false)}
                    className="mt-4 inline-flex items-center gap-1.5 text-xs font-semibold text-accent-soft-ink underline underline-offset-4"
                  >
                    {feature.cta}
                    <ArrowRight className="size-3.5" aria-hidden="true" />
                  </Link>
                </div>
              ) : null,
            )}
        </div>
      </div>

      <div
        id="mega-mobile-panel"
        hidden={!mobileOpen}
        className="border-t border-line-subtle lg:hidden"
      >
        <div className="flex flex-col gap-5 px-4 py-4">
          {megaMenu.map((group) => (
            <nav key={group.label} aria-label={`${group.label}, mobile`}>
              <h2 className="label-caps mb-1 px-3 text-ink-subtle">{group.label}</h2>
              <ul className="flex flex-col gap-0.5">
                {group.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>
            </nav>
          ))}
          <ButtonLink href="/starters" size="sm" block>
            Browse starters
          </ButtonLink>
        </div>
      </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 products with a wide surface area. Deliberately a disclosure, not `role="menu"`: the contents are links and headings, so normal Tab order is both simpler and more usable than forcing roving `menuitem` focus across a page of navigation.

  • Escape closes and returns focus to the trigger; an outside press closes without stealing focus.
  • Selecting a link closes the panel, so the next page does not load underneath an open menu.
  • Keep to three columns. A fourth is a sign the information architecture needs work, not more space.

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.

  • Three columns
  • Promoted feature card
  • Escape and outside-press dismissal
  • Mobile grouped panel

Accessibility

Disclosure semantics
`aria-expanded` and `aria-controls` on the trigger; the panel is a plain region of links.
Dismissal
Escape, outside press and selection all close it — the three ways people expect.

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.