Skip to content

Bento feature grid

Cells of unequal size where the size encodes importance.

Marketingintermediatefeaturesbentogridasymmetric

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 { 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.