In 2015, I decided to take a picture of everything I ate. Every sandwich, every chip, and every square of chocolate. For 365 days, if I ate it, I took a picture of it. When December rolled around, I had over 1,800 pictures with nowhere to put them. I couldn’t upload that many photos to Facebook or Instagram, and I hadn’t found a photo service that I liked enough to put my food photos on.
First: Why the easy solutions don’t work
The most straightforward way to display a collection of images on the web is to show the images in pages of rows and columns, with 3–5 images per row and 20–50 images to a page. It’s tried and true, but not a great solution for my purposes. If I post 1,800 photos in 36 pages, you can be sure that very few people are going to make it past page two or three.
You might think “A-ha! This is what infinite scrolling is for!” Most websites with lots of content use a simple tactic for simulating an infinitely long page: They display 10–20 entries in a vertical array, and when you scroll to the bottom of the screen they load in 20 more and inject them at the bottom of the screen and let you keep scrolling. Problem solved.
Well, not quite. I tried this, and it turns out no matter how you load them, two thousand reasonably-sized images on the same website will crash your web browser. The site works great as you scroll for the first minute or two, but as you keep going it will eventually get bloated and freeze. Bummer.
Finding an elegant solution
I wanted to design an image grid that displays all two thousand images on one page, without showing more than 50 or so images at a time. To do this, I would need to both load and unload images onto the page, to ensure that a reasonable number of images were on screen at once.
To accomplish this, I came up with an idea: wherever you are on the page, we should have all images within the viewport loaded, as well as some images immediately above and below the viewport, so they are visible when you scroll.
For a better user experience, it is helpful to know what direction the user is scrolling and make that buffer larger. If we are scrolling down, for example, we don’t need that many images loaded above the viewport, because they are only needed if the user changes scroll direction. If the user is scrolling down the page, the primary image buffer is below the viewport, and the secondary image buffer is above the viewport.
Images outside the viewport and outside the buffers do not need to be loaded into the page. As the user scrolls down the page, images above the viewport are removed as more are loaded below. This ensures that we have a relatively constant number of images on screen and a time.
It’s a clever solution, but not one that comes naturally in HTML and CSS. If we use a traditional “rows and columns” grid, removing a row of images from the DOM would cause the images below to jerk upwards. I thought about a way to cleverly put the right amount of padding above the upper buffer to avoid this problem, but that ended up being a huge headache. To make matters worse, alongside managing loading and unloading I also wanted the image grid to be responsive.
Enter Google Photos
I figured out a solution to this problem while scrolling through my food pictures on Google Photos. The web app choreographs a similar infinite-scroll that I wanted to build, while also allowing images to shift around smartly as you resize the page.
To figure out how they did it, I spent ten or twenty minutes fooling around in the Chrome inspector, watching as images were loaded and unloaded, and noticing how they did layout. The secret: CSS transform-based positioning.
Using translate3d for layout
The basic layout consists of a bunch of <figure class=”pig-figure”> elements inside a container, with the following CSS:
Then, once the library has injected and laid out all the images, the HTML ends up looking like this:
OK, so what’s so cool about this solution?
- Loading and unloading: Because each <figure> element is absolutely positioned, we can safely remove and add new elements without affecting the position of the others.
- Responsive sizing and layout: When you resize the window, you can update the width, height, and translate3d properties in the inline styles, and shift images around using CSS transforms.
So this much I completely ripped from Google Photos (through the Chrome Inspector). I had an idea how the whole thing worked, but the implementation proved tricky.
How it works
Reverse engineering the Google Photos image grid proved complicated. The grid consists of many rows of images, where each row has the same spacing between images and is perfectly sized to fill the container, but each row might have a different number of images, or a different height. Importantly, within each row, all of the images are the same height.
In order to size each row perfectly, we build up the row image by image, with a maximum desired row height. Because all of our images will share the same height, we can treat them as one combined shape.
While this would work as-is, it’s actually easier to use a minimum aspect ratio rather than a maximum height. Here’s how:
Let’s say the desired maximum row height is 200px. If the row is 1000px wide, that’s a total aspect ratio of 5:1. If we had just one 16:9 image at 100% width, the row would be 1000px * 562.5px. Too tall.
If we add another image to the row, each would be 500px * 281px. Still too tall.
We continue adding images onto our row until the aspect ratio of the combined shape exceeds our minimum, 5:1. In the illustrated example, it takes two landscape images, a portrait image, and a final landscape image to create a row with a large enough aspect ratio.
Then, using the aspect ratio of each image, we can compute each image’s width from its fixed height: For landscape images, their width is 169.5px * (16/9) = 301.3px. For our portrait image: 169.5px * (9/16) = 95.3px.
Now all that’s left is to update the style attributes on each of our <figure> elements and we’re done.
Making it fast
My original implementation involved running this entire algorithm every time a scroll event fired, which left me with some serious lag. In order to achieve smooth scroll behavior, I removed as much work from the scroll handler as I could.
While the selection of photos in view could change on every scroll event, the actual layout of the grid is fixed. Taking advantage of this, I separated the computation of each image size and position and the load/unload logic into two different functions:
- _computeLayout is responsible for creating a huge list of objects, one per image, where each object contains a reference to a DOM element, as well as it’s desired height, width, translateX, and translateY values. I only call this on load and resize, when the layout needs updating.
- _doLayout takes the latest scroll position and direction, determines the primary and secondary image buffers, and then uses the data structure created in _computeLayout to appropriately hide, show, and position the DOM elements.
In doing so, I saw a big uptick in scroll performance. This separation of concerns ensures that we’re not computing our layout many times a second.
Making it (even) faster
The next step was to make sure that the grid ran well on mobile devices as well as desktops. To do this, I looked at how Medium loads images in their articles, and brought that to my grid.
Have you ever noticed on a slow internet connection how Medium’s images load in a blurred state and then appear to come into focus? It’s not just a cool effect; it’s also a tactic for reducing page load times.
They start by loading in a ~20px tall version of the image, blown up to the actual size of the image. Then, they blur that thumbnail, creating the blurred image effect. At the same time, they load the full image, and when it’s ready, they replace one with the other by animating the thumbnail’s opacity to zero. This tactic: loading a placeholder and then loading in the full asset afterwards is called progressive loading.
José M. Pérez wrote a great blog post detailing the entire process, and wrote a super useful Codepen that both shows off the effect and demonstrates how to use filter:blur() in CSS rather than a <canvas>-based solution.
Recap: Performance-first design
I believe that on the web, performance can be one of the biggest components of good design and storytelling. With pig.js, I put speed as my one and only concern for the library. It was a lengthly process, but I ended up with four core tactics for making the grid smooth:
- Dynamically load and unload images above and below the viewport to reduce the number of elements on the page at once.
- Make the primary and secondary image buffer different sizes, because we are more likely to load images in the direction we were already scrolling.
- Separating out layout computation and DOM manipulation into two different functions: _computeLayout and _doLayout, only running the former on load and resize, drastically reducing the work we have to do on scroll.
- Implement a responsive version of Medium’s progressive image loading, making on-the-fly decisions for what size image to load.
The end result? It’s pretty freaking fast
My final result was a library with no dependencies and no feature-bloat. For a pure display of images, it’s the fastest thing I’ve used. I hope you enjoy it!
Have a question or comment? Can you think of anything that might make this run even faster? I’d love to hear from you! Leave a response here, or even better: an issue/PR on GitHub.