Skip to content
UI Kit
Accent color

Bento grid

A bento grid as a layout piece with slots. Tiles with media, figures, icons and links in four grids that fill without gaps.

4 variantsOne of 5 components in Layouts

Tiles that fill every cell

A bento grid sets tiles of different sizes into one calm block, the large ones for what matters most. This one is a layout piece with slots, you put in media, figures, icons and links, and the four grids fill every cell on every screen size.

Large tile left puts one large tile with a screen on the left and four small tiles beside it, one tinted and one dark. Mosaic on hairlines sets six tiles on hairlines, one wide, one tall and two half width, the cells keep their surface while the content rises. Tall center places a tall picture tile between two pairs, every tile a picture with its text on a soft shade. Figures and pictures mixes figures, a note and pictures in three rows, for an about page or a business in numbers.

Give the largest tile to the one message that sells. Mix two or three kinds of tiles, it keeps the block lively. Check the order on a phone, it should still tell the story.

Variants

Large tile leftNew in 1.2

One large tile with a screen on the left and four small tiles beside it, one tinted and one dark.

Mosaic on hairlinesNew in 1.2

Six tiles on hairlines, one wide, one tall and two half width, the cells keep their surface while content rises.

Tall centerNew in 1.2

A tall picture tile in the middle between two pairs, every tile a picture with its text on a soft shade.

Figures and picturesNew in 1.2

Figures, a note and pictures mixed in three rows, for an about page or a business in numbers.

Details

What Bento grid brings along, read from the registry and the source.

Category
Layouts
Area
Layout
Variants
4
Version
1.0.0 · since 1.2.0
File
components/ui/bento-grid.tsx
Brings along
utilskiticonsectionreveal
npm packages
None
Languages
Texts come through props
Studio
Adjust in code

Props

The BentoGridProps interface from components/ui/bento-grid.tsx, with the comments from the code.

  • variantoptional
    BentoGridVariant

    hero-leftmosaictall-centerstats-mix

  • eyebrowoptional
    React.ReactNode
  • titleoptional
    React.ReactNode
  • descriptionoptional
    React.ReactNode
  • tilesrequired
    BentoTile[]

Accessibility

Read from the source of the component.

  • Shows a visible focus ring when used with the keyboard.
  • Needs no client state. The server writes it in full, so it reads without JavaScript too.

Motion

  • Rises in briefly the first time it comes into view (kit-rise, useInView).
  • With reduced motion (prefers-reduced-motion) it stays still. 3 places in the code switch motion off then.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 165 lines
components/ui/bento-grid.tsx
'use client';

import { cn, useSpotlight } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Eyebrow, Section } from '@/components/ui/section';
import { Reveal } from '@/components/ui/reveal';

/*
 * A bento grid as a layout piece: tiles of different sizes that fill a
 * rectangle without gaps. Every tile is a slot with optional media, a
 * figure, an icon and a link, so the same grid holds features, work,
 * numbers or places. The variant picks the grid.
 *
 *   hero-left    one large tile on the left, four small ones beside it
 *   mosaic       six tiles on hairlines, one tall and two wide
 *   tall-center  a tall picture in the middle between two pairs
 *   stats-mix    figures, a note and pictures mixed in three rows
 *
 * Phones get one column in the same order, tablets two, with the large
 * tiles across both.
 */

export type BentoGridVariant = 'hero-left' | 'mosaic' | 'tall-center' | 'stats-mix';

export interface BentoTile {
  eyebrow?: string;
  title?: string;
  text?: string;
  /** A large figure, e.g. "98 %" */
  value?: string;
  icon?: IconName;
  href?: string;
  /** Picture or any node for the tile */
  media?: React.ReactNode;
  /** accent: tinted with the accent color, inverse: dark in both modes */
  tone?: 'default' | 'accent' | 'inverse';
}

export interface BentoGridProps {
  variant?: BentoGridVariant;
  eyebrow?: React.ReactNode;
  title?: React.ReactNode;
  description?: React.ReactNode;
  tiles: BentoTile[];
  className?: string;
}

/* Where each tile sits, by position. Phones: one column. */
const SPANS: Record<BentoGridVariant, { grid: string; tiles: string[]; cover: boolean[] }> = {
  'hero-left': {
    grid: 'sm:grid-cols-2 lg:grid-cols-4 lg:auto-rows-[minmax(15rem,auto)]',
    tiles: ['sm:col-span-2 lg:row-span-2', '', '', '', ''],
    cover: [false, false, false, false, false],
  },
  mosaic: {
    grid: 'sm:grid-cols-2 lg:grid-cols-6 lg:auto-rows-[minmax(14rem,auto)]',
    tiles: ['sm:col-span-2 lg:col-span-4', 'lg:col-span-2 lg:row-span-2', 'lg:col-span-2', 'lg:col-span-2', 'lg:col-span-3', 'sm:col-span-2 lg:col-span-3'],
    cover: [false, false, false, false, false, false],
  },
  'tall-center': {
    grid: 'sm:grid-cols-2 lg:grid-cols-3 lg:auto-rows-[minmax(16rem,auto)]',
    tiles: ['', 'sm:row-span-2 lg:col-start-2 lg:row-start-1', 'lg:col-start-1 lg:row-start-2', 'lg:col-start-3 lg:row-start-1', 'lg:col-start-3 lg:row-start-2'],
    cover: [true, true, true, true, true],
  },
  'stats-mix': {
    grid: 'sm:grid-cols-2 lg:grid-cols-4 lg:auto-rows-[minmax(12rem,auto)]',
    tiles: ['sm:col-span-2 lg:row-span-2', '', '', 'lg:col-span-2', '', 'sm:col-span-2 lg:col-span-3'],
    cover: [true, false, false, false, false, true],
  },
};

const focus = 'outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring';

function Tile({ tile, cover, big, wide = false, tall = false, className, index, hairline }: { tile: BentoTile; cover: boolean; big: boolean; wide?: boolean; tall?: boolean; className?: string; index: number; hairline: boolean }) {
  const spot = useSpotlight<HTMLElement>();
  const tone = tile.tone ?? 'default';
  const Tag = tile.href ? 'a' : 'div';
  const hasMedia = !!tile.media;
  const asCover = cover && hasMedia;

  const body = (
    <>
      {tile.icon && !asCover && (
        <span className={cn('mb-5 flex size-10 items-center justify-center rounded-xl border', tone === 'default' ? 'border-border bg-surface text-foreground' : 'border-white/15 bg-white/10 text-current')}>
          <Icon name={tile.icon} size={19} />
        </span>
      )}
      {tile.eyebrow && <span className={cn('block text-[12.5px] font-medium', asCover || tone === 'inverse' ? 'text-white/75' : tone === 'accent' ? 'text-[color-mix(in_oklab,var(--primary)_85%,var(--foreground))]' : 'text-[color-mix(in_oklab,var(--primary)_75%,var(--muted-foreground))]')}>{tile.eyebrow}</span>}
      {tile.value && <span className={cn('mt-1 block font-semibold leading-none tracking-[-0.04em] tabular-nums', big ? (wide ? 'text-[clamp(3rem,2.2rem+3vw,4.5rem)]' : 'text-[clamp(2.25rem,1.9rem+1.4vw,3rem)] lg:text-[clamp(3rem,2.2rem+3vw,4.5rem)]') : 'text-[clamp(2.25rem,1.9rem+1.4vw,3rem)]')}>{tile.value}</span>}
      {tile.title && <span className={cn('block text-balance font-semibold tracking-[-0.02em]', tile.value ? 'mt-3 text-[15px]' : big ? (wide ? 'mt-2 text-[clamp(1.375rem,1.15rem+0.9vw,1.875rem)] leading-tight' : 'mt-1.5 text-[17px] leading-snug lg:mt-2 lg:text-[clamp(1.375rem,1.15rem+0.9vw,1.875rem)] lg:leading-tight') : 'mt-1.5 text-[17px] leading-snug')}>{tile.title}</span>}
      {tile.text && <span className={cn('mt-2 block max-w-[34rem] text-pretty text-[14.5px] leading-relaxed', asCover || tone === 'inverse' ? 'text-white/80' : 'text-muted-foreground')}>{tile.text}</span>}
      {tile.href && (
        <span className={cn('mt-4 inline-flex size-9 items-center justify-center rounded-full border transition-transform duration-300 group-hover:translate-x-1', asCover || tone === 'inverse' ? 'border-white/30 text-white' : 'border-border text-foreground')}>
          <Icon name="arrow-right" size={15} />
        </span>
      )}
    </>
  );

  // On hairlines the cell keeps its surface and only its content rises, so no grey block shows through
  const outer = (children: React.ReactNode) =>
    hairline ? <div className={cn('flex', className)}>{children}</div> : <Reveal delay={index * 70} className={cn('flex', className)}>{children}</Reveal>;
  const inner = (children: React.ReactNode) => (hairline ? <Reveal delay={index * 70} className="relative block p-6">{children}</Reveal> : <span className="relative block p-6">{children}</span>);
  return outer(
      <Tag
        {...(tile.href ? { href: tile.href } : {})}
        onPointerMove={spot}
        className={cn(
          'kit-spotlight group relative flex w-full flex-col overflow-hidden',
          hairline ? '' : 'rounded-2xl border shadow-[var(--highlight),var(--shadow-sm)]',
          tone === 'accent' ? 'border-[color-mix(in_oklab,var(--primary)_30%,var(--border))] bg-[color-mix(in_oklab,var(--primary)_10%,var(--card))] text-foreground' : tone === 'inverse' ? 'dark border-border bg-background text-foreground' : 'border-border bg-card text-foreground',
          asCover ? 'min-h-[18rem] justify-end text-white' : 'justify-between',
          tile.href && 'transition-[transform,box-shadow] duration-300 ease-[var(--ease-kit)] hover:-translate-y-0.5 hover:shadow-[var(--highlight),var(--shadow-md)] motion-reduce:hover:translate-y-0',
          tile.href && focus,
        )}
      >
        {asCover ? (
          <>
            <span data-clip-ok className="absolute inset-0 -z-20 block transition-transform duration-700 ease-[var(--ease-kit)] group-hover:scale-[1.04] motion-reduce:transition-none [&>*]:size-full">{tile.media}</span>
            <span aria-hidden className="absolute inset-0 -z-10 bg-[linear-gradient(to_top,rgb(0_0_0/0.88),rgb(0_0_0/0.62)_50%,rgb(0_0_0/0.2)_80%,rgb(0_0_0/0.05))]" />
            {inner(body)}
          </>
        ) : (
          <>
            {inner(body)}
            {hasMedia && (
              // On phones a large screen keeps a tablet width and runs off the right edge of the tile, instead of squeezing its content
              <span data-clip-ok className={cn('relative mb-0 ml-6 mt-auto block overflow-hidden rounded-t-xl border border-b-0 border-border bg-muted sm:mr-6', big && !tall ? 'max-sm:aspect-[5/4] max-sm:rounded-tr-none max-sm:border-r-0' : 'mr-6', tall ? 'aspect-[16/10] lg:aspect-auto lg:min-h-[16rem] lg:flex-1' : big ? (wide ? 'sm:aspect-[2/1] lg:aspect-[16/10]' : 'sm:aspect-[16/10]') : 'aspect-[16/9]')}>
                <span className={cn('absolute inset-y-0 left-0 block transition-transform duration-700 ease-[var(--ease-kit)] group-hover:-translate-y-1 motion-reduce:transition-none [&>*]:size-full', big && !tall ? 'w-[34rem] sm:w-full' : 'w-full')}>{tile.media}</span>
              </span>
            )}
          </>
        )}
      </Tag>,
  );
}

/**
 * A bento grid with slots: large and small tiles that fill a rectangle,
 * each with optional media, figure, icon and link.
 *   <BentoGrid variant="mosaic" tiles={[{ title, text, media }, { value: '98 %', title }]} />
 */
export function BentoGrid({ variant = 'hero-left', eyebrow, title, description, tiles, className }: BentoGridProps) {
  const spec = SPANS[variant];
  const hairline = variant === 'mosaic';
  const big = (i: number) => /row-span-2|col-span-4|col-span-3/.test(spec.tiles[i] ?? '');
  return (
    <Section className={className}>
      {(title || eyebrow) && (
        <div className="mb-12 grid gap-6 sm:mb-14 lg:grid-cols-2 lg:items-end lg:gap-16">
          <div>
            {eyebrow && <Eyebrow>{eyebrow}</Eyebrow>}
            {title && <h2 className="text-balance text-h2 font-semibold text-foreground">{title}</h2>}
          </div>
          {description && <p className="max-w-[30rem] text-pretty text-lead text-muted-foreground lg:justify-self-end">{description}</p>}
        </div>
      )}
      <div className={cn('grid grid-cols-[minmax(0,1fr)]', spec.grid, hairline ? 'gap-px overflow-hidden rounded-2xl border border-border bg-border shadow-[var(--shadow-sm)]' : 'gap-4')}>
        {tiles.map((tile, i) => (
          <Tile key={i} tile={tile} index={i} cover={spec.cover[i % spec.cover.length] ?? false} big={big(i)} wide={/sm:col-span-2/.test(spec.tiles[i] ?? '')} tall={/^lg:col-span-2 lg:row-span-2$/.test(spec.tiles[i] ?? '')} hairline={hairline} className={spec.tiles[i] ?? ''} />
        ))}
      </div>
    </Section>
  );
}

Frequent questions

How do I install Bento grid?

With aiw add @ai-website/bento-grid. The command puts components/ui/bento-grid.tsx into your project, the code is yours from then on. It brings along icon, section and reveal. It needs no further npm packages.

Which variants does Bento grid have?

4 variants, Large tile left, Mosaic on hairlines, Tall center and Figures and pictures. You pick one with the variant prop, for example variant="mosaic".

Can an AI assistant build with Bento grid?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Bento grid with aiw_components and install it with aiw_add @ai-website/bento-grid. Name the variant in your prompt, for example variant="hero-left", and the assistant writes your content straight into the props.

Often searched

Jump to

Copied to the clipboard