Article card grid
Three columns of equal cards with reserved media areas.
Live preview
Source
This exact file renders the preview above.
import Link from 'next/link'
import { Container } from '@/components/ui/layout'
import { Badge } from '@/components/ui/badge'
import { Avatar } from '@/components/ui/avatar'
import { formatShortDate } from '@/lib/format'
import { posts } from '@/content/demo'
/**
* Article card grid
*
* Three columns of equal cards with a reserved 16:9 media area. The aspect
* ratio is declared, so the grid does not reflow when images load — the most
* common layout-shift source on a blog index.
*/
export default function BlogCardGrid() {
return (
<section className="border-b border-line bg-canvas py-section">
<Container>
<div className="max-w-2xl">
<h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
Latest articles
</h2>
</div>
<ul className="mt-10 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.slice(0, 6).map((post) => (
<li key={post.slug}>
<article>
<Link href={`/starters/blog/preview/stories/${post.slug}`} className="group block">
<div
className="grid-paper aspect-video w-full rounded-lg border border-line bg-surface-sunken"
aria-hidden="true"
/>
<Badge tone="accent" size="sm" className="mt-4">
{post.category}
</Badge>
<h3 className="mt-2.5 text-md leading-snug font-semibold text-ink-strong group-hover:text-accent">
{post.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-ink-muted">{post.excerpt}</p>
<div className="mt-4 flex items-center gap-2.5">
<Avatar name={post.author} size="xs" decorative />
<span className="text-xs text-ink-muted">{post.author}</span>
<span className="text-xs text-ink-subtle">·</span>
<time dateTime={post.date} className="text-xs text-ink-subtle">
{formatShortDate(post.date)}
</time>
</div>
</Link>
</article>
</li>
))}
</ul>
</Container>
</section>
)
}
components/blocks/sections/blog/card-grid.tsx
import Link from 'next/link'
import { Container } from '@/components/ui/layout'
import { Badge } from '@/components/ui/badge'
import { Avatar } from '@/components/ui/avatar'
import { formatShortDate } from '@/lib/format'
import { posts } from '@/content/demo'
/**
* Article card grid
*
* Three columns of equal cards with a reserved 16:9 media area. The aspect
* ratio is declared, so the grid does not reflow when images load — the most
* common layout-shift source on a blog index.
*/
export default function BlogCardGrid() {
return (
<section className="border-b border-line bg-canvas py-section">
<Container>
<div className="max-w-2xl">
<h2 className="display-type text-2xl font-semibold text-ink-strong sm:text-3xl">
Latest articles
</h2>
</div>
<ul className="mt-10 grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.slice(0, 6).map((post) => (
<li key={post.slug}>
<article>
<Link href={`/starters/blog/preview/stories/${post.slug}`} className="group block">
<div
className="grid-paper aspect-video w-full rounded-lg border border-line bg-surface-sunken"
aria-hidden="true"
/>
<Badge tone="accent" size="sm" className="mt-4">
{post.category}
</Badge>
<h3 className="mt-2.5 text-md leading-snug font-semibold text-ink-strong group-hover:text-accent">
{post.title}
</h3>
<p className="mt-2 text-sm leading-relaxed text-ink-muted">{post.excerpt}</p>
<div className="mt-4 flex items-center gap-2.5">
<Avatar name={post.author} size="xs" decorative />
<span className="text-xs text-ink-muted">{post.author}</span>
<span className="text-xs text-ink-subtle">·</span>
<time dateTime={post.date} className="text-xs text-ink-subtle">
{formatShortDate(post.date)}
</time>
</div>
</Link>
</article>
</li>
))}
</ul>
</Container>
</section>
)
}
components/ui/avatar.tsx
import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'
import { initials as toInitials } from '@/lib/format'
/**
* Avatar / AvatarGroup
*
* Foundry ships no photography, so avatars render deterministic initials on a
* tinted surface. The tint is derived from the name's character codes, which
* keeps the same person the same colour on every page without a colour field
* in the data.
*
* A decorative avatar next to a visible name is `aria-hidden`; a standalone
* one exposes the name as its label.
*/
export interface AvatarProps {
name: string
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
/** Suppresses the accessible name when the name is already on screen. */
decorative?: boolean
/** Small badge anchored bottom-right, e.g. a presence dot. */
indicator?: ReactNode
shape?: 'circle' | 'square'
className?: string
}
const sizes = {
xs: 'size-5 text-2xs',
sm: 'size-7 text-2xs',
md: 'size-9 text-xs',
lg: 'size-12 text-sm',
xl: 'size-16 text-lg',
} as const
const tints = [
'bg-accent-soft text-accent-soft-ink',
'bg-success-soft text-success',
'bg-warning-soft text-warning',
'bg-info-soft text-info',
'bg-danger-soft text-danger',
'bg-surface-sunken text-ink-muted',
] as const
function tintFor(name: string): string {
let hash = 0
for (let i = 0; i < name.length; i += 1) hash = (hash * 31 + name.charCodeAt(i)) % 997
return tints[hash % tints.length] ?? tints[0]
}
export function Avatar({
name,
size = 'md',
decorative = false,
indicator,
shape = 'circle',
className,
}: AvatarProps) {
return (
<span className={cn('relative inline-flex shrink-0', className)}>
<span
role={decorative ? undefined : 'img'}
aria-label={decorative ? undefined : name}
aria-hidden={decorative || undefined}
className={cn(
'inline-flex items-center justify-center border border-line font-semibold select-none',
shape === 'circle' ? 'rounded-full' : 'rounded-md',
sizes[size],
tintFor(name),
)}
>
{toInitials(name)}
</span>
{indicator ? <span className="absolute -right-0.5 -bottom-0.5">{indicator}</span> : null}
</span>
)
}
export interface AvatarGroupProps {
names: string[]
size?: AvatarProps['size']
/** Names beyond this count collapse into a "+n" chip. */
max?: number
className?: string
label?: string
}
export function AvatarGroup({ names, size = 'sm', max = 4, className, label }: AvatarGroupProps) {
const visible = names.slice(0, max)
const overflow = names.length - visible.length
return (
<span
className={cn('flex items-center', className)}
role="group"
aria-label={label ?? `${names.length} people`}
>
{visible.map((name) => (
<span
key={name}
className="-ml-2 first:ml-0 ring-2 ring-[var(--color-surface)] rounded-full"
>
<Avatar name={name} size={size} decorative />
</span>
))}
{overflow > 0 ? (
<span
className={cn(
'-ml-2 inline-flex items-center justify-center rounded-full border border-line bg-surface-sunken font-semibold text-ink-muted ring-2 ring-[var(--color-surface)]',
sizes[size],
)}
>
+{overflow}
</span>
) : null}
<span className="sr-only">{names.join(', ')}</span>
</span>
)
}
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
The aspect ratio is declared, so the grid does not reflow when images load — the most common layout-shift source on a blog index.
- Show the author; a byline is the cheapest credibility signal a blog has.
- Keep excerpts to two lines so the cards stay even.
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 at lg
- Reserved 16:9 media
- Author and date
Accessibility
- Article semantics
- Each card is an article with a heading.
- Reserved ratio
- Media areas occupy their final size from first paint.
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.
Related
All sectionsFeatured post with list
One lead article plus a scannable list of five.
starterFeatured3 variantsArticles by category
The same posts grouped under labelled category regions.
starter3 variantsProduct grid
A storefront grid with reserved image areas, price comparison and stock state.
starterFeatured3 variants