2026-09 · Layout · Figma · GitHub
Seven tiles that declare the room they want, on a grid that clamps every span against its own column count. Each tile answers for the slot it lands in rather than for the modifier it was given — and opening one re-packs the board around it.
Default — four columns. Open a tile: it takes the lead size, the board re-packs around it, and the tile re-composes itself
The board
Dense packing is what makes this a board rather than a stack: a tile too big for the gap waits, and a later unit backfills the hole. So reading order and DOM order are the same thing here, and the screen order is the one that moves.
Two columns asked for; one column available. The ask is clamped.
Three queries set one number. Every span clamps against it.
Twelve divides by four, three, two and one.
12
A tile too big for the gap waits; a later unit fills it.
One leads, one wide, one tall, four units.
7
A grid cannot tween a re-pack, so the tiles are moved.
460
One tile's markup, three slots — identical HTML in a 1×1, a 2×1 and a 2×2. Nothing was added to it; the slot decided
Twelve divides by four, three, two and one.
12
Twelve divides by four, three, two and one.
12
Twelve divides by four, three, two and one.
12
The same board in a 26rem column — it reflows against its own width, not the window's, and each tile answers for the slot it now has
The board
Dense packing is what makes this a board rather than a stack: a tile too big for the gap waits, and a later unit backfills the hole. So reading order and DOM order are the same thing here, and the screen order is the one that moves.
Two columns asked for; one column available. The ask is clamped.
Three queries set one number. Every span clamps against it.
Twelve divides by four, three, two and one.
12
A tile too big for the gap waits; a later unit fills it.
One leads, one wide, one tall, four units.
7
A grid cannot tween a re-pack, so the tiles are moved.
460
The ladder, stepped — four columns to one, and round again
The board
Dense packing is what makes this a board rather than a stack: a tile too big for the gap waits, and a later unit backfills the hole. So reading order and DOM order are the same thing here, and the screen order is the one that moves.
Two columns asked for; one column available. The ask is clamped.
Three queries set one number. Every span clamps against it.
Twelve divides by four, three, two and one.
12
A tile too big for the gap waits; a later unit fills it.
One leads, one wide, one tall, four units.
7
A grid cannot tween a re-pack, so the tiles are moved.
460
A board of seven tiles on a grid that is told how many columns it has and nothing else. Each tile declares the room it wants — two columns, two rows, both, neither — and every span clamps itself against whatever the board currently is, so the arrangement is a rule rather than a map of where things go. Each tile is also its own container, so what it shows is decided by the slot it landed in rather than by the modifier it was given; and opening one promotes it to the lead size, which re-packs the board and re-composes the tile in the same gesture.
A tile carries --bento-grid-tile-cols and
-rows; the board resolves
grid-column: span min(var(--tile-cols), var(--cols)).
The three container queries set --bento-grid-cols to
2, 3 and 4 and touch nothing else — no per-breakpoint span
table, no grid-template-areas map to keep in step.
Without the min() a one-column board grows a phantom
track to fit a two-column tile: measured,
grid-template-columns resolves to
134px 8px and the wide tiles sit 16px proud of the
narrow ones. Rows are deliberately not clamped, so the tile that
led at four columns still leads at 320px.
One 2×2, one 2×1, one 1×2 and four 1×1. Measured at every rung: the board resolves to 4×3, 3×4, 2×6 and 1×9 and fills its rectangle exactly, with no ragged bottom row and no sideways scroll at 320px. Opening a tile breaks that on purpose — it is the reader asking for room, not the layout going wrong.
container: bento-grid-tile / size on every tile, and
two queries decide the composition. Measured as tile content
width: 15.31rem at four columns, 13.29 at three, 13.50 at two
— and 20.38rem at one, because there is only one of it. So a
unit tile is wider at one column than at four and takes
the fuller composition there, which is the answer a span-based
modifier cannot give: 1×1 is a share of a board, not a size.
The threshold is 16rem, between the widest compact case and the
narrowest wide one. Padding lives on the panel inside the tile
rather than on the tile, so the box a container query measures
stays equal to the slot the grid handed over.
<details>.The tile is an <li>; the
<details> inside owns the open state and the
<li> reads it back with :has(),
promoting the tile to 2×2. The board re-packs because the
flow is dense, and the tile re-composes because it has just become
large enough to answer both slot queries. One gesture, both
mechanisms, and no script: disclosure is what that element already
is, keyboard, screen reader and find-in-page included.
component.js is for.A grid cannot tween one: track counts are not interpolable and
neither is a tile's placement, so every tile is simply somewhere
else on the next frame. The View Transitions API is the only thing
that moves them — it snapshots the old layout, lets the
change happen, snapshots the new one and animates each named
element between the two. A name must be unique per document and
this page carries four boards, so it is the board's index and the
tile's, set for the length of a transition and taken off after.
The stylesheet reaches it through
view-transition-class rather than
::view-transition-group(*), which would take every
transition on the page. Everything the script does is enhancement:
deleted, the tiles are still <details>, the
ladder falls back to its keyframes, and the softening it replaced
comes back with it. And the page stays live while the tiles move:
for the length of the component’s own transitions the page
is left out of the capture and the overlay passes clicks through
to it, where before a click anywhere landed on nothing for 460ms
of every 1200ms rung. A tile in flight still swallows its click,
rather than opening whichever tile has arrived under it.
Flat and unlit — no gradient, no tint — and the lift is a 5px offset block of the same ink rather than a blur, because a soft shadow would put light into a register that has none. The accent marks the cells a tile is occupying and nothing else: in the lead’s mini-board, in the span rows, on the ladder. So the colour means one thing everywhere it appears. This was nine flat fields for two rounds, and the count was what was wrong rather than any of the values — beside the other studies at thumbnail size it was the only polychrome card in the set, and a board of twelve cells arriving with nine colours read as a swatch chart before it read as an arrangement.
The row height is measured rather than chosen. A tile's face gets the
row less 48px, and the tallest thing any face has to hold is the mark
tile's 117px; 11rem is the first round figure that clears it. Every
earlier value clipped something, and quietly — at 7rem six
tiles, at 9rem four, by 2 to 37 pixels each. A figure set at
line-height: 1 overflows its own line box by about
0.15em, which with an auto margin pinning it to the bottom lands
exactly on the clip. And an aside gets two lines: about sixty
characters at the narrowest width one appears at.
Three things cost a measurement each. An unnamed @container
asks the nearest ancestor container, so once the tiles became
containers a board-level rule was silently being answered by a 201px
tile and every tile in a four-column board took the wide composition;
two containers in one component is two names. And
--bento-grid-cols is registered with
@property as an <integer> —
without that it cannot be animated at all, and the ladder above jumps
straight to its last rung. And container-type: size
applies size containment, so a tile is laid out as though it had no
contents and grid-auto-rows: minmax(var(--row), auto) can
never grow a row to fit an opened tile — height-based slot
queries and rows that grow to their content are mutually exclusive.
notes.md — the full record, including what was tried and dropped