Skip to content
Facade UI
Colour mode
GitHub (opens in a new tab)
blocksection

Bento grid

A mosaic of feature tiles at mixed sizes, laid out in the order given so reading order matches what you see.

Also ships a motion variant: Bento grid (motion). The static one is the default.

Preview

Open full width (opens in a new tab)
Preview width

Installation

Package manager
pnpm dlx shadcn@latest add https://facadeui.dev/r/bento-grid.json

Pulls in container, feature-icon, heading, section, section-header, types, utils. The CLI installs them for you.

Usage

The exact source of the preview above.

demos/bento-grid.tsx
import { BentoGrid } from "@registry/sections/bento-grid"

import { BENTO } from "./content"

export function Demo() {
  return (
    <BentoGrid
      eyebrow="At a glance"
      title="A mosaic that still reads in order"
      description="Tiles are laid out in the order they are given — no dense packing, so reading order matches what you see."
      items={BENTO}
    />
  )
}

Source

What the CLI copies into your project, byte for byte.

components/sections/bento-grid.tsx
/**
 * BentoGrid — a mosaic of feature tiles at mixed sizes.
 *
 * Each item declares how much room it wants (`span`), and the grid resolves that
 * into column and row spans at `sm` and up. Below `sm` everything stacks: a
 * bento layout at phone width is just a list, and pretending otherwise produces
 * unreadable tiles.
 *
 * a11y: a `<ul>` whose visual order matches DOM order — no `grid-auto-flow:
 * dense`, and no manual `order`. Dense packing reorders tiles visually without
 * touching the DOM, which is exactly the mismatch between reading order and
 * visual order that WCAG 1.3.2 exists to prevent. Tiles are laid out in the
 * order they are given; to change the mosaic, change the array.
 *
 * Clickable tiles use the same stretched-link pattern as FeatureGrid, so the
 * accessible name stays the tile's title rather than its whole body.
 *
 * Dependencies: react, @/lib/types, @/lib/utils,
 * @/components/ui/container, @/components/ui/feature-icon, @/components/ui/heading,
 * @/components/ui/section, @/components/ui/section-header.
 */

import type { ElementType, ReactNode } from "react"

import type {
  HeadingLevel,
  IconComponent,
  LinkComponent,
  ListSlotProps,
  SectionBaseProps,
} from "@/lib/types"
import { cn, slugId } from "@/lib/utils"
import { Container } from "@/components/ui/container"
import { FeatureIcon } from "@/components/ui/feature-icon"
import { Heading } from "@/components/ui/heading"
import { Section } from "@/components/ui/section"
import { SectionHeader } from "@/components/ui/section-header"

/** How much of the mosaic a tile occupies at `sm` and above. */
export type BentoSpan = "sm" | "md" | "lg" | "tall" | "wide"

export interface BentoItem {
  title: string
  description?: string
  icon?: IconComponent
  span?: BentoSpan
  /** Rendered inside the tile, under the copy. A screenshot, a chart, a list. */
  media?: ReactNode
  href?: string
  external?: boolean
  id?: string
}

export interface BentoGridProps extends SectionBaseProps, ListSlotProps {
  items: BentoItem[]
  title?: string
  eyebrow?: string
  description?: string
  link?: LinkComponent
  itemHeadingLevel?: HeadingLevel
  align?: "start" | "center"
}

/** The grid is six columns at `sm`; spans are expressed against that. */
const spans: Record<BentoSpan, string> = {
  sm: "sm:col-span-2",
  md: "sm:col-span-3",
  lg: "sm:col-span-4",
  wide: "sm:col-span-6",
  tall: "sm:col-span-2 sm:row-span-2",
}

export function BentoGrid({
  items,
  title,
  eyebrow,
  description,
  link,
  itemHeadingLevel,
  align = "start",
  listAs,
  itemAs,
  headingLevel = 2,
  as,
  spacing = "md",
  className,
  id,
}: BentoGridProps) {
  const List = (listAs ?? "ul") as ElementType
  const Item = (itemAs ?? "li") as ElementType
  const Link = (link ?? "a") as ElementType
  const titleId = title ? (id ? `${id}-title` : slugId(title)) : undefined
  const itemLevel = itemHeadingLevel ?? (Math.min(headingLevel + 1, 6) as HeadingLevel)

  return (
    <Section
      as={as ?? (title ? "section" : "div")}
      spacing={spacing}
      labelledBy={titleId}
      id={id}
      className={className}
    >
      <Container className="flex flex-col gap-14">
        {title ? (
          <SectionHeader
            align={align}
            eyebrow={eyebrow}
            title={title}
            description={description}
            headingLevel={headingLevel}
            titleId={titleId}
          />
        ) : null}

        <List className="grid auto-rows-[minmax(11rem,auto)] grid-cols-1 gap-4 sm:grid-cols-6">
          {items.map((item) => (
            <Item
              key={item.id ?? item.title}
              className={cn(
                "bg-card text-card-foreground relative flex flex-col gap-3 overflow-hidden rounded-xl border p-6",
                spans[item.span ?? "md"],
                item.href &&
                  "duration-facade-fast ease-facade-out hover:border-ring has-[a:focus-visible]:ring-ring has-[a:focus-visible]:ring-offset-background transition-colors has-[a:focus-visible]:ring-2 has-[a:focus-visible]:ring-offset-2",
              )}
            >
              {item.icon ? <FeatureIcon icon={item.icon} size="sm" /> : null}

              <Heading
                level={itemLevel}
                className="text-foreground text-balance font-semibold"
              >
                {item.href ? (
                  <Link
                    href={item.href}
                    {...(item.external
                      ? { target: "_blank", rel: "noopener noreferrer" }
                      : {})}
                    className="outline-none after:absolute after:inset-0 after:content-['']"
                  >
                    {item.title}
                    {item.external ? (
                      <span className="sr-only"> (opens in a new tab)</span>
                    ) : null}
                  </Link>
                ) : (
                  item.title
                )}
              </Heading>

              {item.description ? (
                <p className="text-muted-foreground text-pretty text-sm">
                  {item.description}
                </p>
              ) : null}

              {item.media ? (
                <div className="mt-auto min-w-0 pt-2">{item.media}</div>
              ) : null}
            </Item>
          ))}
        </List>
      </Container>
    </Section>
  )
}

Props

BentoGridProps

Extends SectionBaseProps, ListSlotProps.

Props for BentoGridProps
PropTypeDefault
items*RequiredBentoItem[]
titlestring
eyebrowstring
descriptionstring
linkLinkComponent
itemHeadingLevelHeadingLevel
align"start" | "center""start"