Skip to main content

The Grid That Doesn't Lie

An operator sorts the camera grid by activity — most recently moved first. Camera three on the list is a 360° pano, so its tile is six times wider than the others. And here's the question that shaped this whole feature: when one tile is a different size, where does everything else go?

Let the browser decide, and it lies to you. This lesson is the story of that lie and the fix.

The lie: grid-auto-flow: dense

CSS Grid's dense packing fills the smallest available gap first, not the next logical position. With mixed tile sizes that means:

┌─────────┐ ┌─────────┐ ┌─────────┐
│ Cam A │ │ Cam B │ │ Cam G! │ ← G back-filled into row 1
└─────────┘ └─────────┘ └─────────┘
┌───────────────────────────────────┐
│ Cam D (PANO 360°) │
└───────────────────────────────────┘
┌─────────┐ ┌─────────┐
│ Cam E │ │ Cam F │ ███████████ ← black gap where G "should" be
└─────────┘ └─────────┘

Three failures at once: sort order breaks (cameras jump to wherever they fit — deadly when the order means "most urgent first"), black gaps appear where nothing fit, and rows with a pano stretch their neighbors into letterboxed black bars. For a wall of fire cameras, a grid that silently reorders itself isn't a cosmetic bug.

The fix: compute every position ourselves

util/grid/computeGridPositions.ts walks the cameras in sort order and assigns each tile an explicit grid-row and grid-column — the browser gets no decisions to make:

  1. Each tile gets its span — featured tiles wide (e.g. 6 of 12 columns), regular tiles standard (3 or 4), pano tiles from the current column to the end of the row (currentCol / -1).
  2. Doesn't fit in the remaining columns? Wrap to the next row.
  3. A pano that would land too narrow (less than half the grid) wraps to a fresh row instead of squeezing.
  4. Tiles sharing a row with a pano get aspectRatio: 'auto', so they stretch to the row's height instead of floating in black bars.

The output — one {gridRow, gridColumn, aspectRatio} per tile — is applied as inline style by CameraListComponent onto each CameraDetails. Result: sort order preserved, no gaps, panos expanding in place:

┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Cam A │ │ Cam B │ │ Cam C │ │ Cam D │
└─────────┘ └─────────┘ └─────────┘ └─────────┘
┌─────────┐ ┌───────────────────────────────────┐
│ Cam E │ │ Cam F (PANO) — col 4 / -1 │
│ auto h │ │ expands in-place to row end │
└─────────┘ └───────────────────────────────────┘
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Cam G │ │ Cam H │ │ Cam I │ │ Cam J │ ← order intact
└─────────┘ └─────────┘ └─────────┘ └─────────┘

A bonus that pays off elsewhere: with positions as plain numbers, reordering is just changing a number — no DOM moves. The inline details panel exploits exactly that to shove rows down and insert itself.

Animations, and where they're switched off

A pano expanding is animated via a transient attribute — no JS animation loop:

.listContainer > div[data-pano-active='true'] {
animation: panoExpand 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}

On mobile portrait, all grid transitions and animations are force-disabled (transition: none !important) — reflowing a 50-tile grid at 60fps on a phone in the field is exactly the jank an operator doesn't need.

Wrapping up

The grid never delegates placement: an algorithm walks the sorted cameras, assigns explicit coordinates, widens panos in place, and hands the browser a fully-decided layout. When tiles land somewhere strange, debug computeGridPositions — not CSS.

Next: what makes a tile switch to pano in the first place.