How to make responsive web apps with container queries

Discover how to transform a complex web app – with components, states and interactions – using container queries.

Responsive web design has been around for over five years now, and in that time we’ve seen a drastic shift in how we approach website development. Media queries let us write CSS based on a variety of conditions, such as resolution, media type and – most popularly – browser viewport dimensions. Design patterns have emerged to help us manage the complexity of building websites that respond to a range of different screens.

Brad Frost conveniently maintains a compilation of patterns that you can use for your sites. Generally speaking, these patterns target content based on the full width of the browser’s viewport. In other words, they target elements at a macro level, not at a micro level. On Shopify.com, for example, each band of content is dealt with separately and collapses well on smaller devices. From a design perspective, it’s easier to manage the design for these bands. From a coding perspective, it’s easier, too.

Breakpoints

You can either create breakpoints at a global level or at band level. At a global level, it’s not uncommon to see mobile, tablet and desktop breakpoints defined and for the developer to choose how each element will respond within those specific breakpoints. At a band level, you have a bit more control over when each content piece should break. Maybe that two column piece should collapse into a single column at 400px, but that three-column piece should collapse into a single column at 500px.

While we have a bit more control when dealing with breakpoints at a band level, we’ve also made things more complex from a design perspective. Most designers working in Sketch or Photoshop will create artboards for a few common viewports: likely portrait mobile, portrait tablet and desktop.

When dealing with breakpoints at a band level, the designer needs to move away from this limited number of views and start breaking things down into smaller chunks. Yet all of these bands still need to be presented as cohesive designs, in traditional mockups.

Complex web apps

With many art-directed, content-based sites, the patterns are presented in a limited number of scenarios. Web apps, on the other hand, use smaller patterns across a wider array of contexts. A button group might be displayed in a header, the main body, a sidebar or a modal.

Each band – the header, intro and content – can be responsively designed independently of each other and without worrying about context

Now consider all of these contexts multiplied by the variety of viewports on which these pages might be displayed. We’ve gone from three possible views (mobile, tablet, and desktop) to hundreds of possibilities. With media queries, we have to use a number of context-based selectors to handle these scenarios.

.buttongroup .button { … }
.header .buttongroup .button { … }
.modal .buttongroup .button { … }
.sidebar .buttongroup .button { … }

@media (min-width: 600px) {
.buttongroup .button { … }
.header .buttongroup .button { … }
.modal .buttongroup .button { … }
.sidebar .buttongroup .button { … }
}

/* and so on for a couple pages */

Many web apps need to present similar components across varying layouts of different sizes. For example, an autocomplete component might be presented differently in a wide main column than in a smaller side column.

Enter container queries

Media queries can only tell us what the width of the viewport is or the width of the document. Therefore, if we want to change how a component looks in a sidebar when the sidebar is larger than 400px, we have to know that the sidebar will be 400px wide when the viewport is greater than 1000px wide. Container queries, on the other hand, allow us to say ‘this is what my component should look like when it has more than 400px of space’.

Before you get too excited, allow me to disappoint you quickly: there is no browser implementation for container queries. Worse, there is no specification for container queries (cue sad violins). The reason no specification has sprung up yet is because of the circular logic that can be introduced.

Let’s take a look at a quick example:

/* mythical :container syntax */
.container {
 float: left;
}
.child {
 width: 500px;
}

Here, the child element pushes the size of the container to 500px wide. Next, we add a container query to apply conditional styling when the container is greater than 450px wide.

.container:container(min-width:450px) > .child {
 width: 400px;
}

Notice, however, that we set the child element to 400px wide. That means our container query no longer applies, as the container will shrink to 400px. Since the container query no longer applies, we fall back to the original declaration of 500px. This pushes the width of the container back out to 500px and the container query applies again – and so on until the browser crashes. It’s not yet clear how to solve circular logic in container queries. 

Shopify solution

With no specification in place, we have to solve the problem with JavaScript. At Shopify, we chose to hand-roll our own solution. We created a custom script that would find our components on the page and could conditionally apply ‘responsive’ classes when the width conditions applied.

For our needs, width was the only consideration we worried about. This is by far the most popular property that is considered when building responsive designs, and some scripts only detect width . Of course, having access to query other properties like height can allow us to create more flexible and powerful designs, but this also increases the complexity.

To define the queries, we created an array of JSON objects that allowed us to quickly select the elements we wanted, then test whether the parent element was greater than the min-width or less than the max-width . If it was, then we applied the responsive class to the element:

elements = [{
 "component": ".flex – 2x1",
"className": "flex-2x1-responsive",
 "minWidth": 768,
 "maxWidth": 1024
 },{
 "component": ".modal",
 "className": "modal-responsive",
 "maxWidth": 1024
 },{
 "component": ".buttons",
"className": "buttons-responsive",
 "minWidth": 300
 }];

The ‘component’ name was a selector – almost always a single-class selector, for simplicity’s sake. We used a naming convention to easily identify our components and continued that naming convention through to the responsive classes that were applied. Those responsive classes would be defined in the same file as the rest of the component. If you’re familiar with Scalable and Modular Architecture for CSS (SMACSS), then these responsive classes behave much like states.

Right - Typical layout - A two-column layout using Flexbox. The content stretches to fill the column it’s in. Left - Lining up - When space is tight, Flexbox automatically reflows the right column underneath the left column, and the content grows to fill it

Moving fast

For complex applications that have a large collection of components, it’s great to be able to focus on a single component with little concern for the context in which it’s used. With a deadline looming, the team was able to divide up the work by tackling each component separately. How would the tabs work? How would the header work? How would the layout work? Each team member could grab a piece, address its concern, create a pull request, and then move on to the next component. This allowed us to take a web application and make it responsive in under a month.

...and not so fast

Going responsive in under a month? Holy cow! Why isn’t everyone using container queries and building responsive applications in such a short period of time? Truth is, much of the work took place at design level, which happened months prior to the Shopify Admin going responsive.

Having a design team that was considering the impact of their work knowing that we’d eventually go responsive saved us a lot of work. Some components – like tab overflow – needed consideration well before going responsive, and a JavaScript-based solution that didn’t use container queries was built separately. 

In my recent time at Xero, for example, the design team was going through this process. It takes time to consider every component under every context, and more time to build the component. If the designers aren’t thinking this through then it’ll be the engineers doing it. I don’t say that disparagingly. The work needs to be done regardless, and the sooner, the better.

Furious JavaScript

The loop for finding elements and applying conditional classes would be executed many times:

  • Every time the page loads
  • Every time the browser resizes
  • Every time an interaction affected document flow
  • Every time content was dynamically injected

The more components you have that need conditional styling, the more work JavaScript has to do. Any of these events could create a noticeable lag. The page loads, and then a fraction of a second later a discernible shift occurs when the classes are applied.

Avoiding queries altogether

Over a year on from when Shopify initially implemented container queries, the team is slowly moving away from using them, choosing other techniques to solve the problem. So if container queries are too taxing for now, what can we do without them? As it turns out – thanks to Flexbox – we have options.

The biggest challenge when it comes to responsive design is how to handle multi-column content and reflow it in a sensible way. Let’s take a look at two common examples.

Two-column offset design with side navigation

Shopify has a number of screens like this: there is a side navigation and then a content area that features two columns, one larger than the other. If the two columns were both the same width, then we could just float the columns. When the page could no longer fit both side by side, one column would fall under the other.

However, with offset sizes, simply floating one column under the other would look weird. As you scroll down, you’d notice the content suddenly didn’t stretch to the full width of the container.

With Flexbox, we can define a min-width to our columns and a flex-wrap: wrap to the container. When the columns hit their minimum, one will flow under the other. The best part is that when it wraps, each column will now stretch to the full width of the container.

<div class="two-columns">
 <div class="col1"> … </div>
 <div class="col2"> … </div>
</div>

.two-columns {
 display: flex;
 flex-wrap: wrap;
}

.col1 {
 flex-basis: 66%;
 min-width: 360px;
}
.col2 {
 flex-basis: 33%;
}

By setting min-width on the primary column, we essentially define a breakpoint based on that column, allowing the column next to it to flow under when there’s no longer enough room.

The Media object with actions aligned to the right on larger screens

Media object with actions

The media object defines a very common pattern: image on the left, with descriptive text to the right of it. This pattern commonly needs to be augmented with a third piece: right-aligned actions.

Flexbox combined with our media object allows it to reflow the actions under the content
<div class="media">
 <img src="…">
  <div class="media-body">
   <div class="media-content"> … </div>
   <div class="media-actions">
    <a href="…"> … </a>
   </div>
  </div>
</div>

.media-body {
 display: flex;
 flex-wrap: wrap;
}

.media-content {
 flex-grow: 1;
 flex-basis: 400px;
}
.media-actions {
 align-self: center;
}

We’ve augmented the body of the media object with two containers: one for the content and one for the actions. Like the last example, we set flex-wrap to wrap so when we run out of room, the second container will wrap onto the next line. When the content container reaches its minimum width, set using flex-basis , the actions will flow to the next line. The actions, however, will now be aligned to the left with the content, instead of to the right.

Fill 'er up

As you can see from these two examples, the best approach to building a responsive site is one where you don’t have to create a bunch of media queries in order to manage the design across different viewports. Take advantage of the fluidity of the web by allowing the components of your design to naturally fill the containers they’re in. Avoid specifying fixed widths and heights that will make your site unduly brittle.

The future

You’ll need to evaluate your project to decide whether using a JavaScript solution to implement container queries on your site is ideal. At Shopify, it allowed us to move quickly and we accepted the performance drawbacks in the beginning. Getting a native implementation of container queries would be ideal, but we’ll need more advocacy and deep thinking about how to solve the performance and circular issues before we see progress.

The ResizeObserver (github.com/WICG/ ResizeObserver), if introduced into the DOM, could provide an easier and possibly more performant implementation in JavaScript. To get involved with the future of container queries, check out the Responsive Issues Community Group (RICG).

This article was originally published in net magazine issue 285. Buy it here.

Related articles

ABOUT THE AUTHOR

Jonathan Snook is a designer and developer from Ottawa, Canada. He writes about tips, tricks, and bookmarks on Snook.ca and has written the successful, self-published book, Scalable and Modular Architecture for CSS.