CSS & Javascript: Flexbox Columns that Grow with Content

I've had a long standing problem with flexbox--particularly with flexbox columns. The proverbial Elephant in the Room has always been this: a mechanism for automatically letting content grow in one column (i.e., adding consecutive child items) and then overflow into the next doesn't really exist. Today, I look at a fix for this problem--and, all it takes is a little JavaScript.

Outlining the Problem

Let's look at flexbox columns and see what's at stake.

We'll start with a fundamental assumption: we have itemized content that we want to force into two columns. We could put that content into any number of HTML elements: <div>, <li>, <tr>, <td>, etc. In my case, however,  I'm working with a Drupal content type that allows users to attach an unlimited number of file references (PDF's) to a given node (page). Drupal renders links to those files in an array of <div> elements--something looking more or less like this:

<div class="field--items">
  <div class="newsletter"><a href="/some-file01.pdf">Download file number 1</a></div>
  <div class="newsletter"><a href="/some-file02.pdf">Download file number 2</a></div>
  <div class="newsletter"><a href="/some-file03.pdf">Download file number 3</a></div>
  <div class="newsletter"><a href="/some-file04.pdf">Download file number 4</a></div>
  <div class="newsletter"><a href="/some-file05.pdf">Download file number 5</a></div>
  <div class="newsletter"><a href="/some-file06.pdf">Download file number 6</a></div>
</div>

Note: Drupal can display file links like this a couple different ways; once you add a file field to a content type, you can change how Drupal renders the file(s) in the "Manage display" options. I've selected a "Generic file" format, which means that Drupal renders the files in <div> elements.

Now, my client knows that the number of PDF's on this node (page) might grow considerably over the course of the year. They'd also like not to have users scrolling way down the page in order to access links, so they and their designer have agreed that we should force the links into two columns.

At this point, some of you may be thinking css column-count. Under the right circumstances, column count might be a good way to handle this--child items fill out columns somewhat evenly and somewhat automatically (regardless of how many you add). Unfortunately, I've always found column-count a little too buggy, particularly in regards to a now legacy browser that need not be named. If I could have my way, we wouldn't waste time developing for such browsers, but client needs are client needs. Beyond this, in my experience, column-count is really best suited for paragraph text; once you start styling all the above out, you're bound to hit bumps. Flexbox, in my experience, is the better answer.

Flexbox - Better, but not Perfect

Unfortunately, flexbox columns don't have any native ability to allow content to overflow from one column into the next as content grows--at least, not that I know of. I'd like to be able to give my client full control over the page in question: no matter how many PDFs they add to the page, I won't need to tweak the CSS or HTML in any way. Flexbox rows can handle growing content, but they're not a natural choice since they order content left to right before top to bottom. I've labeled each item below with a <div> tag to emphasize how a linear DOM order gets translated into flexbox rows:

<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>

To order these items numerically in a flexbox row (top to bottom before left to right) takes a bit of HTML gymnastics; In the above, I'd literally have to play with the DOM order and swap <div>3</div> with <div>2</div>. In my case, Drupal can't readily handle that--and, either way, that can cause yet another headache once the items are flattened into a single column for mobile. You could play with the ordering in CSS, true, but I don't find that a particularly scalable solution; CSS is lacking in the kind of logic that would allow for an unknown number of items. Again, I don't want to touch the HTML or CSS once I give the page to my client.

What about Flexbox columns? Flexbox columns don't need reordering, and they transition to mobile quite well. The only problem is that you can only force multiple columns by giving the flex-container a minimum or fixed height (combined with flex-wrap). It's that height that says: "hey, no more flex-items will fit in this column--let's wrap them over to the next." Here's an example. I've given a fixed height of 50px to each of the flex-items below. I set the height on the flex-container to 100px, and I set flex-wrap to wrap; since each item also has a width less than 50%, once the first two flex-items fill that 100px the rest get pushed neatly into a second column:

<div>1</div>
<div>2</div>
<div>3</div>
<div>4</div>

However, if the number of flex-items can grow, let's say "exponentially", you'd spend a lot of time going in to adjust the flex-container height every time children are added. That's not a good solution, either!

It's almost like you need a mechanism to count how many flex-items there are on the page and then adjust flex-container height accordingly...

Enter JavaScript

It's not quite as simple as that, but JavaScript can certainly count elements and change the style of others accordingly. The logic isn't too difficult either. Let's get started with that. By the end, we'll have a script that will allow us to add an infinite number of flex-items and adjust the parent height so that they are always placed into two neat columns!

The first think we need to do is count the number of flex-items on the page. We'll need to reference that number so we'll put it into a variable:

var items = document.getElementsByClassName("newsletter").length;

/* getElementsByClassName() should build an array of objects with 
the class name "newsletters" -- length just tells me how many 
objects are in the array */

Now that we have a number, stored in our items variable, let's do some math. In my case, I want each flex-item to have a height of 50px--this is just enough space to let copy inside the flex-item wrap before I hit my first mobile breakpoint. Now let's think about the flex-container. For one to two flex-items, the flex-container would need a height of 50px to force the content into two columns; for three to four items, the container would need a height of 100px; for five to six, we'd need a height of 150px, and so on.

Since it doesn't really matter whether the total number of flex-items is odd or even, I can kind of get away with saying that container height is a function of the total number of flex-item height divided by two. Again, that's really only true for even numbers, but let's leave the odd numbers aside for a second. If I were to translate this into mathematical shorthand, it would look like this:

container height = (items * 50px) / 2

What about odd numbers? Well, technically, for odd numbers we don't actually need to mess with the container height--i.e., container height should be the same for one flex item as for two; the same for three as for four. Since the flex-container will treat odd numbers of items the same as it does even numbers, we can treat them the same in our logic. How do you make an odd number even? Add one to it. In order, then, for 3 items to translate to the same container height as 4, we just add one. We can create another shorthand equation that will let us make odd values the same as even values:

container height = ((items + 1) * 50px) / 2

We'll need to account for both of these equations in our code if we want container height to be the same regardless of whether the number of flex-items is odd or even. We can do that with the % operator. This operator outputs a remainder. Since all even numbers have no remainder when divided by 2, we can use it to verify whether the value of items is even; if it is, we'll use our first mathematical shorthand; if not, we'll use the second. Here's what the JavaScript will look like

// If the remainder of items divided by two equals zero (i.e., the value is EVEN)
if (items % 2 == 0) {  
  // Create a variable called "height" to store a pixel value for flex-container height.
  var height = (items * 50 / 2);  
/* Since anything with a remainder other than zero is odd, 
we don't need any more "if" logic, we can just jump straight 
to an "else"
} else {
  var height = ((items + 1) * 50 / 2);  
}
/* Barely broke a sweat!... but one last thing!--need to append "px" to the number value!
let's take the "height" variable, append "px" to it, and store that in a new variable */
var cssheight = height + "px";

Take note of that last line: whatever the numerical value we get from our math, we want to append "px" to it so we can pass it to the flex-container as a pixel value. I've chosen to store this appended value as a new variable: cssheight.

All that's left is to target the flex-container and change it's height. That's quite simple, actually. This should do the trick:

document.querySelector("field--items").style.height = cssheight;

// querySelector() will find the first element with a class of "field--items"

BUT!!!.... that's not really the end of it!

Mobile Considerations

That's right: it's 2021, and you're not really done until you've made your UI responsive (and Google friendly)...

(Disclaimer: I take care of mobile for clients, but I could care less about it here at the firstByte(). If you're reading a web development blog on a mobile device, you're probably a serial killer!)

... Afterall, why would I want two columns for a mobile layout? I wouldn't; neither would you. This JavaScript is pretty fancy and all, but it doesn't really account for the possibility that in some cases, we don't want two columns. How do we handle that?

In this case, letting JavaScript handle the styling of the flex-container precludes you, I think, from handling breakpoints on the style-sheet side of things. Luckily, we can create a JavaScript function to style the flex-container only if we match a specific breakpoint. I won't go too far into detail, but that would basically look like this:

// Create a function called desktop() since I only want to run on large screens
function desktop() {  
  // If the value of var x (defined below) matches
  if (x.matches) {  
    // Change height of flex-container 
    document.querySelector("field--items").style.height = cssheight;
  } else {
    // Otherwise, don't mess with the height! 
    document.querySelector("field--items").style.height = "inherit";
  } 
}
// Create variable x as query to match to
var x = window.matchMedia("(min-width: 960px)");
// Run function
desktop(x);
// Listen!
x.addListener(desktop);
// addListener() might be deprecated; use addEventListener() if appropriate

And there you have it. I deploy this JavaScript with Drupal's Asset Injector module, but feel free to implement however you like. Go ahead and play with the script on JSFiddle as well. Try adding or subtracting flex-items (<div class="newsletter"></div>) and see how the container resizes automatically to keep everything in line!