Skip to content

Articles by category

The same posts grouped under labelled category regions.

Contentstarterblogcategoriesgroupedindex

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 Link from 'next/link'
import { Container } from '@/components/ui/layout'
import { formatShortDate } from '@/lib/format'
import { posts } from '@/content/demo'

/**
 * Articles by category
 *
 * Groups the same posts under their categories, so a reader with a specific
 * interest can find it without a filter control. Each column is a labelled
 * region rather than a styled heading.
 */
export default function CategoryColumnsBlog() {
  const categories = Array.from(new Set(posts.map((post) => post.category)))

  return (
    <section className="border-b border-line bg-surface-sunken py-section">
      <Container>
        <div className="max-w-2xl">
          <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
            Browse by subject
          </h2>
        </div>

        <div className="mt-10 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
          {categories.map((category) => {
            const items = posts.filter((post) => post.category === category)
            return (
              <section
                key={category}
                aria-labelledby={`cat-${category.replace(/\s+/g, '-').toLowerCase()}`}
              >
                <h3
                  id={`cat-${category.replace(/\s+/g, '-').toLowerCase()}`}
                  className="label-caps border-b border-line pb-2 text-accent"
                >
                  {category}
                </h3>
                <ul className="mt-4 flex flex-col gap-4">
                  {items.map((post) => (
                    <li key={post.slug}>
                      <Link
                        href={`/starters/blog/preview/stories/${post.slug}`}
                        className="group block"
                      >
                        <p className="text-sm leading-snug font-medium text-ink group-hover:text-accent">
                          {post.title}
                        </p>
                        <time dateTime={post.date} className="mt-1 block text-xs text-ink-subtle">
                          {formatShortDate(post.date)}
                        </time>
                      </Link>
                    </li>
                  ))}
                </ul>
              </section>
            )
          })}
        </div>
      </Container>
    </section>
  )
}

components/blocks/sections/blog/category-columns.tsx

import Link from 'next/link'
import { Container } from '@/components/ui/layout'
import { formatShortDate } from '@/lib/format'
import { posts } from '@/content/demo'

/**
 * Articles by category
 *
 * Groups the same posts under their categories, so a reader with a specific
 * interest can find it without a filter control. Each column is a labelled
 * region rather than a styled heading.
 */
export default function CategoryColumnsBlog() {
  const categories = Array.from(new Set(posts.map((post) => post.category)))

  return (
    <section className="border-b border-line bg-surface-sunken py-section">
      <Container>
        <div className="max-w-2xl">
          <h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
            Browse by subject
          </h2>
        </div>

        <div className="mt-10 grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
          {categories.map((category) => {
            const items = posts.filter((post) => post.category === category)
            return (
              <section
                key={category}
                aria-labelledby={`cat-${category.replace(/\s+/g, '-').toLowerCase()}`}
              >
                <h3
                  id={`cat-${category.replace(/\s+/g, '-').toLowerCase()}`}
                  className="label-caps border-b border-line pb-2 text-accent"
                >
                  {category}
                </h3>
                <ul className="mt-4 flex flex-col gap-4">
                  {items.map((post) => (
                    <li key={post.slug}>
                      <Link
                        href={`/starters/blog/preview/stories/${post.slug}`}
                        className="group block"
                      >
                        <p className="text-sm leading-snug font-medium text-ink group-hover:text-accent">
                          {post.title}
                        </p>
                        <time dateTime={post.date} className="mt-1 block text-xs text-ink-subtle">
                          {formatShortDate(post.date)}
                        </time>
                      </Link>
                    </li>
                  ))}
                </ul>
              </section>
            )
          })}
        </div>
      </Container>
    </section>
  )
}

lib/format.ts

/** Small formatting helpers shared across catalogue and starter surfaces. */

export function titleCase(value: string): string {
  return value
    .split(/[-_\s]+/)
    .filter(Boolean)
    .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
    .join(' ')
}

export function pluralise(count: number, singular: string, plural = `${singular}s`): string {
  return `${count} ${count === 1 ? singular : plural}`
}

const currencyFormatter = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
  minimumFractionDigits: 2,
})

export function formatCurrency(cents: number): string {
  return currencyFormatter.format(cents / 100)
}

const compactFormatter = new Intl.NumberFormat('en-US', {
  notation: 'compact',
  maximumFractionDigits: 1,
})

export function formatCompact(value: number): string {
  return compactFormatter.format(value)
}

/**
 * Dates in Foundry are authored as ISO date strings so that server and client
 * renders agree byte-for-byte. Formatting is pinned to `en-US` + UTC for the
 * same reason — no hydration drift from the visitor's locale or timezone.
 */
export function formatDate(iso: string): string {
  const date = new Date(`${iso}T00:00:00Z`)
  if (Number.isNaN(date.getTime())) return iso
  return new Intl.DateTimeFormat('en-US', {
    year: 'numeric',
    month: 'long',
    day: 'numeric',
    timeZone: 'UTC',
  }).format(date)
}

export function formatShortDate(iso: string): string {
  const date = new Date(`${iso}T00:00:00Z`)
  if (Number.isNaN(date.getTime())) return iso
  return new Intl.DateTimeFormat('en-US', {
    month: 'short',
    day: 'numeric',
    year: 'numeric',
    timeZone: 'UTC',
  }).format(date)
}

export function initials(name: string): string {
  const parts = name.trim().split(/\s+/).filter(Boolean)
  if (parts.length === 0) return '?'
  if (parts.length === 1) return (parts[0] ?? '?').slice(0, 2).toUpperCase()
  return `${(parts[0] ?? '')[0] ?? ''}${(parts[parts.length - 1] ?? '')[0] ?? ''}`.toUpperCase()
}

export function slugify(value: string): string {
  return value
    .toLowerCase()
    .normalize('NFKD')
    .replace(/[^\w\s-]/g, '')
    .trim()
    .replace(/[\s_]+/g, '-')
    .replace(/-+/g, '-')
}

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

Usage

Groups let a reader with a specific interest find it without a filter control, which is one fewer interactive thing to build and maintain.

  • Derive categories from the posts rather than hard-coding them.
  • Keep entries to title and date; the grouping is doing the work.

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.

  • Four columns
  • Per-category regions
  • Compact entries

Accessibility

Labelled regions
Each category is a section with an aria-labelledby heading.
Time elements
Dates remain machine-readable.

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.