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:
- 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). - Doesn't fit in the remaining columns? Wrap to the next row.
- A pano that would land too narrow (less than half the grid) wraps to a fresh row instead of squeezing.
- 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.