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