BentoGrid
Responsive grid system for modern layouts.
Live demo
01Feature showcase β three columns
Six tiles, mixed spans, the canonical pitch-page layout.
Core Engine
Powered by Svelte 5 runes for maximum performance and reactive clarity.
Zero Deps
Pure Svelte and CSS. No bloated libraries.
Responsive by Design
Seamlessly reflows from mobile to desktop using CSS Grid.
Gold Standard
Following strict quality guidelines for production readiness.
Dark Mode Support
Elegant aesthetics for any environment.
Accessible
Semantic HTML and keyboard support out of the box.
Visual gallery β image-only tiles
Same grid mechanics, but each tile is just an image. Centre-aligned captions.
Mountain Retreat
Ocean Breeze
Forest Path
Dense dashboard β four columns
A wide banner, a 2x2 hero, and a cluster of 1x1 KPI tiles. Demonstrates how the grid handles a higher item count and a denser layout.
Q3 release highlights
Spans the full width β shipping themes, motion preset library, and 12 new components.
Active users
24,318 β up 12% week on week.
Revenue
Β£42,108
Sessions
187k
Bounce rate
32.4%
Avg. session
4m 12s
New signups
892
Churn
1.8%
NPS
+47
Tickets open
14
Single hero, four supporting tiles
Guardian-style editorial layout β one big lead anchored by smaller supporting items.
The lead story
A flagship feature card that anchors the layout and dominates the optical weight.
Supporting one
Context, depth, related angle.
Supporting two
Counterpoint or commentary.
Supporting three
Data point or quick read.
Supporting four
Editorial pick or sidebar item.
Mosaic β irregular spans
A 3x1 banner, a 1x3 tower, a 2x2 centre square and two 1x1 fillers β proves the auto-flow algorithm interlocks irregular shapes cleanly.
Wide banner
Spans 3 columns β sets the editorial tone.
Tall column
Spans 3 rows β perfect for a feature, table of contents, or vertical advert.
Centre square
A 2x2 anchor β the optical centre of gravity.
Filler one
Filler two
Implementation
02<script>
import BentoGrid from '$lib/components/BentoGrid.svelte';
import type { BentoItem } from '$lib/types';
β
const items: BentoItem[] = [
{ id: 1, title: 'Core Engine', icon: 'π', colSpan: 2, rowSpan: 2 },
{ id: 2, title: 'Zero Deps', icon: 'π¦' },
// ...
];
</script>
β
<BentoGrid {items} cols={3} gap={24} />BentoGrid is a CSS-grid layout primitive: each item declares its colSpan and rowSpan, the grid auto-flows the rest. Pair it with image-only items for a gallery, or icon + description items for a feature wall.
Logic explainer
03What Does It Do? (Plain English)
BentoGrid arranges a set of tiles into the kind of asymmetric, mixed-size grid you see on landing pages and dashboards β one big tile next to a column of small ones, a wide banner under three squares, and so on. Each tile declares how many columns and rows it wants to span; CSS Grid does the rest. On a phone the whole thing collapses to a single column so nothing gets squashed.
Think of a Japanese bento box: a few large compartments for the main features, smaller compartments for the sides, all packed neatly into one tray.
How It Works (Pseudo-Code)
state:
items = list of tiles, each with optional colSpan / rowSpan / href / image
cols = max columns at desktop width
gap = spacing between tiles
derive container style:
--bento-cols = cols
--bento-gap = gap (px if number, raw string otherwise)
derive tile style for each item:
--col-span = item.colSpan ?? 1
--row-span = item.rowSpan ?? 1
events:
on keydown(item) Enter or Space (only if item.href is set):
preventDefault
window.location.href = item.href
render:
<div class="bento-grid" style={containerStyle}>
for each item:
tag = item.href ? 'a' : 'div'
<tag href={item.href} style={tileStyle}> ... </tag>
CSS:
default 1 column on mobile (< 768 px)
at >= 768 px: grid-template-columns = repeat(cols, minmax(0, 1fr))
grid-auto-rows = minmax(150 px, auto)
each tile: grid-column span var(--col-span)
grid-row span var(--row-span)There's no JS-driven layout β every dimension comes out of CSS Grid. The component is essentially a typed, themed wrapper around display: grid.
The Core Concept: Native CSS Grid Spans
The whole layout reduces to two CSS Grid facts:
.bento-grid {
display: grid;
grid-template-columns: repeat(var(--bento-cols), minmax(0, 1fr));
grid-auto-rows: minmax(150px, auto);
}
.bento-item {
grid-column: span var(--col-span, 1);
grid-row: span var(--row-span, 1);
}minmax(0, 1fr) is the load-bearing piece. 1fr alone has a quirk β it won't shrink below the intrinsic content size β so a tile with a long title would force the column wider than its share, breaking the grid. minmax(0, 1fr) says "take an equal slice but allow shrinking to zero", which is what people actually mean when they say "a 1fr column".
grid-auto-rows: minmax(150px, auto) does the same job vertically: every implicit row is at least 150 px tall, but expands to fit content. So a rowSpan: 2 tile is at least 300 px + the gap, but can grow if its content needs more room.
Visual example
cols = 3, gap = 16 px
tiles: [
{ id: 1, colSpan: 2, rowSpan: 2 },
{ id: 2 }, { id: 3 },
{ id: 4 }, { id: 5, colSpan: 2 }
]
βββββββββββββββββββββββββββββ¬ββββββββββββββ
β β 2 β
β βββββββββββββββ€
β 1 β 3 β
β (2x2 hero) β β
β β β
βββββββββββββββ¬ββββββββββββββ΄ββββββββββββββ€
β 4 β 5 β
β β (2x1 banner) β
βββββββββββββββ΄ββββββββββββββββββββββββββββ
On mobile (< 768 px) the same tiles stack as a single column,
in source order, ignoring colSpan/rowSpan entirely.The mobile collapse is not a media query that overrides each tile's span β it's that the mobile rule never applies the spans in the first place. The grid-column: span var(--col-span) rule is wrapped in @media (min-width: 768px), so on phones every tile naturally occupies one row of one column.
Image Layering & Hover Transforms
Tiles can carry a background image without a separate slot. The image lives in an absolutely-positioned container behind the content, with opacity: 0.3 so text stays readable, and a hover scale that triggers from the tile (not the image itself):
.bento-item {
position: relative;
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.bento-image-container { position: absolute; inset: 0; z-index: 0; }
.bento-content { position: relative; z-index: 10; }
.bento-item:hover {
transform: translateY(-4px);
box-shadow: 0 10px 25px -5px rgba(0,0,0,0.1);
}
.bento-item:hover .bento-image {
transform: scale(1.05);
}The lift-and-zoom is two transforms on two elements driven by a single :hover. Because both transform and opacity stay on the GPU, even a grid of fifty tiles never repaints anything when the cursor moves around.
prefers-reduced-motion: reduce zeroes out both transforms so the grid is fully static for users who need it.
State Flow Diagram
βββββββββββββββββββββββ
β items prop β
β cols, gap β
ββββββββββββ¬βββββββββββ
β
βΌ
βββββββββββββββββββββββ ββββββββββββββββββββββββ
β CSS variables on β βββββββΆ β Browser layout pass β
β container & tiles β β (no JS recompute) β
βββββββββββββββββββββββ ββββββββββββ¬ββββββββββββ
β
βΌ
ββββββββββββββββββββββββ
β Rendered grid β
β ββ responsive via β
β media query only β
ββββββββββββ¬ββββββββββββ
β
βΌ
hover βββΆ transform: translateY + scale
focus βββΆ outline: 2px solid #3b82f6
click on item.href βββΆ <a> navigates
Enter/Space on item.href βββΆ window.location
prefers-reduced-motion: reduce βββΆ all transforms disabledProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
items |
BentoItem[] |
FALLBACK_BENTO_ITEMS |
Tiles to render. Each item carries id, title, optional description, icon, image, href, colSpan, rowSpan, and per-item class. |
cols |
number |
3 |
Maximum columns at desktop width (>= 768 px). Phones always use one column. |
gap |
number | string |
16 |
Space between tiles. Numbers become pixels; strings (e.g. '1rem') pass through verbatim. |
class |
string |
'' |
Extra classes appended to the grid container. |
itemClass |
string |
'' |
Extra classes applied to every tile (compose with per-item item.class). |
The BentoItem type:
interface BentoItem {
id: string | number;
title: string;
description?: string;
icon?: string; // emoji or character
image?: string; // URL for background image
colSpan?: number;
rowSpan?: number;
class?: string;
href?: string; // when set, tile renders as <a> and is keyboard-activatable
}Edge Cases
| Situation | Behaviour |
|---|---|
Tile has colSpan larger than cols |
CSS Grid clamps the span to the available track count for that row. The tile fills the row instead of overflowing. |
Tile has both image and icon |
Both render. The image sits behind at 30% opacity; the icon sits in the foreground content stack. |
gap passed as a string like '1.5rem' |
Forwarded verbatim into --bento-gap. Any valid CSS length works. |
| Viewport narrower than 768 px | All tiles collapse to a single column in source order. colSpan and rowSpan are ignored entirely on mobile. |
Tile has href and the user presses Enter |
keydown handler intercepts Enter and Space, calls preventDefault(), and navigates via window.location.href to keep behaviour identical to clicking. |
Tile has no href |
Renders as a plain <div> with tabindex="-1". It's no longer keyboard-focusable, which is correct β there's nothing to activate. |
User has prefers-reduced-motion: reduce |
All transforms (lift on hover, zoom on image) are disabled; the grid is fully static. |
Dependencies
- Svelte 5.x β
$props,svelte:elementfor the dynamic<a>/<div>switch, and CSS custom properties for the spans. - Zero external dependencies β pure CSS Grid for layout, pure CSS for hover/focus, no motion library.
File Structure
src/lib/components/BentoGrid.svelte # implementation
src/lib/components/BentoGrid.md # this file
src/lib/components/BentoGrid.test.ts # vitest unit tests
src/routes/bentogrid/+page.svelte # demo page
src/lib/types.ts # BentoGridProps + BentoItem
src/lib/constants.ts # FALLBACK_BENTO_ITEMS sample dataAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
items | BentoItem[] | [] | Items to render. Each can declare title, description, icon, image, colSpan and rowSpan. |
cols | number | 3 | Number of columns in the grid. |
gap | number | 16 | Gap between cells in pixels. |
itemClass | string | '' | Additional class applied to every item. |