Hajime, the duck guy

Monday, March 27, 2023, by Hajime Yamasaki Vukelic

Responsive columns without media queries.

This article is based around the method described first in a 2021 article by Jason Knight. I remember I was super-excited when I first read it. Then I was very disappointed when I discovered that it doesn't "just work" for something that isn't quite like what's in the article. And then, luckily, I decided that I just don't know enough flexbox to make it work. After having made it work quite a few times, I'm comfortable enough to say that it's, indeed, a reliable and flexible method that "just works".

Before I begin, let me set your expectations first. You will not see any prepackaged classes that you can just slap on your tags and call it done. That's not how I do CSS and I'm generally opposed to the whole class-based CSS approach. It seems like a quick fix, but it stops being quick as soon as you start overriding the classes in the OOP inheritance style. In the examples below, we will apply CSS the proper way, by choosing selectors based on the elements and applying the selectors to the CSS declaration blocks, the way it was intended.

Web is responsive by default

I think this cannot be repeated often enough.

If you remove all CSS from your page, it is fully responsive and just works on any screen. Therefore, if anything breaks on some screen, it's your CSS. Period.

Jason's method of using flexboxes to create adaptable layouts emulates the way web pages respond to screen size changes by default. It allows the elements to reposition and change size based mostly on the content size without additional CSS, and does not require any media queries. This is in contrast to the prevalent method of doing responsive layouts using a combination of predefined breakpoints with additional CSS rules for each breakpoint.

I like to call this method "responsive-first" because it sounds fancier that way and who doesn't like a fancy name. Not that it matters. 🤣

Anyway, without further ado, let's get started.

Column container

All of the layouts discussed below use the following declarations for the container:

{
  display: flex;
  flex-wrap: wrap;
}

The general idea behind flexbox wrapping is that the flex children will wrap into the next row once they can no longer fit the parent. We use fixed widths and min-width to force wrapping at a certain width — wrapping width. Unlike media queries, the wrapping width a the characteristic of the column rather than the screen.

There is something delightful about having only two lines of code that unlocks so much untapped power. We can add more if we like, though. There are several things that we can tweak depending on the desired presentation.

The gap between column is controlled using the gap property. Note that the gap property is a shorthand for row-gap and column-gap properties (in that order when two values are specified), so we can specify different gaps vertically and horizontally. There is no need to use margins for the spacing between the columns.

We can also give the container padding as usual, giving us more control over the space around the content.

Please note that by default align-items property has the value of stretch. This will cause all columns to assume the height of the tallest column. If you want columns of different heights, use flex-start.

We can use the justify-content property to control the overall alignment of the columns as they wrap to the next line. Setting it to flex-end will cause the newly-wrapped columns to be right-aligned (on ltr writing direction), while setting it to center will cause them to wrap to the middle.

Although we can technically change both the order of the columns and the order in which they will drop to the next line, I stay away from such reordering for accessibility. It will screw up the expected tab order for the keyboard users.

Everything else is controlled using the properties on the child elements.

Columns can themselves be column containers, and these layouts can be nested infinitely with relatively predictable behavior.

All fixed columns

The first example is a set of centered fixed-width columns. This is a common pattern used for cards on homepages. The HTML for this example looks like this:

<section id="cards">
  <article>Card 1</article>
  <article>Card 2</article>
  <article>Card 3</article>
</section>

We use a gap of 1em between the cards, and we center-align them using justify-content: center.

Here's a working demo of the code.

As we change the viewport size, the cards drop down one by one until we eventually end up with a single-column layout.

A slight variation of this layout would be to distribute the spacing around the columns justify-content: space-around. Again, I'm providing you with a demo for this variation.

All flexible columns

Another common pattern is to use multiple flexible columns. This layout has all kinds of different use cases, from basic two equal-column layout to user-configurable multi-column layouts like in task list apps.

<article id="file-manager">
  <div id="folder-tree">
    Folder tree
  </div>
  <div id="left-pane">
    Left pane
  </div>
  <div id="right-pane">
    Right pane
  </div>
</article>

We will use a three-column layout for a supposed two-pane file manager. We will use a layout that gives more space to the two panes, and a bit less to the folder tree.

#file-manager {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5em;
}

#folder-tree {
  flex: 1;
  min-width: min(100%, 10em);
}

#file-manager > :not(:first-child) {
  flex: 2;
  min-width: min(100%, 20em);
}

Unlike the fixed column width examples, we are using the flex to control the relative column widths, and min-width to control the wrap width separately. In this example, the first column, #folder-tree will be half the width of the other two columns. The min-width value is the width at which the column will wrap to the next line.

We use the min() formula to ensure that the minimum width never exceeds 100% of the parent. If the container width becomes less than the specified em value, then the container width is used instead of the em value causing the column to never exceed the container width. This is something we use everywhere where we are setting the min-width of the columns.

Here's the demo.

As we change the viewport size, the panes drop down one by one until we end up with a single column layout.

Outer-wrap-first nested column layout

In the previous example, we may actually prefer the panes to wrap together first, and then wrap individually. To achieve this, we can create a outer-wrap-first nested flexible column layout.

The "outer-wrap-first" refers to the fact that the outer layout will wrap before the nested one(s). We will also look at the "inner-wrap-first" layout later.

We will first modify the HTML so that panes are grouped inside a single element:

<article id="file-manager">
  <div id="folder-tree">
    Folder tree
  </div>
  <div id="panes">
    <div id="left-pane">
      Left pane
    </div>
    <div id="right-pane">
      Right pane
    </div>
  </div>
</article>

The CSS will then look like this:

#file-manager,
#panes {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5em;
  height: 100vh;
}

#folder-tree {
  flex: 1;
  min-width: min(100%, 10em);
}

#panes {
  flex: 4;
  min-width: min(100%, 40em);
}

#panes > div {
  flex: 1;
  min-width: min(100%, 20em);
}

The #panes element is a column containers as well as a column. The #panes is also a flexible column. We set the wrap width of the #panes element to the cumulative wrap width of its child columns, 40em. We also set the relative width to 4, again the cumulative relative width of the nested columns.

Here's the demo for this code.

As we change the viewport size, the two panes first drop down together, and then the right pane wraps to form a single-column layout.

Inner-wrap-first nested column layout

In the previous pattern, the two panes drop down together first, and then wrap. We can also make them wrap internally first, and then drop to the next line. I call this pattern the "inner-wrap-first" nested layout because the inner columns wrap first.

We are using the same HTML as with the plain nested layout.

<article id="file-manager">
  <div id="folder-tree">
    Folder tree
  </div>
  <div id="panes">
    <div id="left-pane">
      Left pane
    </div>
    <div id="right-pane">
      Right pane
    </div>
  </div>
</article>

The CSS is modified like so:

#file-manager,
#panes {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5em;
}

#folder-tree {
  flex: 1;
  min-width: min(100%, 10em);
}

#panes {
  flex: 4;
  min-width: min(100%, 20em);
}

#panes > div {
  flex: 1;
  min-width: min(100%, 20em);
}

The only change compared to the outer-wrap-first is that we now use a 20em wrap width on the #panes element. This matches the 20em width of the panes, and ensures that the #panes can shrink enough to trigger the vertical layout of the panes.

Here's the demo.

As we reduce the viewport width, the columns inside the #panes wrap first to form a vertical layout, and then the #panes itself drops down into the next row to form a single-column layout.

Mixed fixed-flexible columns

A mixed fixed and flexible column layout has one or more fixed-width columns alongside flexible columns. A common use case for fixed-width columns is sidebars and toolboxes.

<main>
  <section id="dashboard">
    Dashboard
  </section>
  <section id="content">
    Content
  </section>
  <div id="toolbox">
    Toolbox
  </div>
</main>

In the above HTML, the we want the toolbox to be fixed width, and the other two columns to evenly fill the remaining space. Thus the CSS looks like this:

main {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5em;
}

#dashboard {
  flex: 1.5;
  min-width: min(100%, 30em);
}

#content {
  flex: 1;
  min-width: min(100%, 20em);
}

#toolbox {
  flex: 0.001;
  min-width: min(100%, 10em);
}

For this layout, the flex determines the ratio of the column width within the layout, except for the fixed-width column which is always 0.001. The reason we use the small-but-still-not-zero flex-grow value for the fixed columns is that we do want them to grow to full width if they are the only column in the row, but we don't want them to mess up the ratios used by flexible columns (since it's a non-zero number, it will still take up some of the remaining space, but we keep that to the minimum). The flexible columns are styled the same way as with normal flexible column layouts we've seen before.

Here's the demo.

As the viewport size changes, the toolbox column drops to the next row and expands to full width. It is followed by the content column, to form a single column layout.

To make the column truly fixed we adjust the #toolbox styles as follows:

#toolbox {
  flex: none;
  width: 10em;
  max-width: 100%;
}

We give the toolbox a flex: none and replace min-width with width. We also add max-width to give it the same behavior as the min(100%, 10em).

Here's a demo of the variation.

The mixed layout can also be combined with the nested layout styles that we discussed earlier.

Conclusion

As far as basic layout goes, these patterns should more than cover the most common (and not so common) cases.

The short of it is that we declaratively specify the wrapping behavior instead of imperatively handling specific breakpoints using media queries (media query is basically an if block). It greatly reduces the amount of code we use for layouts.

More importantly, these layouts are far more robust than the layouts that rely on fixed breakpoints. This is especially true when compared to media queries that use pixel dimensions. With fixed breakpoints out of the picture, we can focus on the actual content layout and let that dictate the wrapping.

Does this mean we can say goodbye to media queries? Well, not quite. There's always going to be a little something that we use media queries for. Maybe we want to remove the container's padding when we reach a single-column layout, or we would like a 3-column layout to immediately collapse into a single-column one. Media queries also handle things other than viewport sizes, like a preference for less animations and similar media features. However, we can get rid of a big chunk of media queries as column layouts are currently one of the CSS' most media-query-heavy aspects in common practice.

Posted in Programming tips
Back to top