BentoGrid

Responsive grid system for modern layouts.

Live demo

01

Feature 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

Mountain Retreat

Ocean Breeze

Ocean Breeze

Forest Path

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
BentoGrid.svelte
<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

03

What 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 disabled

Props 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:element for 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 data

API

04
PropTypeDefaultDescription
itemsBentoItem[][]Items to render. Each can declare title, description, icon, image, colSpan and rowSpan.
colsnumber3Number of columns in the grid.
gapnumber16Gap between cells in pixels.
itemClassstring''Additional class applied to every item.