A responsive bento layout of feature cards that span columns and rows. Each BentoCard has a background visual slot, icon, title and description, and a link that slides up on hover or focus.
import {
Bell,
Calendar,
Cloud,
CreditCard,
Database,
GitBranch,
Mail,
MessageSquare,
Search,
Webhook,
Zap,
} from "lucide-react"
import { AnimatedGrid } from "@/components/ballmac/animated-grid"
import { BentoCard, BentoGrid } from "@/components/ballmac/bento-grid"
import { BorderBeam } from "@/components/ballmac/border-beam"
import { Marquee } from "@/components/ballmac/marquee"
import { NumberTicker } from "@/components/ballmac/number-ticker"
const regions = [
{ code: "iad1", x: "18%", y: "22%" },
{ code: "fra1", x: "50%", y: "13%" },
{ code: "sin1", x: "80%", y: "30%" },
{ code: "gru1", x: "36%", y: "42%" },
{ code: "syd1", x: "66%", y: "48%" },
]
function Regions() {
return (
<div className="absolute inset-0">
<AnimatedGrid cellSize={32} count={10} interval={500} />
{regions.map((r) => (
<span
key={r.code}
className="absolute flex -translate-x-1/2 -translate-y-1/2 items-center gap-1.5 rounded-full border bg-background/90 px-2 py-0.5 font-mono text-[11px] shadow-xs"
style={{ left: r.x, top: r.y }}
>
<span className="relative flex size-1.5">
<span className="absolute inset-0 animate-ping rounded-full bg-chart-2 opacity-60 motion-reduce:animate-none" />
<span className="relative size-1.5 rounded-full bg-chart-2" />
</span>
{r.code}
</span>
))}
</div>
)
}
function Uptime() {
return (
<div className="absolute inset-x-5 top-5 flex flex-col gap-3">
<p className="text-4xl font-semibold tracking-tight">
<NumberTicker value={99.99} from={97} format={{ minimumFractionDigits: 2, maximumFractionDigits: 2 }} />
<span className="text-muted-foreground">%</span>
</p>
<div className="flex h-10 items-end gap-[3px]">
{Array.from({ length: 30 }, (_, i) => (
<span
key={i}
className={i === 19 ? "h-1/2 flex-1 rounded-sm bg-chart-3" : "h-full flex-1 rounded-sm bg-chart-2/70"}
/>
))}
</div>
</div>
)
}
const apps = [Bell, CreditCard, Calendar, Database, Webhook, Mail, Cloud, MessageSquare, Zap]
function Integrations() {
const tile = (Icon: (typeof apps)[number], i: number) => (
<span
key={i}
className="flex size-12 items-center justify-center rounded-xl border bg-background shadow-xs [&_svg]:size-5"
>
<Icon aria-hidden="true" className={["text-[color-mix(in_oklch,var(--chart-1),black_42%)] dark:text-chart-1", "text-chart-4", "text-chart-2", "text-chart-5"][i % 4]} />
</span>
)
return (
<div className="absolute inset-x-0 top-5 flex flex-col gap-3">
<Marquee speed={24} gap={12}>{apps.map(tile)}</Marquee>
<Marquee speed={24} gap={12} reverse>{[...apps].reverse().map(tile)}</Marquee>
</div>
)
}
function CommandMenu() {
return (
<div className="absolute inset-x-5 top-5 overflow-hidden rounded-lg border bg-background text-xs shadow-sm">
<div className="flex items-center gap-2 border-b px-3 py-2 text-muted-foreground">
<Search className="size-3.5" aria-hidden="true" />
Deploy
<span className="ms-auto rounded border px-1 font-mono text-[10px]">⌘K</span>
</div>
{["Deploy main to production", "Roll back last deploy"].map((item, i) => (
<div key={item} className={`flex items-center gap-2 px-3 py-1.5 ${i === 0 ? "bg-accent text-accent-foreground" : ""}`}>
<Zap className="size-3.5 text-muted-foreground" aria-hidden="true" />
{item}
</div>
))}
</div>
)
}
function Previews() {
return (
<div className="absolute inset-x-5 top-5 flex flex-col gap-2 font-mono text-[11px]">
{[
{ branch: "feat/billing-v2", state: "Building", live: true },
{ branch: "fix/login-redirect", state: "Ready" },
].map((b) => (
<div key={b.branch} className="relative flex items-center gap-2 rounded-md border bg-background px-2.5 py-1.5">
<GitBranch className="size-3.5 text-muted-foreground" aria-hidden="true" />
{b.branch}
<span className={`ms-auto ${b.live ? "text-[color-mix(in_oklch,var(--chart-1),black_42%)] dark:text-chart-1" : "text-muted-foreground"}`}>{b.state}</span>
{b.live && <BorderBeam size={50} duration={3} />}
</div>
))}
</div>
)
}
export default function BentoGridDemo() {
return (
<BentoGrid className="max-w-3xl" rowHeight="15rem">
<BentoCard
colSpan={2}
title="Deploy to every region"
description="Push once; live in 35 regions in under a minute."
href="#"
cta="See regions"
background={<Regions />}
/>
<BentoCard
title="Uptime you can show"
description="Public status page included."
href="#"
background={<Uptime />}
/>
<BentoCard
title="Integrations"
description="Plug in the tools you already use."
href="#"
cta="Browse"
background={<Integrations />}
/>
<BentoCard
title="Keyboard first"
description="Every action is in the command menu."
href="#"
background={<CommandMenu />}
/>
<BentoCard
title="Branch previews"
description="A live URL for every pull request."
href="#"
background={<Previews />}
/>
</BentoGrid>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/bento-gridInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/mediaCopy the source into your project.
components/ballmac/bento-grid.tsx// Ballmac UI: Bento Grid. https://ui.ballmac.com/components/bento-grid import * as React from "react" import { ArrowRight } from "lucide-react" import { cn } from "@/lib/utils" import { Media, isMediaImage, type MediaSource } from "@/components/ballmac/media" type BentoGridProps = React.ComponentProps<"div"> & { /** Columns once the grid is wider than 36rem; narrower grids stack in one column. */ columns?: 2 | 3 | 4 /** Minimum height of one row, any CSS length. */ rowHeight?: string } const COLUMNS = { 2: "@xl/bento:grid-cols-2", 3: "@xl/bento:grid-cols-3", 4: "@xl/bento:grid-cols-2 @4xl/bento:grid-cols-4", } as const function BentoGrid({ columns = 3, rowHeight = "13rem", className, style, children, ...props }: BentoGridProps) { return ( <div data-slot="bento-grid" className={cn("@container/bento w-full", className)} style={style} {...props}> <div className={cn("grid grid-cols-1 gap-3 auto-rows-[minmax(var(--bento-row),auto)]", COLUMNS[columns])} style={{ "--bento-row": rowHeight } as React.CSSProperties} > {children} </div> </div> ) } type BentoCardProps = Omit<React.ComponentProps<"div">, "title"> & { /** Columns the card spans in the wide layout. */ colSpan?: 1 | 2 | 3 | 4 /** Rows the card spans in the wide layout. */ rowSpan?: 1 | 2 /** Decorative visual that fills the card behind the text; it fades out toward the text. */ background?: MediaSource /** Small icon shown above the title. */ icon?: React.ReactNode /** Card title. */ title: React.ReactNode /** One or two lines under the title. */ description?: React.ReactNode /** Makes the whole card a link to this URL. */ href?: string /** Label of the link revealed on hover and focus. */ cta?: string } const COL_SPAN = { 1: "", 2: "@xl/bento:col-span-2", 3: "@xl/bento:col-span-3", 4: "@xl/bento:col-span-2 @4xl/bento:col-span-4" } as const const ROW_SPAN = { 1: "", 2: "@xl/bento:row-span-2" } as const function BentoCard({ colSpan = 1, rowSpan = 1, background, icon, title, description, href, cta = "Learn more", className, children, ...props }: BentoCardProps) { return ( <div data-slot="bento-card" className={cn( "group/bento relative isolate flex min-h-(--bento-row) flex-col justify-end overflow-hidden rounded-xl border bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04)] transition-[border-color,box-shadow] duration-200 hover:border-foreground/15 has-[a:focus-visible]:ring-[3px] has-[a:focus-visible]:ring-ring/50 dark:shadow-none", COL_SPAN[colSpan], ROW_SPAN[rowSpan], className )} {...props} > {background && ( <div aria-hidden="true" inert data-slot="bento-card-background" className="pointer-events-none absolute inset-0 -z-10 [mask-image:linear-gradient(to_bottom,black_35%,transparent_72%)] transition-transform duration-500 ease-(--bm-ease-out) group-hover/bento:scale-[1.03] motion-reduce:transition-none motion-reduce:group-hover/bento:scale-100" > {isMediaImage(background) ? <Media media={background} alt="" fill className="absolute inset-0" /> : background} </div> )} {children} <div data-slot="bento-card-content" className={cn( "relative p-5 transition-transform duration-300 ease-(--bm-ease-out) motion-reduce:transition-none", // Slides up to make room for the link, only where hover exists; touch screens always show it. href && "[@media(hover:hover)]:translate-y-9 [@media(hover:hover)]:group-hover/bento:translate-y-0 [@media(hover:hover)]:group-has-[a:focus-visible]/bento:translate-y-0" )} > {icon && ( <div className="mb-3 flex size-9 items-center justify-center rounded-lg border bg-background/80 text-foreground shadow-xs backdrop-blur [&_svg]:size-4"> {icon} </div> )} <h3 className="text-base font-semibold tracking-tight">{title}</h3> {description && <p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>} {href && ( <a href={href} data-slot="bento-card-link" className="mt-3 inline-flex items-center gap-1 text-sm font-medium outline-none transition-opacity duration-300 after:absolute after:inset-0 after:content-[''] [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/bento:opacity-100 [@media(hover:hover)]:focus-visible:opacity-100" > {cta} <ArrowRight aria-hidden="true" className="size-3.5 transition-transform duration-200 group-hover/bento:translate-x-0.5 rtl:rotate-180 rtl:group-hover/bento:-translate-x-0.5" /> </a> )} </div> </div> ) } export { BentoGrid, BentoCard, type BentoGridProps, type BentoCardProps }Update the import paths to match your project setup.
Usage
import { BentoGrid, BentoCard } from "@/components/ballmac/bento-grid"The full example is in the Code tab above.
Examples
Two columns
Secure by default
Every project starts with SSO, audit logs and encrypted secrets turned on.
Passkeys
Sign in without passwords.
Audit log
Who changed what, kept for a year.
Bring your own keys
Store data encrypted with keys you control, rotated on your schedule.
Read the security docsimport { KeyRound, Lock, ScrollText, ShieldCheck } from "lucide-react"
import { BentoCard, BentoGrid } from "@/components/ballmac/bento-grid"
function Glow({ color, at }: { color: string; at: string }) {
return (
<div
className="absolute inset-0"
style={{ background: `radial-gradient(60% 70% at ${at}, color-mix(in oklch, ${color} 24%, transparent), transparent)` }}
/>
)
}
export default function BentoGridFeatures() {
return (
<BentoGrid columns={2} rowHeight="11rem" className="max-w-2xl">
<BentoCard
rowSpan={2}
icon={<ShieldCheck />}
title="Secure by default"
description="Every project starts with SSO, audit logs and encrypted secrets turned on."
background={<Glow color="var(--chart-2)" at="30% 20%" />}
/>
<BentoCard icon={<KeyRound />} title="Passkeys" description="Sign in without passwords." background={<Glow color="var(--chart-1)" at="80% 10%" />} />
<BentoCard icon={<ScrollText />} title="Audit log" description="Who changed what, kept for a year." background={<Glow color="var(--chart-4)" at="80% 10%" />} />
<BentoCard
colSpan={2}
icon={<Lock />}
title="Bring your own keys"
description="Store data encrypted with keys you control, rotated on your schedule."
href="#"
cta="Read the security docs"
background={<Glow color="var(--chart-5)" at="90% 0%" />}
/>
</BentoGrid>
)
}API reference
<BentoGrid>
| Prop | Type | Default |
|---|---|---|
columnsColumns once the grid is wider than 36rem; narrower grids stack in one column. | 2 | 3 | 4 | 3 |
rowHeightMinimum height of one row, any CSS length. | string | "13rem" |
<BentoCard>
| Prop | Type | Default |
|---|---|---|
colSpanColumns the card spans in the wide layout. | 1 | 2 | 3 | 4 | 1 |
rowSpanRows the card spans in the wide layout. | 1 | 2 | 1 |
backgroundDecorative visual that fills the card behind the text; it fades out toward the text. | MediaSource | — |
iconSmall icon shown above the title. | React.ReactNode | — |
title*Card title. | React.ReactNode | — |
descriptionOne or two lines under the title. | React.ReactNode | — |
hrefMakes the whole card a link to this URL. | string | — |
ctaLabel of the link revealed on hover and focus. | string | "Learn more" |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Focuses the card's link; the whole card is its click target and shows a focus ring |
Use with AI
<BentoGrid columns={3}> with <BentoCard colSpan rowSpan icon title description href background />. The grid responds to its own width (container queries): one column below 36rem. The background slot takes any decorative node, such as animated-grid, marquee or number-ticker visuals. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Bento Grid (@ballmac/bento-grid) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Landing-page feature sections with 4–7 features of different weight
- Product overviews where each tile shows a small live visual
- Dashboard home pages with shortcuts
Not for
- Uniform lists of equal items (use a plain grid of cards)
- Long text content; cards hold one or two lines
Registry JSON: https://ui.ballmac.com/r/bento-grid.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/mediashadcn/utils
Pairs well with
Animated Grid
A decorative hairline grid background where a few random cells softly light up and fade, like instrument lights. SVG, hydration-safe, static under reduced motion.
Marquee
An endless horizontal or vertical scroller for logo rows and testimonials. Speed is in pixels per second, it pauses on hover and focus, and it fills any width.
Number Ticker
Counts up to a number when it scrolls into view, with locale-aware formatting for currency, percentages and decimals.
Border Beam
A short beam of light that travels around the inside edge of its parent's border at a constant speed. Decorative, token-colored, hidden under reduced motion.