Bento grid
An asymmetric grid of feature cards with authored spans (1x1, 2x1, 1x2, 2x2) in three or four columns, media at the foot of each card, optional whole-card links and a one-column phone layout with its own order.
cmp_bento_grid_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_bento_grid_01 version 1.0.0 with variant "violet", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Violet accent
- Version
- 1.0.0
- Digest
Full digest
sha256-7297ab27a85d8e341cfaef31159c1107ee2bf046b6fb88e185e707f24a91c9a9
<script lang="ts" module>
import type { Snippet } from 'svelte';
/** Columns × rows a card covers from 768 px. Below that every card is one column wide. */
export type BentoSpan = '1x1' | '2x1' | '1x2' | '2x2';
export interface BentoItem {
/** Stable key for the card, unique within `items`. */
id: string;
title: string;
description?: string;
/** Image, illustration or product surface, filling the foot of the card. */
media?: Snippet;
/** Defaults to `1x1`. */
span?: BentoSpan;
/** Turns the title into the card's one link; its hit area covers the card. */
href?: string;
/** Position in the one-column phone layout. Visual only: reading and tab order follow `items`. */
mobileOrder?: number;
}
</script>
<script lang="ts">
interface Props {
/** The cards, in reading order. */
items: BentoItem[];
/** Desktop columns: 3 from 768 px, or 4 from 1024 px (2 in between). */
columns?: 3 | 4;
/** Short label above the section title. */
eyebrow?: string;
/** Section heading. */
title?: string;
/** One or two sentences under the title. */
description?: string;
/** Replaces the eyebrow, title and description. Put `headingId` on your heading to name the section. */
intro?: Snippet<[headingId: string]>;
/** Replaces the inside of each card; the grid keeps its span and order. */
card?: Snippet<[item: BentoItem]>;
/** Heading level of each card title. */
headingLevel?: 3 | 4;
}
let {
items,
columns = 3,
eyebrow,
title,
description,
intro,
card,
headingLevel = 3
}: Props = $props();
const uid = $props.id();
const headingId = `${uid}-title`;
/* Every class is a complete static string, so Tailwind sees it in the source. */
const GRID = {
3: 'md:grid-cols-3',
4: 'md:grid-cols-2 lg:grid-cols-4'
} as const;
const SPAN: Record<BentoSpan, string> = {
'1x1': '',
'2x1': 'md:col-span-2',
'1x2': 'md:row-span-2',
'2x2': 'md:col-span-2 md:row-span-2'
};
/*
* Phone order: cards with a mobileOrder first, ascending, then the rest in the order given.
* Applied as CSS order below 768 px only, so the desktop grid and the DOM keep the given order.
*/
const phoneOrder = $derived.by(() => {
if (!items.some((item) => item.mobileOrder !== undefined)) return undefined;
const ranked = items
.map((item, index) => ({ id: item.id, key: item.mobileOrder ?? Infinity, index }))
.sort((a, b) => a.key - b.key || a.index - b.index);
return new Map(ranked.map((entry, rank) => [entry.id, rank + 1]));
});
/* One card fills the row rather than standing alone in the first column. */
const spanFor = (item: BentoItem) =>
items.length === 1 ? 'md:col-span-full' : SPAN[item.span ?? '1x1'];
/* The lead keeps its larger title and media at every width, so it still leads on phones. */
const isLead = (item: BentoItem) => item.span === '2x2' || items.length === 1;
/*
* Media fills what the text leaves. Single cells hold theirs at one height on the card's foot,
* so the media in a row starts on one line however long each description runs.
*/
const mediaFor = (item: BentoItem) =>
isLead(item)
? 'min-h-64 flex-1'
: (item.span ?? '1x1') === '1x1'
? 'mt-auto h-36 flex-none'
: 'min-h-36 flex-1';
</script>
<section
class="bento-grid px-4 py-16 sm:px-6 sm:py-24 lg:px-8 lg:py-32"
aria-labelledby={intro || title ? headingId : undefined}
>
<div class="mx-auto max-w-6xl">
{#if intro}
<div class="bento-grid__intro mb-12 max-w-2xl text-start sm:mb-16">
{@render intro(headingId)}
</div>
{:else if eyebrow || title || description}
<div class="mb-12 max-w-2xl text-start sm:mb-16">
{#if eyebrow}
<p
class="text-xs leading-none font-medium tracking-[0.06em] text-balance text-(--_muted) uppercase rtl:tracking-normal rtl:normal-case"
>
{eyebrow}
</p>
{/if}
{#if title}
<h2
id={headingId}
class={[
'text-3xl leading-[1.15] font-semibold tracking-tight text-balance break-words text-(--_ink) sm:text-4xl rtl:tracking-normal',
eyebrow && 'mt-2'
]}
>
{title}
</h2>
{/if}
{#if description}
<p
class={[
'max-w-xl text-base leading-6 text-pretty text-(--_muted) sm:text-lg sm:leading-7',
(eyebrow || title) && 'mt-4'
]}
>
{description}
</p>
{/if}
</div>
{/if}
{#if items.length}
<!--
No dense packing: cards fill the grid in the order given, so what is seen, what is read
and what is tabbed through stay the same. Rows are auto-sized, so a card grows with its
text instead of clipping it.
-->
<ul role="list" class={['grid grid-cols-1 gap-4', GRID[columns]]}>
{#each items as item (item.id)}
<li
class={[
'bento-grid__cell min-w-0 max-md:order-(--_order)',
spanFor(item),
card
? 'grid'
: 'bento-grid__card relative isolate flex flex-col overflow-hidden rounded-(--_radius) bg-(--_surface)',
!card && item.href && 'bento-grid__card--linked'
]}
style:--_order={phoneOrder?.get(item.id)}
>
{#if card}
{@render card(item)}
{:else}
<div class="p-6">
<svelte:element
this={`h${headingLevel}`}
class={[
'leading-[1.3] font-semibold tracking-[-0.015em] text-balance break-words text-(--_ink) rtl:tracking-normal',
isLead(item) ? 'text-xl' : 'text-lg'
]}
>
{#if item.href}
<a href={item.href} class="bento-grid__link"
>{item.title}⁠<svg
class="bento-grid__arrow ms-1 inline-block size-4 align-[-0.125em] text-(--_muted) rtl:-scale-x-100"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"><path d="M3 8h10M9 4l4 4-4 4" /></svg
></a
>
{:else}
{item.title}
{/if}
</svelte:element>
{#if item.description}
<p
class="mt-2 max-w-[30em] text-base leading-6 text-pretty break-words text-(--_muted)"
>
{item.description}
</p>
{/if}
</div>
{#if item.media}
<div class={['bento-grid__media relative bg-(--_raised)', mediaFor(item)]}>
{@render item.media()}
</div>
{/if}
{/if}
</li>
{/each}
</ul>
{/if}
</div>
</section>
<style>
/* Public tokens: set --bento-grid-* on this section or any ancestor to retone it. */
.bento-grid {
--_accent: var(--bento-grid-accent, #6d28d9);
--_ink: var(--bento-grid-ink, #18181b);
--_muted: var(--bento-grid-muted, #52525b);
--_hairline: var(--bento-grid-hairline, rgb(0 0 0 / 0.08));
--_surface: var(--bento-grid-surface, #ffffff);
--_raised: var(--bento-grid-raised, #fafafa);
--_radius: var(--bento-grid-radius, 16px);
/* Formulas, read by the card rules below. */
--_hairline-hover: color-mix(in oklab, var(--_ink) 22%, transparent);
--_ease: cubic-bezier(0.2, 0, 0, 1);
}
/*
* The card surface and its hover, press and focus live together here, because the card reacts
* to its title link (a relation between elements), and one state keeps one home.
*/
.bento-grid__card {
box-shadow: 0 0 0 1px var(--_hairline);
transition-property: box-shadow, transform;
transition-duration: 150ms;
transition-timing-function: var(--_ease);
}
/* Whatever the media snippet renders fills the area: content the grid does not render, so CSS. */
.bento-grid__media > :global(:where(img, video, picture, svg)) {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.bento-grid__media > :global(:where(picture) > img) {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Linked cards: the title link's ::after covers the card, media included. One link, one tab stop. */
.bento-grid__link::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
border-radius: var(--_radius);
}
.bento-grid__arrow {
transition-property: color, transform;
transition-duration: 150ms;
transition-timing-function: var(--_ease);
}
/* Hover exists only where the click lands: the link's area, which is the whole linked card. */
@media (hover: hover) {
.bento-grid__card--linked:has(:global(.bento-grid__link:hover)) {
box-shadow: 0 0 0 1px var(--_hairline-hover);
}
.bento-grid__link:hover .bento-grid__arrow {
color: var(--_ink);
}
}
/* The arrow's nudge is movement, so it exists only when motion is allowed. */
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
/* In right-to-left text the arrow's rtl:-scale-x-100 flips this nudge with it. */
.bento-grid__link:hover .bento-grid__arrow {
transform: translateX(2px);
}
}
/* A press settles the card by 1%: on a 760 px lead card a full 2% would move its edge 8 px. */
.bento-grid__card--linked:has(:global(.bento-grid__link:active)) {
transform: scale(0.99);
transition-duration: 80ms;
}
/* Fallback ring on the link itself; replaced by a ring round the whole card where :has() works. */
.bento-grid__link:focus-visible {
outline: 2px solid var(--_accent);
outline-offset: 2px;
border-radius: 2px;
}
@supports selector(:has(*)) {
.bento-grid__link:focus-visible {
outline: none;
}
.bento-grid__card--linked:has(:global(.bento-grid__link:focus-visible)) {
outline: 2px solid var(--_accent);
outline-offset: 2px;
}
}
@media (prefers-reduced-motion: reduce) {
.bento-grid__card,
.bento-grid__arrow {
transition-property: box-shadow, color;
}
.bento-grid__card--linked:has(:global(.bento-grid__link:active)) {
transform: none;
}
}
</style>
Usage#
On this pagePresentational only: you author each card's span and the grid places cards in the order given. There is no packing algorithm, so a span set that does not add up to whole rows leaves a gap. Linked cards have short hover and press transitions; there are no entrance animations, tilt or spotlight effects, and nothing is fetched.
- Suggested location
src/lib/components/bento-grid-01- Required props
items
Limitations
- Spans are authored, not computed. In three columns, make each row's spans add up to three (a 2x2 beside two stacked 1x1 cards, then a row of three 1x1 or a 2x1 and a 1x1); in four, to four. Otherwise the grid leaves a hole rather than reordering cards.
- With columns={4} the grid shows two columns between 768 and 1024 px, where a layout authored for four may leave a hole.
mobileOrdermoves cards visually on phones only. Screen readers and the Tab key follow the order of items, so keep items in the order that reads best and usemobileOrdersparingly (WCAG 2.4.3).- On a linked card the stretched link covers the media, so anything interactive in the media (a video's controls, a button) cannot be reached by pointer. Keep interactive media on static cards.
- A single item spans the full row, whatever its span.
- Light appearance by default. The tokens retone it for a dark or tinted page, but no dark mode is declared or selected automatically.
Example
<script lang="ts">
import BentoGrid from '$lib/components/bento-grid-01/BentoGrid.svelte';
</script>
{#snippet roadmap()}
<img src="/images/roadmap.png" alt="" />
{/snippet}
<BentoGrid
eyebrow="Halcyon"
title="Plan in boards. Present in timelines."
items={[
{ id: 'timeline', span: '2x2', title: 'Roadmap timeline', description: 'One lane per team.', media: roadmap },
{ id: 'boards', title: 'Boards', description: 'Every card has an owner and a due date.' },
{ id: 'guests', title: 'Guest access', description: 'Invite a client to one board.' }
]}
/>Bento grid#
A grid of feature cards in which you choose how much room each card gets. One card usually leads at 2x2 and carries the strongest image; the rest sit around it at 1x1, 2x1 or 1x2.
Authoring spans#
The grid places cards in the order you give them and never repacks them, so on desktop what a
visitor sees, what a screen reader reads and where the Tab key goes all agree (on phones too,
unless you set mobileOrder). Each card's id must be unique within items. The cost is that the
spans have to add up. In three columns these fill whole rows:
| Rows | Cards |
|---|---|
| 2 | 2x2, 1x1, 1x1 (the lead with two cards stacked beside it) |
| 1 | 1x1, 1x1, 1x1, or 2x1, 1x1, or 1x1, 2x1 |
| 2 | 1x2, 2x1, 2x1 (a tall card beside two wide ones) |
In four columns each row adds up to four. Between 768 and 1024 px a four-column grid shows two
columns, so a 2x2 or 2x1 card takes the full width there.
Media#
{#snippet roadmap()}
<img src="/images/roadmap.png" alt="" />
{/snippet}An img, picture, video or svg placed directly in the snippet fills the media area with
object-fit: cover. Anything else is laid out inside a positioned box at least 144 px tall
that stretches to the foot of the card, so an illustration can fill it with absolute inset-0.
Your media can read the grid's --_accent, --_ink and --_hairline to match a retone.
Phone order#
Below 768 px the grid is one column. By default it follows items. Give a card mobileOrder
to move it up: cards with a mobileOrder come first, lowest number first, and the rest follow
in their given order. This is CSS order, so assistive technology and keyboard focus still
follow items; only use it where the visual change does not alter what the content means.
Custom cards#
<BentoGrid {items}>
{#snippet card(item)}
<article class="h-full rounded-2xl bg-zinc-950 p-6 text-white">
<h3>{item.title}</h3>
</article>
{/snippet}
</BentoGrid>The grid still applies the span and the phone order, and the cell stretches the card to the row's height.
Dark page retone#
.dark-band {
--bento-grid-surface: #18181b;
--bento-grid-raised: #27272a;
--bento-grid-ink: #fafafa;
--bento-grid-muted: #a1a1aa;
--bento-grid-hairline: rgb(255 255 255 / 0.1);
--bento-grid-accent: #fafafa;
}Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
items | BentoItem[] | Yes | None | The cards, in reading order. Each is { id (unique within items), title, description?, media?, span?, href?, mobileOrder? }: span is '1x1' (default), '2x1', '1x2' or '2x2'; media is a Snippet; href makes the title the card's one link; mobileOrder places the card in the phone layout. |
columns | 3 | 4 | No | 3 | Desktop columns: three from 768 px, or four from 1024 px with two between 768 and 1024 px. Below 768 px every card is one column wide. |
eyebrow | string | No | None | Short uppercase label above the title. Omitted, no eyebrow renders. |
title | string | No | None | Section heading (h2). It names the section for assistive technology. |
description | string | No | None | One or two sentences under the title, held to a readable measure. |
intro | Snippet<[headingId: string]> | No | None | Replaces the eyebrow, title and description. Put headingId on your heading's id so it names the section. |
card | Snippet<[item: BentoItem]> | No | None | Replaces the inside of every card. The grid keeps each card's span and phone order; the snippet draws the surface, title, text and link itself. |
headingLevel | 3 | 4 | No | 3 | Heading level of each card title. Use 4 when the grid sits under an h3. |
Customization#
On this pageWrite the cards and their spans, pass media as snippets, retone through seven --bento-grid-* variables (accent, ink, muted, hairline, surface, raised and radius), and edit the source for spacing or layout.
- Content: items is the whole grid. Keep titles to a few words and descriptions to one or two sentences; cards grow to fit longer copy rather than clipping it.
- Spans: give the first card span '2x2' to make it the lead, and fill each row exactly: in three columns a 2x2 sits beside two 1x1 cards, then a row of three 1x1 or a 2x1 and a 1x1. Use at most three different spans in one grid.
- Media: a snippet per card. An img, picture, video or svg placed directly in it fills the area with
object-fit: cover; anything else is laid out inside a box that is at least 144 px tall and grows to fill the card. Use alt="" when the title already says what the image shows. Your media can read the grid's private --_accent, --_ink and --_hairline tokens. - Links: set href on a card and its title becomes the one link, with a trailing arrow, a hit area that covers the card and a focus ring round the card. Mix linked and static cards only when the difference means something.
- Phone order: set
mobileOrderon the cards that should lead on phones; the rest follow in the order given. It is visual only. - Accent:
--bento-grid-accentdraws the linked card's focus ring, and is there for your media to use (the preview's schematics take one mark each from it). The violet palette sets it to#6d28d9. - Text:
--bento-grid-inksets titles;--bento-grid-mutedsets descriptions, the eyebrow and the link arrow at rest. Keep both at 4.5:1 on the surface. - Surfaces:
--bento-grid-surfacefills the cards,--bento-grid-raisedfills the media area,--bento-grid-hairlinedraws the card edge, and--bento-grid-radiussets the card corners (16 px). - Dark page retone: surface
#18181b, raised#27272a, ink#fafafa, muted#a1a1aa, hairline rgb(255 255 255 / 0.1), accent#fafafa(all--bento-grid-*).usage.mdhas the snippet. - Custom cards: pass card to draw each card yourself; the grid still applies the span, the phone order and a full-height cell.
Public CSS variables
| Variable | Token |
|---|---|
--bento-grid-accent | accent |
--bento-grid-ink | ink |
--bento-grid-muted | muted |
--bento-grid-hairline | hairline |
--bento-grid-surface | surface |
--bento-grid-raised | raised |
--bento-grid-radius | radius |
Accessibility#
On this page- The cards are a list (ul with role="list", so Safari keeps the semantics without bullets), and each card title is a heading, h3 by default or h4 with
headingLevel={4}. The section is named by its title, or by the heading you mark withheadingIdin the intro snippet. - A linked card has exactly one link, the title, so its accessible name is the title rather than the whole card's text. The arrow after it is
aria-hidden. Each linked card is one tab stop. - A linked card shows a two-pixel accent focus ring round the whole card, offset by two pixels, on :focus-visible only. Where :has() is unsupported the ring falls back to the link itself.
- Hover, press and focus styling appear only on linked cards. The press scale and the arrow's nudge are removed under prefers-reduced-motion.
- Cards are placed in the order of items without dense packing, so reading, tab and visual order match on desktop.
mobileOrderchanges only the visual order on phones; keep items in the intended reading order. - Media is yours to describe: give images alt text when they add something the title does not, otherwise alt="" or
aria-hidden. - Muted text (
#52525b) measures 7.7:1 on white; ink (#18181b) 17.7:1. The violet focus ring (#6d28d9) measures 7.1:1 on white. - The layout uses logical properties, so it mirrors under dir="rtl" (the lead card moves to the right); title and eyebrow tracking resets to 0 and the arrow flips.
Known limitations
- Contrast is computed for the default tokens only; re-check any token you change (4.5:1 for text, 3:1 for the focus ring).
- The card hairline is a decorative boundary under 3:1; cards are identified by their headings, and linked cards by their title links.
- Text inside a linked card cannot be selected by dragging over it, because the link covers it.
Release details#
On this page- Integration
- Presentational
- Works without client-side JavaScript
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 1 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.