All studies

2026-09 · Layout · Figma · GitHub

Interactive bento grid

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

    Twelve cells, four ways

    7 tiles

    12 cells

    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.

  • Span

    Two columns asked for; one column available. The ask is clamped.

    4 columns

    1 column

    Without the clamp a one-column board grows a phantom track to fit the tile, and the wide ones sit proud of the narrow ones.
    Rule
    span min(2, cols)
    Measured
    134px 8px
  • Ladder

    Three queries set one number. Every span clamps against it.

    4

    3

    2

    1

    The rungs are container widths, not viewport widths — the board reads the box it was dropped into.
    Rungs
    44 / 29 / 22rem
    Sets
    --cols, nothing else
  • Cells

    Twelve divides by four, three, two and one.

    12

    at rest

    At rest
    12 cells
    One open
    15 cells
  • Flow

    A tile too big for the gap waits; a later unit fills it.

    Flow
    row dense
    Costs
    visual order
  • Tiles

    One leads, one wide, one tall, four units.

    7

    one leads

    Sizes
    2×2, 2×1, 1×2
    Units
    4
  • Morph

    A grid cannot tween a re-pack, so the tiles are moved.

    460

    ms, eased out

    Curve
    0.16, 1, 0.3, 1
    Without JS
    it still opens

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

  • Cells

    Twelve divides by four, three, two and one.

    12

    at rest

    At rest
    12 cells
    One open
    15 cells
  • Cells

    Twelve divides by four, three, two and one.

    12

    at rest

    At rest
    12 cells
    One open
    15 cells
  • Cells

    Twelve divides by four, three, two and one.

    12

    at rest

    At rest
    12 cells
    One open
    15 cells

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

    Twelve cells, four ways

    7 tiles

    12 cells

    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.

  • Span

    Two columns asked for; one column available. The ask is clamped.

    4 columns

    1 column

    Without the clamp a one-column board grows a phantom track to fit the tile, and the wide ones sit proud of the narrow ones.
    Rule
    span min(2, cols)
    Measured
    134px 8px
  • Ladder

    Three queries set one number. Every span clamps against it.

    4

    3

    2

    1

    The rungs are container widths, not viewport widths — the board reads the box it was dropped into.
    Rungs
    44 / 29 / 22rem
    Sets
    --cols, nothing else
  • Cells

    Twelve divides by four, three, two and one.

    12

    at rest

    At rest
    12 cells
    One open
    15 cells
  • Flow

    A tile too big for the gap waits; a later unit fills it.

    Flow
    row dense
    Costs
    visual order
  • Tiles

    One leads, one wide, one tall, four units.

    7

    one leads

    Sizes
    2×2, 2×1, 1×2
    Units
    4
  • Morph

    A grid cannot tween a re-pack, so the tiles are moved.

    460

    ms, eased out

    Curve
    0.16, 1, 0.3, 1
    Without JS
    it still opens

The ladder, stepped — four columns to one, and round again

  • The board

    Twelve cells, four ways

    7 tiles

    12 cells

    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.

  • Span

    Two columns asked for; one column available. The ask is clamped.

    4 columns

    1 column

    Without the clamp a one-column board grows a phantom track to fit the tile, and the wide ones sit proud of the narrow ones.
    Rule
    span min(2, cols)
    Measured
    134px 8px
  • Ladder

    Three queries set one number. Every span clamps against it.

    4

    3

    2

    1

    The rungs are container widths, not viewport widths — the board reads the box it was dropped into.
    Rungs
    44 / 29 / 22rem
    Sets
    --cols, nothing else
  • Cells

    Twelve divides by four, three, two and one.

    12

    at rest

    At rest
    12 cells
    One open
    15 cells
  • Flow

    A tile too big for the gap waits; a later unit fills it.

    Flow
    row dense
    Costs
    visual order
  • Tiles

    One leads, one wide, one tall, four units.

    7

    one leads

    Sizes
    2×2, 2×1, 1×2
    Units
    4
  • Morph

    A grid cannot tween a re-pack, so the tiles are moved.

    460

    ms, eased out

    Curve
    0.16, 1, 0.3, 1
    Without JS
    it still opens

Decisions

Type
Layout

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.

  1. One number per breakpoint, and the spans clamp themselves.

    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.

  2. Twelve cells, because twelve divides by four, three, two and one.

    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.

  3. A tile answers for its slot, not for its span.

    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.

  4. Opening a tile is the whole interaction, and it is a <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.

  5. The re-pack is moved, not disguised, and that is what 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.

  6. One plate, one accent, and the accent is always the same thing.

    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.

Inspiration
None — original design. It carried a Tal R line for two rounds, for the nine-colour register and the refusal to shade anything, and half of that is gone: what is left is flat unlit colour, which is common property and not a debt. The arrangement was always this study’s own.

notes.md — the full record, including what was tried and dropped