Improving CSS Architecture with Cascade Layers, Container Queries, & Scope

@ TPAC 2021

I want to quickly show you three CSS features that we’re working on, to help teams manage the overall architecture of their CSS.

First, we wanted to address some of the pain points around selectors and specificity.

  • * (universal/weak)
  • type
  • .class & [attr]
  • #IDs (single-use/strong)

Specificity is based on the assumption that generic selectors are also lower priority, and more narrowly targeted selectors are higher priority.

This is a rough approximation of the layers in our code – But it’s not perfect.

And authors don’t have much direct control. The act of layering is intertwined with the need to select elements.

Cascade Layers are designed to give authors more direct control, to describe our own custom layers of the cascade.

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

We can define a layer, give it a name, and add styles to it using either the at-layer rule 

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

@layer default { /* same "default" layer */ }

Or by adding a layer function to imports, or both.

/* unlayered styles (lowest) */
@layer default { /* higher */ }
@layer theme { /* even higher */ }
@layer components { /* highest */ }

Layers stack in the order they were first defined, with un-layered styles at the bottom, and the last layer at the top. Selectors inside the components layer here, will take priority over all the other layers.

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

/* still a lower layer than "theme" styles */
@layer default { /* … */ }

But we don’t need to keep all our layered styles in that same order. The layer priority is based on when a layer name first appears.

@layer default;
@layer theme, components, utilities;

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

So we can even establish our desired layer order up-front, by using the layer rule without any styles – just a layer name or list of names. Once they’re established, we can add to those layers in any order, and our code will slot into place.

@layer tools {
@layer theme {
/* tools.theme */

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

We can also nest layers as needed – and reference nested layers directly with a dot notation.

Or create anonymous layers that are maintained in a single location.

This spec is currently a Working Draft, and has become fairly stable.

Existing Feature Flags

  • Firefox Nightly:
    » layout.css.cascade-layers.enabled feature flag
  • Chrome Canary:
    » --enable-blink-features=CSSCascadeLayers run-time flag

Both Firefox and Chrome already have prototypes available behind flags, and there’s also been significant progress on the Webkit issue.

The next feature is also about how selectors work. With “scope”, we’re trying to address two closely related 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.

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 slot in the middle for content. We should be able to style these donut compontents, without worrying that we might accidentally style everything inside them by mistake.

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)

There are some similarities between scope and shadow-DOM encapsulation.

But Shadow boundaries are defined in the DOM, so that each element has a single scope, and styles are strongly isolated from getting in or out. They never allowed to overlap at all.

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

Where scopes are defined entirely in CSS – more fluid, able to overlap, and integrate more smoothly with global design systems.

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

The current proposal uses an at-scope rule, which 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 match elements between a matched root element and any lower-boundary descendants.

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

We’re also considering how scope proximity might become part of the cascade. When two styles have the same specificity we could give priority to the “closer” scope-root before falling back to source-order.

@scope (.media) to (.content) {
img { border: red; }

/* as a selector, without proximity rules? */
img:in(.media / .content) { border: red; }

There has also been talk about adding some form of lower-boundary or donut selector syntax, to write more targeted selectors without the at-rule or proximity weighting.

This is still in very early design discussions, but we’re happy to get feedback.

And that brings us to Container Queries – one of the most requested features over the last decade.

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

But this type of query could lead to an infinite layout loop.

.container {
contain: size layout style;

So the first thing we need to do is define our containers – any element the we want to query – and turn off content-based sizing on those elements.

We can do something similar with the contain property, but the current options are a little too heavy-handed.

We usually want to contain only the width of an element, or the inline-dimension, and allow the height or block dimension to grow and shrink with the content. This isn’t entirely solved, yet, but we have a prototype, and we’re confident that there’s a path forward.

.sidebar, main, .grid-item {
container-type: inline-size;

But authors wont set that containment directly. Instead, we’ll define the type of container we want – what dimensions we need to query 

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

And then any element can query the container it is in – its nearest ancestor that’s been defined as a container. This container-query looks exactly like a media-query, but with at-container instead of at-media.

.sidebar {
container-type: inline-size;
container-name: sidebar;

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

And if you don’t want to rely on the nearest container, you can also give containers names, and only query containers with a specific name.

.sidebar { container-type: inline-size; }

@container type(inline-size) (width > 30em) {
.container { padding: 2em; }

Or only query a specific container type.

Chrome Canary Prototype

chrome://flags/ » Enable CSS Container Queries

Chrome Canary already has a prototype, and you can start playing with it behind a feature flag.

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

More to do… Container Units

qw | qh | qi | qb | qmin | qmax

We’re also working on container-relative units, similar to vw, vh, vmin, vmax, but a percentage of the container size rather than the viewport.

These are also supported in the Chrome Canary prototype. Here’s a demo from Scott Kellum showing query units in action.

We’re also working on queries that aren’t about the container size. The exact syntax is not established yet, but we might be able to query the actual value of a property 

Or the current state of a container. Is it position-sticky, and currently in a “stuck” state?

This is an editor’s draft, just waiting on the details of single-axis containment before we move it to First Public Working Draft, and start looking for more implementations.

All of these features are designed to work together, building on the existing features of CSS. we’re excited to see what people build with these new tools, and always eager to get feedback.