Display

Bento Grid

A responsive tiling layout of mixed-size feature cards, with a pointer-tracked glow and honest click affordances.

Preview in your theme

Loading preview…

Dependencies
lucide-react
Registry dependencies
utils, use-prefers-reduced-motion
"use client"

import * as React from "react"
import { ArrowUpRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion"

type Columns = 2 | 3 | 4
type Span = 1 | 2 | 3

// Tailwind only sees class names that exist as literal strings in the source,
// so every column count and span lives in a lookup table — `col-span-${n}`
// would compile away to nothing.
const COLUMNS_CLASS: Record<Columns, string> = {

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/bento-grid.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "BentoGrid" pair (BentoGrid +
BentoCard) using lucide-react for the affordance arrow and the shared cn()
utility.

Contract
- Shared primitive: use-prefers-reduced-motion (installed automatically via
  registryDependencies) — gates the pointer-tracked glow: under reduced motion
  the coordinates are never written. If rebuilding without it: a
  useSyncExternalStore over matchMedia("(prefers-reduced-motion: reduce)") with
  a "change" listener and a server snapshot of false (for one-shot reads: read
  .matches at the moment of the event).
- BentoGrid: forwardRef<HTMLDivElement> extending
  React.HTMLAttributes<HTMLDivElement>. Props: columns = 2 | 3 | 4
  (default 3), gap = 16 (px, both axes), className, children.
- BentoCard: forwardRef extending React.HTMLAttributes<HTMLElement>.
  Props: title (ReactNode, required), description?, icon?,
  colSpan = 1 | 2 | 3, rowSpan = 1 | 2 | 3 (both default 1), href?,
  onClick? (inherited), children (free content / background slot).
- Both merge className through cn() and spread the rest onto their root.

Behavior
- Responsive by construction: 1 column on phones, 2 from sm, and `columns`
  from lg. Rows are auto-rows-[minmax(8rem,auto)] so a mostly-empty tile
  still has presence and a full one grows.
- Spans come from static lookup tables — { 2: "sm:col-span-2", 3:
  "sm:col-span-2 lg:col-span-3" } and the row equivalent — never from
  template strings: Tailwind extracts classes by scanning literal source
  text, so `col-span-${n}` compiles to nothing. Spans also start at sm,
  because every card is full width on phones anyway.
- Affordance follows behaviour: a card renders as a real <a> when it has
  href, as a card whose title is a real <button type="button"> inside the
  <h3> when it only has onClick, and as a plain <div> otherwise. The corner
  arrow and the pointer cursor appear only in the first two cases — a static
  tile must never look pressable.
- Never put role="button" on the onClick card root: that role makes every
  descendant presentational, so the <h3> stops being a heading, and any
  button or link in children becomes a control nested inside a control. The
  root stays a plain <div> with onClick, so a pointer click anywhere on the
  tile still fires it; the title button is the keyboard and screen-reader
  path (Tab stop, native Enter/Space, accessible name = the title), and its
  click bubbles to the same onClick. Clicks bubble by design, so an
  interactive child that must not also trigger the card calls
  stopPropagation() in its own handler.
- Focus ring: the <a> card rings itself with focus-visible:ring-2 ring-ring
  ring-offset-2 ring-offset-background; the onClick card draws the same ring
  on its root while the title button has focus, through
  has-[[data-bento-action]:focus-visible]:*. ring-offset-background matters:
  Tailwind's ring offset defaults to white, a hardcoded band in dark mode.
- Each card tracks the pointer by writing --bento-x / --bento-y straight
  into the glow layer's style through a ref, so a fast move stream repaints
  the light without re-rendering the card or its children. Under
  prefers-reduced-motion the coordinates are never written and the glow
  falls back to its centred default; on hover-less devices the layer is
  hidden outright.
- Because the root tag varies, forward the ref through a callback typed on
  HTMLElement instead of casting anchor refs to div refs.

Rendering & styling
- Card: group relative isolate flex flex-col overflow-hidden rounded-xl
  border bg-card p-5, transition-colors with hover:border-primary/40.
  flex-col is what keeps the free-content block aligned to the bottom of
  every tile in a row.
- Glow: an aria-hidden, pointer-events-none span, absolute inset-0 -z-10,
  radial-gradient at var(--bento-x, 50%) / var(--bento-y, 50%) using
  color-mix(in oklab, var(--primary) 14%, transparent), fading in on
  group-hover. `isolate` on the card plus -z-10 puts it above the card
  background but below the copy.
- Icon: decorative, aria-hidden, inside a size-9 bg-muted chip. Title is a
  real h3 (text-card-foreground), description is text-muted-foreground.
- children render below the copy in a flex-1 wrapper; for a decorative
  background pass an element with `absolute inset-0 -z-10` instead — the
  card is already a positioned, isolated stacking context.
- Semantic tokens only, so the whole mosaic re-themes with the host palette
  and dark mode.

Customization levers
- Density: columns 2 for a chunky 4-tile row, 4 for a dense feature wall;
  gap 8–12 for a tight mosaic, 20–24 for an airy one.
- Emphasis: give the hero tile colSpan 2 + rowSpan 2 and let the rest stay
  1x1; keep the total spans a multiple of `columns` if you want the grid to
  end flush.
- Row rhythm: auto-rows-[minmax(8rem,auto)] is the one knob for how tall an
  empty tile is; raise it for image-heavy tiles.
- Glow: retint by swapping --primary for --accent or a --chart-* token,
  widen the 220px radius for larger tiles, or drop the layer for a flat,
  enterprise look.
- Content slot: charts, images, avatar rows, code snippets — anything can go
  in children; wire href/onClick only on the tiles that really lead
  somewhere, and leave the rest static.

Concepts

  • Bento tiling — one grid plus one card type, where visual weight comes from colSpan / rowSpan rather than from bespoke markup per tile.
  • Span lookup tables — Tailwind extracts classes by scanning literal source text, so spans and column counts live in Record maps; a class name assembled at runtime from a template string would silently produce no CSS.
  • Responsive span safety — spans only engage from sm up, because every tile is full width on phones and a span wider than the grid just gets clamped.
  • Affordance follows behaviour — the corner arrow, the pointer cursor and the focus ring appear only when the card actually has a destination; a purely informational tile stays a div and never fakes being pressable.
  • Pointer-tracked glow — cursor coordinates are written into CSS custom properties through a ref, so the light layer repaints without re-rendering the card; it is aria-hidden, pointer-events-none, hidden on touch, and stays centred under reduced motion.
  • Isolated stacking context — isolate on the card lets both the glow and any caller-supplied background sit at -z-10: above the card's own background, below its copy, with no z-index arms race against the page.

On This Page