A Few Things I Learned From “Beyoncé Knolls”

I’m fortunate to have a couple of weeks off of work to end this miserable year. The first mini project I finished during the break stemmed from a two-word pun that’s been rattling around my brain for over a year — Beyoncé Knolls. That is, “Beyoncé” as in “Beyoncé” and “knolls” as in “Always Be Knolling.” (Admittedly, this is a weak pun.)

Until very recently I had envisioned it as a Sketch project, something to be drawn by hand and exported as a static image to share on Twitter or wherever. I enjoy noodling around in Sketch, and making rectangles and shadows work overtime to give the illusion of CD jewel cases and LP covers was a nice diversion. But as the idea grew from “do the six studio releases and call it a day” to “illustrate Beyoncé’s entire discography,” constantly rearranging the grid layout to accommodate more and more releases got a little frustrating for a one-off Twitter gag.

I was on the verge of adding this to the dumb-visual-jokes-I-gave-up-on thread, when it hit me: drawing things semi-realistically with rectangles and shadows and then arranging them on a grid is precisely the domain of CSS. So I threw in the towel on throwing in the towel and got to work.

The final result looks like this:

Grid of Beyoncé album covers
beyonce-knolls.netlify.app

It might also look like this, depending on the size of your browser and the scale at which it is zoomed:

Grid of Beyoncé album covers arranged differently to accommodate a smaller browser
beyonce-knolls.netlify.app

Which brings us to my first takeaway…

Grid is incredibly powerful

It’s old news to front-end developers by now, but my goodness is Grid an incredible tool.

The complexity and sheer number of media queries it would take to pull off the same layout in such a fluid and sturdy way using traditional responsive design techniques is mind-boggling.

On the contrary, the magic that makes the Beyoncé Knolls layout responsive without resorting to a hundred fiddly, brittle breakpoints is this unassuming little powerhouse:

grid-template-columns: repeat(
    auto-fill,
    minmax( var( --grid-min ), 1fr )
);

Wild, right? repeat, auto-fill, and minmax are a hell of a combo.

(Confession: I do use two media queries to adjust the --grid-min custom property at very rough breakpoints to ensure that at least two columns are visible at most viewport widths. This is less to do with Grid and more to do with the fact that a single column of squares and rectangles can hardly be considered knolling…)

Using auto-fill with minmax for the first time shone a bright light on something I’ve known academically but finally learned first-hand.

A case for container queries

To help maintain the semi-realistic appearance of each object, I tried to ensure that important visual styles remained proportional to their parent elements.

Real DVD cases, for example, have a fairly consistent radius to their rounded corners and a pretty standard inset between the edge of the case and the cover insert. Straying too far in either direction—a cover that’s too close to the edge or too far away—ruins the illusion.

I got lucky with the corners. border-radius accepts <percentage> units, and percentage values are relative to their parent elements:

.item-dvd-cover {
    --aspect-ratio: calc( 7.50 / 5.30 );
    border-radius: 2.5%;
}

The inset—the black border separating the top, right, and bottom edges of the cover art from the edge of the case—is drawn using box-shadow. Unfortunately, box-shadow only supports <length> units like rem or vw, none of which are calculated relative to their parent elements.

This is the perfect job for a media query, right? Adjust the shadow offset at a few key breakpoints, bing-bang-boom. Well, not really. Consider two instances of the same DVD element, with the left viewport just 1 pixel wider than the right:

Two browsers showing the same DVD element at very different sizes

Because the width of each grid track can change dramatically with the slightest change in browser width, trying to ensure a proportional box-shadow offset using the viewport size alone is completely impractical.

I ended up using clamp with vw to help keep the inset from being too big or too small for the parent element:

.item-dvd-cover:before {
    --cover-inset: 0.1625rem;
    --cover-inset: clamp( 0.125rem, 0.15vw, 0.2rem );

    box-shadow:
        -0.20rem 0.20rem 0 0.20rem rgb( 20, 20, 20 ) inset,
        -0.20rem -0.20rem 0 0.20rem rgb( 20, 20, 20 ) inset,
        0 0.375rem 0.25rem -0.25rem rgba( 255, 255, 255, 0.33 ) inset,
        0.0625rem -0.0625rem 0.125rem rgba( 0, 0, 0, 0.5 ) inset
    ;
}

and it’s good enough for this project, but it still feels like a ham-fisted, imprecise approach for what I wanted to accomplish.

My takeaway after wrapping up Beyoncé Knolls: media queries are great for defining rules that specifically relate to the height or width of the viewport, but those dimensions can be a poor or wholly inaccurate proxy in other contexts. Or, as Ethan Marcotte said more eloquently:

We’re making design decisions at smaller and smaller levels, but our code asks us to bind those decisions to a larger, often-irrelevant abstraction of a “page.”

Container queries, it seems, would be a welcome tool to tackle problems of this genre. They wouldn’t outright solve the issue of building box shadows with offsets proportional to their parent elements, of course, but they would allow for more context-appropriate breakpoints than media queries could ever support.

CSS is Awesome

It really is ☕️.️ It’s a rich and complex language, and I’m in deep, genuine awe of people who have made careers out of learning to use it well.