Skip to content

Related posts

Three suggestions for the end of an article, ordered by relatedness.

Contentstarterblogrelatedfooterrecirculation

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 { ArrowRight } from 'lucide-react'
import { Container } from '@/components/ui/layout'
import { Badge } from '@/components/ui/badge'
import { formatShortDate } from '@/lib/format'
import { posts } from '@/content/demo'

/**
 * Related posts footer
 *
 * The end-of-article block. Three items, because a reader who has finished an
 * article will choose from three and abandon a list of ten. Ordered by
 * relatedness rather than recency, which is the whole point of the block.
 */
export default function RelatedPostsBlog() {
  const related = posts.slice(1, 4)

  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container size="narrow">
        <div className="flex flex-wrap items-end justify-between gap-4 border-t border-line pt-8">
          <h2 className="display-type text-xl font-semibold text-ink-strong">Keep reading</h2>
          <Link
            href="/starters/blog/preview/stories"
            className="inline-flex items-center gap-1.5 text-sm font-semibold text-accent underline underline-offset-4"
          >
            All articles
            <ArrowRight className="size-4" aria-hidden="true" />
          </Link>
        </div>

        <ul className="mt-6 grid gap-4 sm:grid-cols-3">
          {related.map((post) => (
            <li key={post.slug}>
              <Link
                href={`/starters/blog/preview/stories/${post.slug}`}
                className="group flex h-full flex-col rounded-xl border border-line bg-surface p-5 transition-colors hover:border-accent"
              >
                <Badge size="sm">{post.category}</Badge>
                <h3 className="mt-3 flex-1 text-sm leading-snug font-semibold text-ink-strong group-hover:text-accent">
                  {post.title}
                </h3>
                <time dateTime={post.date} className="mt-4 text-xs text-ink-subtle">
                  {formatShortDate(post.date)} · {post.readingTime}
                </time>
              </Link>
            </li>
          ))}
        </ul>
      </Container>
    </section>
  )
}

components/blocks/sections/blog/related-posts.tsx

import Link from 'next/link'
import { ArrowRight } from 'lucide-react'
import { Container } from '@/components/ui/layout'
import { Badge } from '@/components/ui/badge'
import { formatShortDate } from '@/lib/format'
import { posts } from '@/content/demo'

/**
 * Related posts footer
 *
 * The end-of-article block. Three items, because a reader who has finished an
 * article will choose from three and abandon a list of ten. Ordered by
 * relatedness rather than recency, which is the whole point of the block.
 */
export default function RelatedPostsBlog() {
  const related = posts.slice(1, 4)

  return (
    <section className="border-b border-line bg-canvas py-section">
      <Container size="narrow">
        <div className="flex flex-wrap items-end justify-between gap-4 border-t border-line pt-8">
          <h2 className="display-type text-xl font-semibold text-ink-strong">Keep reading</h2>
          <Link
            href="/starters/blog/preview/stories"
            className="inline-flex items-center gap-1.5 text-sm font-semibold text-accent underline underline-offset-4"
          >
            All articles
            <ArrowRight className="size-4" aria-hidden="true" />
          </Link>
        </div>

        <ul className="mt-6 grid gap-4 sm:grid-cols-3">
          {related.map((post) => (
            <li key={post.slug}>
              <Link
                href={`/starters/blog/preview/stories/${post.slug}`}
                className="group flex h-full flex-col rounded-xl border border-line bg-surface p-5 transition-colors hover:border-accent"
              >
                <Badge size="sm">{post.category}</Badge>
                <h3 className="mt-3 flex-1 text-sm leading-snug font-semibold text-ink-strong group-hover:text-accent">
                  {post.title}
                </h3>
                <time dateTime={post.date} className="mt-4 text-xs text-ink-subtle">
                  {formatShortDate(post.date)} · {post.readingTime}
                </time>
              </Link>
            </li>
          ))}
        </ul>
      </Container>
    </section>
  )
}

components/ui/badge.tsx

import type { HTMLAttributes, ReactNode } from 'react'
import { variants } from '@/lib/variants'

/**
 * Badge
 *
 * A compact, non-interactive label. Tones map to the status token trios, and
 * because a badge is often the only signal in a dense table, the `dot` option
 * exists to add a second, colour-independent cue alongside the text.
 */
const badgeVariants = variants('inline-flex items-center gap-1.5 whitespace-nowrap font-medium', {
  variants: {
    tone: {
      neutral: 'bg-surface-sunken text-ink-muted border-line',
      accent: 'bg-accent-soft text-accent-soft-ink border-accent-line',
      success: 'bg-success-soft text-success border-success-line',
      warning: 'bg-warning-soft text-warning border-warning-line',
      danger: 'bg-danger-soft text-danger border-danger-line',
      info: 'bg-info-soft text-info border-info-line',
      inverse: 'bg-surface-inverse text-ink-inverse border-transparent',
    },
    appearance: {
      soft: 'border',
      outline: 'border bg-transparent',
      solid: 'border border-transparent',
    },
    size: {
      sm: 'h-4.5 rounded-sm px-1.5 text-2xs',
      md: 'h-5.5 rounded-sm px-2 text-xs',
    },
  },
  defaultVariants: { tone: 'neutral', appearance: 'soft', size: 'md' },
  compound: [
    { appearance: 'solid', tone: 'accent', class: 'bg-accent text-accent-ink' },
    { appearance: 'solid', tone: 'success', class: 'bg-success text-white' },
    { appearance: 'solid', tone: 'warning', class: 'bg-warning text-white' },
    { appearance: 'solid', tone: 'danger', class: 'bg-danger text-white' },
    { appearance: 'solid', tone: 'info', class: 'bg-info text-white' },
    { appearance: 'solid', tone: 'neutral', class: 'bg-ink text-ink-inverse' },
    { appearance: 'outline', tone: 'neutral', class: 'text-ink-muted' },
  ],
})

export type BadgeTone = 'neutral' | 'accent' | 'success' | 'warning' | 'danger' | 'info' | 'inverse'

export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
  tone?: BadgeTone
  appearance?: 'soft' | 'outline' | 'solid'
  size?: 'sm' | 'md'
  /** Adds a leading dot so the badge does not rely on hue alone. */
  dot?: boolean
  icon?: ReactNode
}

export function Badge({
  tone = 'neutral',
  appearance = 'soft',
  size = 'md',
  dot = false,
  icon,
  className,
  children,
  ...props
}: BadgeProps) {
  return (
    <span className={badgeVariants({ tone, appearance, size, className })} {...props}>
      {dot ? (
        <span className="size-1.5 shrink-0 rounded-full bg-current" aria-hidden="true" />
      ) : null}
      {icon}
      {children}
    </span>
  )
}

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

Usage

Three items, because a reader who has finished an article will choose from three and abandon a list of ten. Ordered by relatedness rather than recency, which is the whole point of the block.

  • Never show the article the reader just finished.
  • Three items. This block competes with leaving, not with the rest of the site.

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 cards
  • Category badges
  • All-articles link

Accessibility

Whole-card links
Each suggestion is one focus stop.
Heading level
Card titles are h3 under the section h2.

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.