Column-filled grids: the issues

Previous post

Next post

CSS Day tickets: normal or late-bird? Not yet published

About this site and me

Blog homepage RSS feed Mastodon Bluesky About me

This blog uses a new RSS feed. Please update the old QuirksBlog feed you used to follow.

Tag archives

Archives (1) CSS (4) Conferences (4) Personal (2) Safari (1) Site (2) Thidrekssaga (1)

Monthly archives

May 2026 (3) April 2026 (9) 2004-2021 blog

In part one I created a technique to fill a grid column by column instead of row by row, while still setting a maximum number of columns. It works, but is brittle. Here we’ll discuss why it is brittle, and why lack of children-count() makes a theoretically superior technique for doing the same impossible.

The technique

The sidebar on my blog pages contains a list of tags. I want to first fill the left column alphabetically, then continue with the next — like an index in a book.

I use the following CSS:

.columnGrid {
	--size: 150px;
	--padding: 0.5em;
	display: grid;
	container-type: inline-size;
	grid-template-columns: repeat(auto-fit,minmax(var(--size),1fr));
	padding: var(--padding);
	
	@supports (order: sibling-count()) and (order: calc(1cqw/1px)) {
		grid-template-columns: 1fr;
		grid-auto-columns: 1fr;
	}
	
	& > * {
		--gridWidth: calc(100cqw - var(--padding) * 2);
		--maxColumns: round(down,calc(var(--gridWidth) / var(--size)));
		--rows: round(up,calc(sibling-count() / var(--maxColumns)));
		--row: calc(mod(calc(sibling-index() - 1),var(--rows)) + 1);
	
		grid-row: var(--row);
	}
}

It works, but it has issues. Let’s talk about them.

Issues

Consider an item with display: none. In a regular grid it’s simply ignored. In my technique, however, it messes up the calculations.

Despite being hidden, the item is in the DOM, and counts for sibling-count() and -index(). Thus, the number of rows can be off. In the second example six items are visible, so it should have three rows. Instead, it has four because sibling-count() still counts seven siblings.

Worse, my technique assumes that the hidden item takes the row 2 / column 1 cell. But it doesn’t, and item 6, which is supposed to go in row 2 / column 2, goes into column 1 instead. That breaks the alphabetical order rather dramatically.

Something similar happens with a colspan. There is no way of detecting that 'CSS' now takes up two columns instead of one, and my technique breaks and the alphabetical order is off again.

CSS Grid by itself does a decent job of handling both situations. But I gave a bunch of absolute commands: this item should go there. I took away Grid’s ability to adjust, to compensate for problems, to balance things out.

I made myself responsible for handling these tricky situations — and I can’t. It’s not possible to say "don’t sibling-count an item that has display: none" or to correct sibling-index/count() for colspans and rowspans.

Working with the grid

CSS Grid does in fact have the tools to create a much more robust technique that handles the edge cases much better. I realised that after a day and a half of work, and decided to switch to a simpler, better technique.

By default, CSS Grid looks at the number of available columns and then creates enough rows to hold all the items. We as web developers only have to set the number of columns, and we’re in business.

This behaviour is caused by the declaration grid-auto-flow: row. It sort-of means "if you need more space, we’d prefer that you add rows." We tend to leave it alone because this is how we expect grids to work.

But there’s also grid-auto-flow: column. Now CSS Grid looks at the available number of rows and then creates enough columns to hold all the items.

.columnGrid.withTheGrain {
	grid-template-rows: repeat(3,auto);
	grid-auto-flow: column;
	grid-auto-columns: 1fr;
}

This, I realised, is the solution. CSS Grid has all kinds of clever defaults to paper over our issues. We just have to allow it to do its job.

We shouldn’t give CSS Grid a bunch of detailed orders, but hand-wavingly tell it to lay out these items column-wise as well as it can.

In the quick proof-of-concept I made, the one shown here, I hard-coded the three rows and it worked fine.

I still wanted to set a number of columns, though, and use the variables to calculate the number of rows. No problem, right? I already wrote that calculation, right? I just have to plug it into the new grid code, right?

.columnGrid.withTheGrain {
	--gridWidth: calc(100cqw - var(--padding) * 2);
	--maxColumns: round(down,calc(var(--gridWidth) / var(--size)));
	--rows: round(up,calc(sibling-count() / var(--maxColumns)));
	/* We don't need --row */

	grid-template-rows: repeat(var(--rows),auto);
	grid-auto-flow: column;
	grid-auto-columns: 1fr;
}

Wrong.

CSS variable scope

CSS variables are scoped to the context in which they are evaluated. And we just changed that context. That has consequences.

If you’re not sure what that means, concentrate on sibling-count(). Whose siblings are we counting? Whose siblings should we be counting?

Well, we should be counting the siblings of the grid items. That gives us the total number of items we need for the calculation. The original technique did so.

.columnGrid {
	display: grid;
	
	& > * {
		--gridWidth: calc(100cqw - var(--padding) * 2);
		--maxColumns: round(down,calc(var(--gridWidth) / var(--size)));
		--rows: round(up,calc(sibling-count() / var(--maxColumns)));
		--row: calc(mod(calc(sibling-index() - 1),var(--rows)) + 1);

		grid-row: var(--row);
	}
}

The individual grid items used the variable --row. Thus, --row is evaluated in the context of a grid item, as are all the variables it depends on. In particular, sibling-count() counts the grid item’s siblings, and sibling-index() yields the grid item’s index.

But the new technique changes that:

.columnGrid.withTheGrain {
	--gridWidth: calc(100cqw - var(--padding) * 2);
	--maxColumns: round(down,calc(var(--gridWidth) / var(--size)));
	--rows: round(up,calc(sibling-count() / var(--maxColumns)));
	/* We don't need --row */

	grid-template-rows: repeat(var(--rows),auto);
	grid-auto-flow: column;
	grid-auto-columns: 1fr;
}

Now the grid container uses the variable --rows, and it, as well as the other variables it depends on, are evaluated in the context of the grid container. In particular, sibling-count() now counts the grid container’s siblings.

And that’s wrong. I don’t care how many siblings the container has, but that useless number is force-fed into our formula and yields gibberish. Garbage in, garbage out.

.columnGrid.withTheGrain {
	--gridWidth: calc(100cqw - var(--padding) * 2);
	--maxColumns: round(down,calc(var(--gridWidth) / var(--size)));
	--rows: round(up,calc(children-count() / var(--maxColumns)));
	/* We don't need --row */

	grid-template-rows: repeat(var(--rows),auto);
	grid-auto-flow: column;
	grid-auto-columns: 1fr;
}

In the new context we don’t need sibling-count(). Instead, we need children-count(). That would give us the number of grid items, and our formula would work once more.

But there’ a tiny problem with children-count(): despite developer pressure it doesn’t exist. Well, an issue exists, and since I take issue with its lack of existence I’m going to add a comment.

There is a second problem: finding the width of the grid container. In the original version I could made it a queryable container so I could use a simple 100cqw in the context of the grid item. But due to the change of evaluation context I now have to measure the width of the element from the context of that same element, and container queries don’t do that. Temami Afif solved that problem, but if I were to use his technique I would have to fully understand it — that’s my rule for this blog. I’m not sure if I want to go on yet another long digression, so I’m secretly relieved I don’t have to.

That’s why we can’t use the superior version without children-count(), and we’re left with the brittle version that works in simple cases but will fail in more complicated ones. It’s a pity, but it cannot be helped.

My use case is really simple: I just want a simple grid with no hidden or colspanned items, no complicated. That will work. If you want the same, hey, use the original version. If you want the better version, wait for children-count().