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…

"use client"

import * as React from "react"
import { ArrowUpRight } from "lucide-react"
import { cn } from "@/lib/utils"

const REDUCED_MOTION = "(prefers-reduced-motion: reduce)"

function subscribeReducedMotion(callback: () => void) {
  const mq = window.matchMedia(REDUCED_MOTION)
  mq.addEventListener("change", callback)
  return () => mq.removeEventListener("change", callback)
}

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