Container Queries & The Future of CSS

@ Front Range Front-End

I’m here to talk about the future of CSS, but in order to understand where we’re going 

we need to understand where we are 

And how we got here.

In my mind CSS exists for two primary reasons

First, CSS makes styles responsive 

and not just to dimensions of a viewport 

But user preferences 

and device interfaces.

When Sir Tim and the team at CERN released the first hypermedia browser It was designed for the NeXT machine, with a fancy graphic interface

But you can’t make a web that’s “world-wide”, by saying “it works on my machine” and everyone else is an “edge-case”.

So right away they release a second browser, designed to work on any terminal with an internet connection.

This becomes the mission statement of the web – web for all, web on everything.

That includes assistive devices 

And non-visual media – always with the end-user in control of the outcome.

Provide hints that the browser may or may not use.

– Håkon Lie

We provide hints and suggestions, semantic clues, but only the browser can put it all together.

And the cascade describes that process 

An ordered list (cascade) of style sheets … can be referenced from the same document.

– Håkon Lie

by accepting style sheets from everyone involved 

The user/browser specifies initial preferences and hands the remaining influence over to the document.

– Håkon Lie

Browsers & users establish global defaults and preferences across the web, and then we fill in the details of our particular site

Cascade Origins

  • 🎨 Author (Document)
  • 👥 User Preferences
  • 🖥 User Agent (Browser)

These are the primary “cascade origins” – each one representing a different set of needs and concerns, different perspectives, sometimes in conflict.

The rules of cascade & inheritance describe how to merge all three, and resolve any conflicts.

  1. 🎨 Author (Document)
  2. 👥 User Preferences
  3. 🖥 User Agent (Browser)

By default user preferences override the browser defaults, and (for better or worse) we’re allowed to override everyone.

If conflicts arise the user should have the last word, but one should also allow the author to attach style hints.

– Håkon Lie

But when things really get heated, when it really matters, the user and browser can insist 

That some styles are more important than others 

  1. ❗🖥 User Agent Important
  2. ❗👥 User Important
  3. ❗🎨 Author Important
  4. 🎨 Author Styles
  5. 👥 User Preferences
  6. 🖥 User Agent Defaults

Creating important origins that cascade in reverse order: Important author styles aren’t that special – that’s us in the middle – but users can override us when they need to, and the browser finally decides what’s out of bounds, what’s possible on this device, and what features are supported in what ways.

The second goal of CSS is to make our design objects reusable…


Instead of repeating the same styles over and over in our HTML

We can use selectors to apply styles broadly 

We use Classes & Attributes

Creating patterns based on things like classes and attributes 

Which we can combine, to compose re-usable objects 

– Design Systems & Component Libraries.

CSS is object-oriented to the core, but it is also declarative, contextual, and resilient – features of the cascade. CSS is cascade-oriented.

💥 Conflict!

button          { background: gray; }
.action { background: darkblue; }
[type=“submit”] { background: darkgreen; }
#send { background: maroon; }

And selectors create another potential conflict for the cascade to resolve. Since we can use multiple different selectors to target the same element 

Selector Specificity

The cascade needs to determine a winner, using a clever heuristic called specificity – based on how narrowly a selector has been targeted. Again, each selector type represents a different goal.

The most generic selectors, help us paint in broad strokes to establish low-priority defaults 

Classes and attributes allow us to describe higher-priority patterns, and make up the majority of our styles 

Then one-off ID’s are both the most narrowly targeted, and the highest priority.

  1. Unique #IDs
  2. Reusable .classes & [attributes]
  3. Element types
  4. Universal *

One ID will always override any number of attributes, and on down the list. It’s not perfect, but it’s an approximation of the layers in our code.

Until things get complicated…

As our projects become larger and more complex with more distributed teams and third-party integrations, there are a lot of situations that don’t fit the rule 

table[rules]:not([rules=""])> tr > td,
table[rules]:not([rules=""])> * > tr > td,
table[rules]:not([rules=""])> tr > th,
table[rules]:not([rules=""])> * > tr > th,
table[rules]:not([rules=""])> td,
table[rules]:not([rules=""])> th

border-width: thin;
border-style: none;

Some low-priority defaults are very specific 

While some generic attributes, really ought to have more weight behind them.

  1. Unique #IDs
  2. Reusable .classes & [attributes]
  3. Element types
  4. Universal *

And out of all these selectors, there’s only one that we can both customize and reuse. Classes & attributes.

.block .element.modifier { /* 3 */ }
.block__element--modifier { /* 1 */ }

So we spend a lot of our time fighting over how many attributes should be in a selector 

With rules and conventions to ensure our cascade specificity 

matches carefully crafted layers of intent – building up from global abstractions to components and overrides.

And throwing importance like a grenade when we get stuck,

.bootstrap-thing {
font-weight: bold !important;

Or if one part of the system doesn’t play by the same rules.

So that brings us to our first new feature: Cascade Layers. Jen Simmons and I suggested this at the end of 2019, it was approved by the CSS Working Group last February, and I expect browsers to start implementing it this year.

Very similar to CSS origins, we’re again creating layers that represent different perspectives, from different parts of a system, and potentially different teams on a project.

Stacked in Layers

  1. Components?
  2. Themes?
  3. Frameworks?
  4. Resets?

But we get to define these layers ourselves, as authors – for things like resets, defaults, frameworks, themes, components, utilities – anything we want, in whatever order we need.

  1. Important Resets
  2. Important Themes
  3. Important Components
  4. Components
  5. Themes
  6. Resets

And the important flag works as intended, when it becomes necessary for a lower layer to insist on something, and punch above it’s weight. But we’re not actually adding new origins here, so it may be better to think of them as customizable layers of specificity.

@import url(headings.css) layer(default);

We can define a layer, give it a name, and add styles to it using either a layer function on the import rule 

@layer default {
audio[controls] { display: block; }
[hidden] { display: none !important; }

Or by nesting styles inside the new at-layer rule 

@import url(headings.css) layer(default);

@layer default {
audio[controls] { display: block; }
[hidden] { display: none !important; }

– Or both. Here we’re creating a “default” layer with the headings.css import, and using the at-rule to add a few more styles to the same “default” layer.

@layer default { /* lowest */ }
@layer theme { /* higher */ }
@layer components { /* highest */ }

Layers stack in the order they were first defined, with the highest layer taking precedence, no matter what specificity is used for the selectors inside. Specificity only matters inside each layer.

@layer framework {
.menu > .dropdown > .item {
background: whitesmoke;

@layer override {
.my-menu-item {
background: lightcyan;

In this case, the override layer wins even though the selector inside has a lower specificity.

@layer default { /* … */ }
@layer theme { /* … */ }
@layer components { /* … */ }
@layer default { /* … */ }
@layer components { /* … */ }

But we don’t need to keep all our styles in that order. Once a layer has been established, we can add to it from anywhere in our code. The priority is based on when the layer name first appears.

@layer default;
@layer theme;
@layer components;

@layer default {
* { box-sizing: border-box; }

We can even use the at-layer rule with only a name to establish our order up front, so we don’t have to worry about the actual code order 

@layer default, theme, components;

@layer default {
* { box-sizing: border-box; }

And there’s a shorthand syntax to make that even easier, using a comma-separated list of layer names.

@layer default, tools, theme, components;
@import url(tools.css) layer(tools);

One of the goals here is to make it so that we as authors get to define exactly where third-party tools belong in our layering. No matter what specificity those tools use internally, or whatever layers they create, we can always override them without resorting to specificity hacks.

/* tools.css */
@layer theme { /* … */ }
@layer components { /* … */ }

And this also gives framework or component-library authors a way to provide layers we can hook into 

@import url(tools.css) layer(tools);

@layer tools.theme { /* … */ }
@layer tools.custom { /* … */ }

Either directly, or by wrapping those layers into a contained namespace. We can create or access “nested” or “name-spaced” layers using a dot-notation to combine the names.

@layer tools {
@layer custom {
/* tools.custom */

Or we can actually nest the layer rules.

This gives us a lot more control over our corner of the cascade, so we’re not totally reliant on selector specificity and code-order to determine what takes precedence.

Hopefully allowing us to replace all our specificity & importance hacks with more clearly defined patterns.

Unlayered styles Default Lowest Priority

Of course, we don’t have to put all our styles in a layer – and for the sake of progressive enhancement, we likely want to start adding layers slowly.

Un-layered styles will work the same way they always have, and belong to an implied “base layer” below all the others.

This is actually a recent change – we used to have unlatered styles at the top – but we think making them a base helps match expectations & improve the upgrade path.

But there is also discussion now about making it adjustable. You can check out the CSSWG issue if you’re interested in that conversation.

The next feature is also about how selectors work. With “scope”, we’re trying to address two issues that come up regularly, and drive people to use tools & conventions like BEM syntax or CSS-in-JS.

1. Avoid Naming Conflicts

(across large teams & projects)

The first goal is to avoid naming conflicts as our projects grow.

2. By Expressing Membership

(through lower boundaries & proximity)

Which we can solve by focusing on our second goal: expressing “membership” or “ownership” in our selectors.

.title { /* global */ }
.post .title { /* nested */ }

While nested selectors might seem like a way to express membership – in this case a title that is inside a post 

.title { /* global */ }
.post .title { /* nested */ }

.post__title { /* BEM */ }

That’s not quite the same thing as a post-title. The first one only describes a nested structure, but the second describes a more clear membership in a component pattern. Not all the titles in a post, just the title that belongs to the post.

.post__title { /* BEM */ }
.title[data-JKGHJ] { /* Vue */ }

We don’t have a good way to convey that using our current CSS selectors, unless we invent a new unique name for every kind of title, based on what it belongs to – either manually using a convention like BEM, or automated with JavaScript compilers.

And if we want some global title styles, we end up using multiple classes – and hoping the more targeted pattern will override the global pattern.

wireframe of a site, with multiple nested components

Another way to think about this is to say that some components have lower boundaries – the component itself is a “donut” with a hole in the middle for content. We should be able to style a tab component, or a media-object, without worrying that we might accidentally style everything inside it by mistake.

Different from Shadow-DOM Encapsulation

This might sound similar to shadow-DOM encapsulation, and there is certainly cross-over between scope & encapsulation.

Diagram shows a widget with solid boundaries,
which cannot be penetrated
in either direction
(global styles can't get in, widget styles can't get out)

But the Shadow-DOM is designed for more highly-isolated widgets. This creates a 1-to-1 relationship, where boundaries are defined in the DOM, each component has a single scope, and styles are isolated from getting in or out.

Build-tools Provide Scoped Styles

BEM, CSS Modules, Vue, JSX, Stylable, etc

While encapsulation can be useful, it’s very different from the lighter-touch “scope” that we get from existing build-tools and conventions 

Diagram shows a component with porous boundaries,
all styles can penetrate, or establish their own lower boundaries

Where scopes reference the DOM, but they are able to overlap, and integrate more smoothly with global design systems. Different styles can be given different or overlapping boundaries, while global styles continue to apply globally. This provides us with a much lower-impact alternative, where scopes are defined in CSS, and can be re-used across components, or overlap & cascade together.

@scope (.media) to (.content) {
img { /* only images that are "in scope" */ }

So we’re proposing an at-scope rule, that accepts both a scope-root selector (in this case media) and a lower-boundary selector (in this case content). Any selectors inside the at-rule only apply between the root and the lower-boundary. In this case we’re styling images inside media, unless they are also inside the media content.

.light-theme a { color: purple; }
.dark-theme a { color: plum; }

We can also talk about this in terms of proximity. These two selectors apply to links inside a light-theme or dark-theme class. And that works great, as long as we never nest one theme inside the other. Since our selectors both have the same specificity, and ancestor proximity is not part of the cascade 

dark-theme will always override light theme in nested situations.

@scope ([data-theme=light]) to ([data-theme]) {
a { color: purple; }
@scope ([data-theme=dark]) to ([data-theme]) {
a { color: plum; }

We can solve that problem using lower-boundaries, so that themes never bleed into each other 

@scope ([data-theme=light]) {
a { color: purple; }
@scope ([data-theme=dark]) {
a { color: plum; }

But I think it would also make sense for scope proximity to be added as part of the scope feature. When specificity is equal, we would default to using the “closer” scope-root. This part of the spec is still being debated.

There’s a lot more to the proposal, which you can look into if your interested. The CSSWG has expressed interest, feedback is welcome, and Chrome plans to prototype this soon, for more testing.

And that brings us to the real reason we’re here. Container Queries.

  • Media queries let us respond to viewport
  • Same element in multiple containers, viewport isn’t useful
  • Respond to containers instead

No matter how those containers are nested.

But trying to measure a “container” in CSS, and then make changes based on that measurement, poses a bit of a paradox.

One of the coolest responsive features in CSS, which we don’t talk about nearly enough, is the way we calculate layout based on both context and content. Add more content, and a container will try to grow, but it might also be constrained by context, or explicit sizing.

That’s very cool, but if you add container queries, it becomes an infinite loop: as the container gets larger, we make the content smaller, which makes the container smaller, which makes the content larger.

2010-2020 🚧 Laying Foundations 🚧

So for a long time, this seemed impossible to implement. But behind the scenes, a lot of people have been laying the groundwork in browsers.

2020 Proposals

  • David Baron: @container
    limited by containment
  • Brian Kardell: switch()
    limited to paint

Last year two proposals emerged, showing different ways we might pull this off. Both are interesting, but David Baron’s approach has the most momentum right now, and I’ve been working on it to flesh out some of the details, and start writing a specification.

The first thing we need to do is define our containers 

Anything we want to be able to measure.

In order to avoid any layout loops, we need to turn off content-based sizing. Our containers need to be sized without reference to anything inside it.

.container {
contain: size layout style;

We already have a property for this! It’s called contain, and allows us to “contain” various types of things.

Size containment turns off content-based sizing, layout containment is kinda like a clearfix – wrapping around floats and margins – and style containment keeps list-counters from leaking out.

And we’re going to need all three of these for our container queries work.

2D size containment Is Too Restrictive

But size-containment is… bad in most cases. It’s just not possible to build all our containers with explicit widths and heights! We usually need one axis to be fluid, so that extra content has somewhere to go.

Most layouts work by containing the width, or the inline-dimension, and allowing the height to grow or shrink with the content. So we’re adding an option to make single-axis containment possible.

Contain inline-size.

We’re not sure if we can also allow a block-size values here. That needs some more experimenting.

.sidebar, main, .grid-item {
contain: inline-size layout style;

In our initial proposal, applying the appropriate containment – layout and 1d or 2d size – creates a container.

That’s how the Chrome prototype currently works, but you can see it’s a lot to remember, and it’s not obvious what’s going on.

So we’re working on a simpler syntax here.

Instead of specifying all the containment required, we just say what type of container we want – or what we want to query. In this case we want to query the inline size. Browsers can take that, and apply the right containment in the background.

This syntax is still very much in development, so it could change a lot. There’s some concern around having a container property that is so similar to the contain property.

Once we have containers, we can begin to query them!

@container (min-width: 40em) {
.card { /* ... */ }
h2 { /* ... */ }

A container-query looks exactly like a media-query, but with at-container instead of at-media. And each element will query the size of it’s nearest ancestor container.

That’s another important limitation to make sure there are no loops. Container’s can’t query themselves.

<div class="container">
<div class="container">
<div class="container">
We can nest containers!

We can have containers inside of containers 

.container { container: inline; }

@container (width > 30em) {
.container { padding: 2em; }

And we can change containers inside a container query. But each container will respond to the size of its parent container.

Chrome Prototype

  1. Download/Update Chrome Canary
  2. Go to chrome://flags in the URL bar
  3. Search for “CSS Container Queries” & enable it
  4. You’ll need to restart after turning it on

Chrome already has a prototype, and you can start playing with it behind a feature flag. I’ve started collecting codepen demos to help you get started.

But why don’t I just show you? I’ve set up two containers on the page, each with one card using a media-query, and one card using a container query. The media queries all trigger at the same time, but the container queries depend on the size of the container.

My coworker David Herron, made this one showing the same blockquote with three different designs based on the size of the container.

In some cases, like inside flexbox or grid, there is no outside container that will tell us the actual space available for each item. But we can get around that by adding a container around each component – in this case div.card is wrapping each article. The outer div establishes a container, and the inner article can query it.

Max Böck has created this bookstore demo with self-contained web components. Each component host element is a container, and everything inside the component adjusts based on available size.

Of course, we can also get creative! Jhey Tompkins made these interactive blinds that get smaller as the container gets bigger. Because CSS doesn’t have to be practical to be awesome.

@container (width > 30em) { /* CQ support */ }

/* works for now… */
@supports not (contain: inline-size) {
@media (width > 40em) { /* no CQ support */ }
/* actual syntax TBD */
@supports not (container: inline) {
@media (width > 40em) { /* no CQ support */ }

All of these features are designed to work together

Building on the existing features of CSS 

And the cascade that holds it all together 

But particularly the overlap between the two main goals of CSS: to make responsive 

and reusable styles. Building components that are inherently responsive.

There’s already been a lot of progress in this space, with tools like grid & flexbox & aspect-ratios – now layers, scope, and container queries – but also color-functions, nesting and more.

Our medium is not done. Our medium is still going through radical changes.

– Jen Simmons, Designing with Grid

“Our medium is not done. Our medium is still going through radical changes.”