I kept getting a neat grid. That was the problem.
I was trying to build an image display with the character of an editorial layout: unequal rectangles, strong alignments, a mixture of portrait and landscape images, and occasional empty blocks that felt deliberate. The whole composition needed to form a clean rectangle, but the individual divisions did not need to run all the way across it.
I could describe the result I wanted. Getting HTML, CSS and JavaScript to produce it reliably was another matter.
The early attempts looked reasonable in isolation. They had different tile sizes. They preserved the images. They were responsive. Yet the composition still felt like a set of predefined boxes with images placed inside them.
What I wanted was the reverse: a composition whose geometry came from the images themselves.
That distinction eventually changed the implementation more than any adjustment to the styling.
The wrong thing was making the decisions
The initial approach was familiar: define grid tracks, give selected items larger spans, and use a handful of arrangements to introduce variety.
But the images were already making a visual argument of their own. A tall portrait, a broad landscape and a narrow panorama are not interchangeable content. Their proportions matter.
With the fixed-cell model, those proportions had very little influence over the layout. A rectangle could become larger or smaller, but only within the vocabulary of the underlying cells.
There were two obvious ways to deal with the mismatch. object-fit: cover could fill the tile while cropping the image. object-fit: contain could preserve the full image while leaving unused space inside a differently proportioned tile. Those are the intended behaviours of the property, not browser defects.1
Neither was the design I was trying to build.
Cropping would make the image conform to the composition. Containment would leave the composition pretending that an image occupied more space than it actually did.
There was nothing inherently wrong with CSS Grid. The problem was that my chosen grid model was making the important decisions before it knew enough about the assets.
The requirement became much clearer once I expressed it as a rule:
Every image should occupy a rectangle with its own proportions. Any additional space should belong to a separate empty rectangle.
That is a different problem from placing images into an attractive grid.
Empty space became part of the data
An empty block might look like the absence of content, but in this design it has a job. It completes the outer rectangle without forcing an image to stretch, crop or sit inside an oversized box.
So the layout model needed to recognise three kinds of region: images, optional text, and empty rectangles. All of them have coordinates and dimensions. Only the images have an immutable source aspect ratio; text has readability constraints, and empty regions have more freedom.
The composition must account for the entire container:
Image rectangles + optional caption + empty rectangles
= one complete rectangular stage
It is not enough for the images to avoid overlapping. The remaining space must also be divided into valid rectangles. Otherwise, an irregular hole is still an irregular hole, even when its background happens to match the surrounding page.
This also changed the meaning of “optimisation”. Empty space was allowed. Making it disappear at any cost was not the objective.
The research that changed the direction
Flickr’s Justified Layout was a useful early reference. It accepts aspect ratios or image dimensions and returns layout geometry, with row height as a central control. That reinforced the idea of calculating the boxes from the assets rather than delegating that decision to a set of CSS spans.2
I did not want the whole composition organised into justified rows, but the ratio-first thinking was valuable.
A more decisive reference was Ahmad Moussa’s An Algorithm for Irregular Grids, published on Gorilla Sun. It draws attention to interlocking rectangular arrangements that recursive subdivision cannot produce. Its construction method uses a boolean occupancy grid, places rectangles in available cells, and fills the remaining gaps through additional passes.3
That helped explain why an earlier proposal, repeatedly split a rectangle horizontally or vertically, was still too restrictive. It could generate variety, but not every kind of variety I was looking for.
The distinction has a formal counterpart in research on rectangular layouts. A sliceable layout can be decomposed through successive full cuts; other arrangements cannot. The same research also cautions against assuming that an arbitrary arrangement can accommodate every assignment of aspect ratios.4
This gave us two separate concerns: the relationships between the blocks, and the dimensions that make those relationships possible.
The Gorilla Sun algorithm was not a drop-in image-layout solution. Its rectangle dimensions were chosen in grid-cell units, independently of image metadata. Adopting that unchanged would have recreated the original mismatch.3
What I took from it was permission to explore interlocking structures, not a reason to keep a fixed display lattice.
What we actually implemented
At one point, the proposed approach was becoming a general-purpose system for generating arrangements and solving their boundary constraints.
The working implementation took a narrower route. Rather than trying to support every possible rectangular topology, it compares three concrete families of layouts. Each works with continuous dimensions, and each must satisfy the same validation rules.
This kept the first version dependency-free at runtime without restricting it to a small catalogue of hard-coded CSS templates.
Ratio-driven composition trees
The first family combines images or image groups either beside one another or in a stack.
The mathematics is useful precisely because it is small. Defining aspect ratio as width divided by height, two side-by-side rectangles with equal height have a combined ratio of:
combinedRatio = a + b
For rectangles stacked at equal width:
combinedRatio = 1 / (1 / a + 1 / b)
A group can then participate in another combination as a rectangle in its own right.
For example, stacking a 3:2 landscape above a 3:1 panorama produces a square group. At 720 pixels wide, their respective heights are 480 and 240 pixels.
The solver explores different groupings and can introduce space within a group to reconcile its natural proportions with the stage. Shared widths and heights become local alignment relationships, not evidence of a global cell size.
Continuous free-rectangle packing
The second family explores placements within available rectangular regions. Jukka Jylänki’s RectangleBinPack work provided the relevant packing background, particularly the maximal-free-rectangle approach.5
Our version must also choose display sizes: the assets have fixed proportions, not fixed rendered dimensions. It therefore tries different relative area preferences and scale probes, placing proportionally correct image rectangles into available space.
A crucial implementation detail is that the free rectangles used during this search can overlap one another. They describe alternative places where an image might fit; they are not the final empty blocks.
Treating that internal search representation as renderable geometry would create a different set of layout errors.
Analytically solved pinwheels
The third family supplies explicitly interlocking candidates. Four outer groups wrap around a central region, which can contain an image, a caption or empty space.
Their aspect-ratio equations determine the boundary positions. The image pixels are not rotated or distorted to make the structure work. When the calculated arrangement is invalid or makes a required block too small, the candidate is rejected.
This is a limited family, not a universal interlocking-layout solver. But it gives the search arrangements that a pure slicing tree cannot represent, and the demo includes a test case that verifies that distinction.
Importantly, an interlocking composition is not automatically better. A simple stacked arrangement should still win when it displays the available images more effectively.
The empty blocks needed their own algorithm
Once the content rectangles have been placed, the solver calculates their rectangular complement: the regions of the stage they do not occupy.
In the implementation, a sweep moves through the stage in strips defined by image and caption boundaries. Within each strip, it finds the unoccupied intervals. Matching intervals in neighbouring strips can extend into larger empty rectangles.
The calculation is tried in both directions, and the cleaner result is retained. Adjacent empty rectangles are merged only when their union is also a rectangle.
That last condition sounds obvious until an L-shaped remainder appears. Turning it into a single bounding box would cover space belonging to an image.
The result is an explicit collection of non-overlapping empty blocks, not a set of leftover packing regions or a background colour disguising unaccounted-for space.
Those blocks also participate in candidate scoring. Their number and shape can influence which image arrangement wins. They are calculated after placement, but they are not an aesthetic afterthought.
“Best” needed a definition
My original requirement was to optimise the size of each image. The difficulty is that the images compete for the same space.
Maximising total image coverage alone could favour one large image while reducing another to something technically present but practically useless. Giving every image equal area would create a different problem when one asset is supposed to have more emphasis.
The implementation separates validity from preference.
A valid result must include every supplied image exactly once, preserve its ratio within numerical tolerance, respect minimum dimensions, remain inside the stage, avoid overlaps, and account for the whole rectangle.
Only then do we compare compositions.
The search first identifies the highest image coverage among the valid candidates it found. It then considers alternatives within a configurable coverage allowance and favours a better distribution of image sizes, fewer fragmented empty regions and more balanced placement.
That is what the compact, balanced and editorial modes adjust. They do not swap between predefined designs, and they do not impose a mandatory percentage of empty space.
Sometimes a low coverage figure is simply the correct answer. A single 3:4 portrait in a 1,200 × 600 stage can be at most 450 × 600 without cropping, distortion, rotation or overflow:
Image area: 450 × 600 = 270,000
Stage area: 1,200 × 600 = 720,000
Maximum coverage: 37.5%
No better search strategy can remove the remaining space while preserving those conditions.
The honest promise is therefore the best valid composition found within a bounded search, not a guaranteed global optimum. A failed search also does not always prove that no solution exists.
From geometry experiment to reusable component
The core solver does not know about the DOM, fonts, image loading or animation. It receives image metadata, a canvas size and configuration, and returns rectangles, divider geometry, statistics and warnings.
The browser component handles the surrounding lifecycle.
That separation made it possible to test the geometry independently, run the search in an optional worker, and keep the reusable feature separate from the demo controls and page styling.
The demo itself supports uploaded images, different container proportions, image emphasis, optional captions, composition modes and a geometry view. Those controls were useful for testing the requirement against unfamiliar assets rather than repeatedly admiring the same sample arrangement.
Trust the image, then position its rectangle
The browser component reads the loaded image’s intrinsic dimensions through naturalWidth and naturalHeight, rather than trusting potentially inaccurate supplied metadata. These properties report the browser’s intrinsic, density-corrected dimensions.6
Geometry is calculated in CSS pixels. Percentages are only introduced when positioning the resulting elements.
There is an easy mistake here: dividing width by the stage width and height by the stage height changes their numerical ratio whenever the stage is not square. A rectangle’s normalised width divided by its normalised height is not its displayed aspect ratio.
Solving in one physical coordinate system avoids that confusion.
The renderer uses absolutely positioned elements. object-fit: contain remains as a safeguard, but it is no longer responsible for rescuing an incompatible tile. The calculated image box should already be proportionally correct.
Shared dividers are drawn in a single SVG overlay rather than as gaps that consume space between images. Collinear edges are merged, and vector-effect: non-scaling-stroke keeps the stroke from scaling with the SVG geometry.7
Recompose rather than shrink
The component observes its own width, not just the browser window. ResizeObserver is designed to report element-size changes, which matters when a component sits inside a changing page layout.8
When the available width changes, the solver calculates another composition. The same seed makes equivalent inputs reproducible; it does not promise that a different-sized container will preserve the previous arrangement.
For an adaptable section, the stage can grow taller when the search cannot satisfy the minimum sizes. A strictly fixed stage instead reports a fit failure. It does not quietly crop an image or discard an item to make the error disappear.
Text and failure states are real constraints
A caption cannot be treated like a photograph. Its required height depends on wrapping and typography.
The component waits for fonts, measures the caption with the styles used for rendering, and reserves minimum dimensions based on that measurement. It does not shrink text to squeeze it into an attractive gap.
Similarly, a failed replacement image should not erase a working composition. The component retains the last accepted result when a new collection fails to load or fit, and it prevents an older loading request from replacing a newer collection.
The renderer also preserves source DOM order, hides decorative empty regions from assistive technology, and includes reduced-motion-aware styling. Those are useful foundations, not a substitute for a full accessibility assessment. Visual placement can differ from source order, so this is not an appropriate default for a sequence that must be read chronologically.
These details are less visible than the mosaic, but they are part of making the feature reusable rather than merely demonstrable.
Testing more than the attractive examples
A screenshot can show that a layout looks convincing. It cannot establish that the algorithm kept every image, preserved every ratio, or accounted for every part of the stage.
The geometry tests check those properties directly. They exercise single images, mixed collections, all-portrait and all-landscape groups, extreme ratios, fractional container dimensions, captions and deliberately invalid results.
Known-answer cases are particularly useful. The single portrait has a calculable coverage ceiling. The interlocking example can be checked for the absence of a recursive slicing decomposition. Seeded runs make a surprising composition reproducible.
The working build passed 132 geometry tests and 34 Chromium checks. The browser checks covered rendering, resizing, uploads, caption overflow, failed-image handling and replacement-request races.
That evidence has boundaries. The browser harness exercised the embedded standalone demo; it was not a production deployment or a Safari, Firefox and screen-reader certification. Those integration and accessibility checks remain separate work.
The geometry view was useful alongside the tests: by exposing dimensions, ratios and empty regions, it made it much harder for attractive artwork to conceal a structural mistake.
The useful outcome was a better model
The result is a small, framework-independent HTML, CSS and JavaScript feature intended for curated groups of images, not an infinite-scroll gallery or a universal page-layout engine.
It preserves the assets, generates both simple and interlocking compositions, represents empty space explicitly, and exposes enough control to influence the result without manually placing every rectangle.
This was an AI-assisted build. The first technically plausible recommendations were not the final answer. Progress depended on making the visual requirement more precise, finding research that challenged the proposed approach, and checking what the implementation actually did.
That is the part of the exercise I found most useful.
I did not need another variation of the same grid. I needed to change which information the layout was allowed to prioritise.
The images needed to determine the composition. The empty blocks needed to complete it.
Once those became explicit rules, the implementation had something concrete to solve.
References and what they contributed
Footnotes
-
W3C, CSS Images Module Level 3 —
object-fit. The distinction between preserving an image within a box and making the box itself match the image. ↩ -
Flickr, Justified Layout. An early reference for aspect-ratio-driven geometry and separating layout calculation from rendering. The library itself is not a runtime dependency. ↩
-
Ahmad Moussa / Gorilla Sun, An Algorithm for Irregular Grids. The key conceptual reference for looking beyond recursive subdivision towards interlocking arrangements. The delivered solver does not retain its fixed occupancy grid as the display geometry. ↩ ↩2
-
Stefan Felsner, Andrew Nathenson and Csaba D. Tóth, Aspect Ratio Universal Rectangular Layouts. Background for sliceable layouts and the limits of preserving a selected structure while assigning aspect ratios. This is not an implementation of the paper’s recognition algorithm. ↩
-
Jukka Jylänki, RectangleBinPack, the repository accompanying A Thousand Ways to Pack the Bin — A Practical Approach to Two-Dimensional Rectangle Bin Packing. Packing background for the maximal-free-rectangle candidate family; not a claim that fixed-size packing alone solves the mosaic problem. ↩
-
MDN Web Docs, HTMLImageElement:
naturalWidth. The practical reference for using the browser’s intrinsic image dimensions. ↩ -
MDN Web Docs, SVG
vector-effect. The practical reference for consistent divider strokes. ↩ -
MDN Web Docs,
ResizeObserver. The practical reference for responding to changes in the component’s available space. ↩