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…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/bento-grid.jsonPrompt
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
- 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 an activatable card (role="button" + tabIndex + Enter/Space,
which forwards to a real click) 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. Interactive
cards get focus-visible:ring-2 focus-visible:ring-ring.
- A real <button> is intentionally avoided so the card can keep a heading
and a paragraph inside (a <button> may only contain phrasing content).
Because children may include their own buttons or links, the card's
Enter/Space handler only fires when event.target === event.currentTarget;
otherwise it would preventDefault a descendant's Enter and fire the card
instead. Clicks still bubble by design, so an interactive child that must
not also trigger the card calls stopPropagation() in its own handler.
- 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/rowSpanrather than from bespoke markup per tile. - Span lookup tables — Tailwind extracts classes by scanning literal source text, so spans and column counts live in
Recordmaps; a class name assembled at runtime from a template string would silently produce no CSS. - Responsive span safety — spans only engage from
smup, 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
divand 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 —
isolateon 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.