Skip to content

404 section

A missing page that still offers the real top-level destinations.

StatesstarterFeaturederror404not-foundrecovery

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.

import { ArrowLeft, Compass, Search } from 'lucide-react'
import Link from 'next/link'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { families } from '@/lib/catalogue'

/**
 * 404 section
 *
 * A page that does not exist should still be useful. The status code is stated
 * plainly, and the recovery options are the real top-level destinations read
 * from the catalogue — not a hard-coded list that goes stale.
 */
export default function NotFoundSection() {
  return (
    <section className="bg-canvas py-section">
      <Container size="narrow" className="text-center">
        <p className="display-type text-6xl font-semibold text-ink-subtle tabular-nums">404</p>
        <h1 className="display-type mt-4 text-2xl font-semibold text-ink-strong sm:text-3xl">
          That page does not exist.
        </h1>
        <p className="mx-auto mt-3 max-w-md text-md text-ink-muted">
          The link may be out of date, or the address may have a typo in it. Nothing has broken.
        </p>

        <div className="mt-8 flex flex-wrap items-center justify-center gap-3">
          <ButtonLink href="/" leadingIcon={<ArrowLeft className="size-4" />}>
            Back to the homepage
          </ButtonLink>
          <ButtonLink href="/search" variant="outline" leadingIcon={<Search className="size-4" />}>
            Search the catalogue
          </ButtonLink>
        </div>

        <div className="mt-12 border-t border-line pt-8">
          <p className="label-caps flex items-center justify-center gap-2 text-ink-subtle">
            <Compass className="size-3.5" aria-hidden="true" />
            Or start from a catalogue
          </p>
          <ul className="mt-4 flex flex-wrap items-center justify-center gap-2">
            {families.map((family) => (
              <li key={family.segment}>
                <Link
                  href={`/${family.segment}`}
                  className="inline-flex rounded-full border border-line bg-surface px-3.5 py-1.5 text-sm text-ink-muted transition-colors hover:border-accent hover:text-accent"
                >
                  {family.label}
                </Link>
              </li>
            ))}
          </ul>
        </div>
      </Container>
    </section>
  )
}

components/blocks/sections/error/not-found.tsx

import { ArrowLeft, Compass, Search } from 'lucide-react'
import Link from 'next/link'
import { ButtonLink } from '@/components/ui/button'
import { Container } from '@/components/ui/layout'
import { families } from '@/lib/catalogue'

/**
 * 404 section
 *
 * A page that does not exist should still be useful. The status code is stated
 * plainly, and the recovery options are the real top-level destinations read
 * from the catalogue — not a hard-coded list that goes stale.
 */
export default function NotFoundSection() {
  return (
    <section className="bg-canvas py-section">
      <Container size="narrow" className="text-center">
        <p className="display-type text-6xl font-semibold text-ink-subtle tabular-nums">404</p>
        <h1 className="display-type mt-4 text-2xl font-semibold text-ink-strong sm:text-3xl">
          That page does not exist.
        </h1>
        <p className="mx-auto mt-3 max-w-md text-md text-ink-muted">
          The link may be out of date, or the address may have a typo in it. Nothing has broken.
        </p>

        <div className="mt-8 flex flex-wrap items-center justify-center gap-3">
          <ButtonLink href="/" leadingIcon={<ArrowLeft className="size-4" />}>
            Back to the homepage
          </ButtonLink>
          <ButtonLink href="/search" variant="outline" leadingIcon={<Search className="size-4" />}>
            Search the catalogue
          </ButtonLink>
        </div>

        <div className="mt-12 border-t border-line pt-8">
          <p className="label-caps flex items-center justify-center gap-2 text-ink-subtle">
            <Compass className="size-3.5" aria-hidden="true" />
            Or start from a catalogue
          </p>
          <ul className="mt-4 flex flex-wrap items-center justify-center gap-2">
            {families.map((family) => (
              <li key={family.segment}>
                <Link
                  href={`/${family.segment}`}
                  className="inline-flex rounded-full border border-line bg-surface px-3.5 py-1.5 text-sm text-ink-muted transition-colors hover:border-accent hover:text-accent"
                >
                  {family.label}
                </Link>
              </li>
            ))}
          </ul>
        </div>
      </Container>
    </section>
  )
}

lib/catalogue/index.ts

import { componentItems } from './components'
import { navigationItems } from './navigation'
import { formItems } from './forms'
import { sectionItems } from './sections'
import { patternItems } from './patterns'
import { starterItems } from './starters'
import { families, familyBySegment, familyByName } from './families'
import type { CatalogueFamily, CatalogueItem, FamilyDefinition } from './types'

export * from './types'
export { families, familyBySegment, familyByName } from './families'

/**
 * The catalogue graph.
 *
 * One flat array plus a handful of indexes. Every browse, filter, search and
 * related-content surface in the application reads from these — which is why
 * adding an item never requires touching a page component.
 */
export const catalogue: CatalogueItem[] = [
  ...componentItems,
  ...navigationItems,
  ...formItems,
  ...sectionItems,
  ...patternItems,
  ...starterItems,
]

export const catalogueById = new Map(catalogue.map((item) => [item.id, item]))

export const catalogueByFamily = new Map<CatalogueFamily, CatalogueItem[]>(
  families.map((family) => [
    family.family,
    catalogue.filter((item) => item.family === family.family),
  ]),
)

export function itemsForFamily(family: CatalogueFamily): CatalogueItem[] {
  return catalogueByFamily.get(family) ?? []
}

export function itemsForSegment(segment: string): CatalogueItem[] {
  const family = familyBySegment.get(segment)
  return family ? itemsForFamily(family.family) : []
}

export function findItem(family: CatalogueFamily, slug: string): CatalogueItem | undefined {
  return catalogueById.get(`${family}/${slug}`)
}

export function resolveRelated(item: CatalogueItem): CatalogueItem[] {
  return item.relatedItems
    .map((id) => catalogueById.get(id))
    .filter((candidate): candidate is CatalogueItem => Boolean(candidate))
}

/** Route segment for an item, e.g. `/components/button`. */
export function hrefForItem(item: CatalogueItem): string {
  const family = familyByName.get(item.family)
  return `/${family?.segment ?? 'components'}/${item.slug}`
}

export function categoriesForFamily(family: CatalogueFamily): FamilyDefinition['categories'] {
  return familyByName.get(family)?.categories ?? []
}

/** Live counts. Never hard-code a marketing number — derive it from here. */
export const catalogueCounts = {
  component: itemsForFamily('component').length,
  navigation: itemsForFamily('navigation').length,
  form: itemsForFamily('form').length,
  section: itemsForFamily('section').length,
  pattern: itemsForFamily('pattern').length,
  starter: itemsForFamily('starter').length,
  total: catalogue.length,
} as const

export function countForCategory(family: CatalogueFamily, category: string): number {
  return itemsForFamily(family).filter((item) => item.category === category).length
}

export const featuredItems = catalogue.filter((item) => item.featured)

/** Every tag in use, with its frequency, most used first. */
export const tagIndex: Array<{ tag: string; count: number }> = (() => {
  const counts = new Map<string, number>()
  for (const item of catalogue) {
    for (const tag of item.tags) counts.set(tag, (counts.get(tag) ?? 0) + 1)
  }
  return Array.from(counts.entries())
    .map(([tag, count]) => ({ tag, count }))
    .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag))
})()

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

Usage

The recovery options are the real top-level destinations read from the catalogue, not a hard-coded list that goes stale.

  • State the code plainly; hiding it helps nobody diagnose a broken link.
  • Offer search as well as home — the visitor was looking for something specific.

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.

  • Status code
  • Two recovery actions
  • Catalogue links from data

Accessibility

Single h1
The 404 page has one heading, and the numeral is a paragraph.
Derived links
Destination links come from the catalogue, so they cannot go stale.

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.