Bento feature grid
Cells of unequal size where the size encodes importance.
Live preview
Source
This exact file renders the preview above.
import { Boxes, Gauge, KeyRound, Rows3 } from 'lucide-react'
import { Container } from '@/components/ui/layout'
import { Badge } from '@/components/ui/badge'
import { catalogueCounts } from '@/lib/catalogue'
/**
* Bento feature grid
*
* Cells of unequal size, where the size encodes importance. The grid is
* explicit at `lg` and collapses to a single column below it — a bento layout
* that keeps its asymmetry on a phone is unreadable.
*/
export default function BentoFeatures() {
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">
The parts that make it a system rather than a folder.
</h2>
</div>
<div className="mt-12 grid gap-4 lg:grid-cols-3 lg:grid-rows-2">
<div className="rounded-xl border border-line bg-surface p-6 lg:col-span-2 lg:row-span-2 lg:p-8">
<Badge tone="accent">Catalogue</Badge>
<h3 className="display-type mt-4 text-xl font-semibold text-ink-strong sm:text-2xl">
One typed record shape across six families
</h3>
<p className="mt-3 max-w-lg text-sm leading-relaxed text-ink-muted">
Components, navigation, forms, sections, patterns and starters all share the same
record. That is what lets one detail renderer, one search index and one
related-content engine serve everything.
</p>
<dl className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-3">
{[
{ label: 'Components', value: catalogueCounts.component },
{ label: 'Navigation', value: catalogueCounts.navigation },
{ label: 'Forms', value: catalogueCounts.form },
{ label: 'Sections', value: catalogueCounts.section },
{ label: 'Patterns', value: catalogueCounts.pattern },
{ label: 'Starters', value: catalogueCounts.starter },
].map((entry) => (
<div key={entry.label}>
<dd className="display-type text-xl font-semibold text-ink-strong tabular-nums">
{entry.value}
</dd>
<dt className="label-caps mt-0.5 text-ink-subtle">{entry.label}</dt>
</div>
))}
</dl>
</div>
<div className="rounded-xl border border-line bg-surface p-6">
<Gauge className="size-5 text-accent" aria-hidden="true" />
<h3 className="mt-3 text-md font-semibold text-ink-strong">Density as an axis</h3>
<p className="mt-1.5 text-sm text-ink-muted">
Compact admin screens and relaxed marketing pages, one component set.
</p>
</div>
<div className="rounded-xl border border-line bg-surface p-6">
<KeyRound className="size-5 text-accent" aria-hidden="true" />
<h3 className="mt-3 text-md font-semibold text-ink-strong">Keyboard contracts</h3>
<p className="mt-1.5 text-sm text-ink-muted">
Menus, tabs, dialogs and the palette follow published ARIA patterns.
</p>
</div>
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div className="rounded-xl border border-line bg-surface-sunken p-6">
<Boxes className="size-5 text-ink-muted" aria-hidden="true" />
<h3 className="mt-3 text-md font-semibold text-ink-strong">Blocks are standalone</h3>
<p className="mt-1.5 text-sm text-ink-muted">
Copying one section brings the primitives it names and nothing else.
</p>
</div>
<div className="rounded-xl border border-line bg-surface-sunken p-6">
<Rows3 className="size-5 text-ink-muted" aria-hidden="true" />
<h3 className="mt-3 text-md font-semibold text-ink-strong">Docs from source</h3>
<p className="mt-1.5 text-sm text-ink-muted">
A build step reads the real file, and a test fails on drift.
</p>
</div>
</div>
</Container>
</section>
)
}
components/blocks/sections/features/bento.tsx
import { Boxes, Gauge, KeyRound, Rows3 } from 'lucide-react'
import { Container } from '@/components/ui/layout'
import { Badge } from '@/components/ui/badge'
import { catalogueCounts } from '@/lib/catalogue'
/**
* Bento feature grid
*
* Cells of unequal size, where the size encodes importance. The grid is
* explicit at `lg` and collapses to a single column below it — a bento layout
* that keeps its asymmetry on a phone is unreadable.
*/
export default function BentoFeatures() {
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">
The parts that make it a system rather than a folder.
</h2>
</div>
<div className="mt-12 grid gap-4 lg:grid-cols-3 lg:grid-rows-2">
<div className="rounded-xl border border-line bg-surface p-6 lg:col-span-2 lg:row-span-2 lg:p-8">
<Badge tone="accent">Catalogue</Badge>
<h3 className="display-type mt-4 text-xl font-semibold text-ink-strong sm:text-2xl">
One typed record shape across six families
</h3>
<p className="mt-3 max-w-lg text-sm leading-relaxed text-ink-muted">
Components, navigation, forms, sections, patterns and starters all share the same
record. That is what lets one detail renderer, one search index and one
related-content engine serve everything.
</p>
<dl className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-3">
{[
{ label: 'Components', value: catalogueCounts.component },
{ label: 'Navigation', value: catalogueCounts.navigation },
{ label: 'Forms', value: catalogueCounts.form },
{ label: 'Sections', value: catalogueCounts.section },
{ label: 'Patterns', value: catalogueCounts.pattern },
{ label: 'Starters', value: catalogueCounts.starter },
].map((entry) => (
<div key={entry.label}>
<dd className="display-type text-xl font-semibold text-ink-strong tabular-nums">
{entry.value}
</dd>
<dt className="label-caps mt-0.5 text-ink-subtle">{entry.label}</dt>
</div>
))}
</dl>
</div>
<div className="rounded-xl border border-line bg-surface p-6">
<Gauge className="size-5 text-accent" aria-hidden="true" />
<h3 className="mt-3 text-md font-semibold text-ink-strong">Density as an axis</h3>
<p className="mt-1.5 text-sm text-ink-muted">
Compact admin screens and relaxed marketing pages, one component set.
</p>
</div>
<div className="rounded-xl border border-line bg-surface p-6">
<KeyRound className="size-5 text-accent" aria-hidden="true" />
<h3 className="mt-3 text-md font-semibold text-ink-strong">Keyboard contracts</h3>
<p className="mt-1.5 text-sm text-ink-muted">
Menus, tabs, dialogs and the palette follow published ARIA patterns.
</p>
</div>
</div>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div className="rounded-xl border border-line bg-surface-sunken p-6">
<Boxes className="size-5 text-ink-muted" aria-hidden="true" />
<h3 className="mt-3 text-md font-semibold text-ink-strong">Blocks are standalone</h3>
<p className="mt-1.5 text-sm text-ink-muted">
Copying one section brings the primitives it names and nothing else.
</p>
</div>
<div className="rounded-xl border border-line bg-surface-sunken p-6">
<Rows3 className="size-5 text-ink-muted" aria-hidden="true" />
<h3 className="mt-3 text-md font-semibold text-ink-strong">Docs from source</h3>
<p className="mt-1.5 text-sm text-ink-muted">
A build step reads the real file, and a test fails on drift.
</p>
</div>
</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 grid is explicit at lg and collapses to a single column below it — a bento layout that keeps its asymmetry on a phone is unreadable.
- The largest cell must carry the most important claim, or the hierarchy lies.
- Keep to five or six cells; a bento with ten is a mosaic.'
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.
- Two-by-two lead cell
- Supporting cells
- Single column below lg
Accessibility
- Heading levels
- Cell titles are h3 under the section h2, so the outline stays flat.
- Derived counts
- The figures in the lead cell come from the live catalogue.
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 sectionsFeature icon grid
Three columns of icon, title and paragraph — the workhorse feature section.
starterFeatured3 variantsIncluded and excluded checklist
What the product does and does not do, side by side.
starter3 variantsProduct mosaic hero
Three offset panels built from live components rather than a screenshot.
intermediateFeatured3 variants