Don’t Fight the Cascade, Control It!

If you’re disciplined and make use of the inheritance that the CSS cascade provides, you’ll end up writing less CSS. But because our styles often comes from all kinds of sources — and can be a pain to structure and maintain—the cascade can be a source of frustration, and the reason we end up with more CSS than necessary.

Some years ago, Harry Roberts came up with ITCSS and it’s a clever way of structuring CSS.

Mixed with BEM, ITCSS has become a popular way that people write and organize CSS.

However, even with ITCSS and BEM, there are still times where we still struggle with the cascade. For example, I’m sure you’ve had to @import external CSS components at a specific location to prevent breaking things, or reach for the dreaded !important at some point in time.

Recently, some new tools were added to our CSS toolbox, and they allow us to finally control the cascade. Let’s look at them.

O cascade, :where art thou?

Using the :where pseudo-selector allows us to remove specificity to “just after the user-agent default styles,” no matter where or when the CSS is loaded into the document. That means the specificity of the whole thing is literally zero — totally wiped out. This is handy for generic components, which we’ll look into in a moment.

First, imagine some generic <table> styles, using :where:

:where(table) {
  background-color: tan;
}

Now, if you add some other table styles before the :where selector, like this:

table {
  background-color: hotpink;
}

:where(table) {
  background-color: tan;
}

…the table background becomes hotpink, even though the table selector is specified before the :where selector in the cascade. That’s the beauty of :where, and why it’s already being used for CSS resets.

:where has a sibling, which has almost the exact opposite effect: the :is selector.

The specificity of the :is() pseudo-class is replaced by the specificity of its most specific argument. Thus, a selector written with :is() does not necessarily have equivalent specificity to the equivalent selector written without :is(). Selectors Level 4 specification

Expanding on our previous example:

:is(table) {
  --tbl-bgc: orange;
}
table {
  --tbl-bgc: tan;
}
:where(table) {
  --tbl-bgc: hotpink;
  background-color: var(--tbl-bgc);
}

The <table class="c-tbl"> background color will be tan because the specificity of :is is less specific than table.

However, if we were to change it to this:

:is(table, .c-tbl) {
  --tbl-bgc: orange;
}

…the background color will be orange, since :is has the weight of it’s heaviest selector, which is .c-tbl.

Example: A configurable table component

Now, let’s see how we can use :where in our components. We’ll be building a table component, starting with the HTML:

Let’s wrap .c-tbl in a :where-selector and, just for fun, add rounded corners to the table. That means we need border-collapse: separate, as we can’t use border-radius on table cells when the table is using border-collapse: collapse:

:where(.c-tbl) {
  border-collapse: separate;
  border-spacing: 0;
  table-layout: auto;
  width: 99.9%;
}

The cells use different styling for the <thead> and <tbody>-cells:

:where(.c-tbl thead th) {
  background-color: hsl(200, 60%, 40%);
  border-style: solid;
  border-block-start-width: 0;
  border-inline-end-width: 1px;
  border-block-end-width: 0;
  border-inline-start-width: 0;
  color: hsl(200, 60%, 99%);
  padding-block: 1.25ch;
  padding-inline: 2ch;
  text-transform: uppercase;
}
:where(.c-tbl tbody td) {
  background-color: #FFF;
  border-color: hsl(200, 60%, 80%);
  border-style: solid;
  border-block-start-width: 0;
  border-inline-end-width: 1px;
  border-block-end-width: 1px;
  border-inline-start-width: 0;
  padding-block: 1.25ch;
  padding-inline: 2ch;
}

And, because of our rounded corners and the missing border-collapse: collapse, we need to add some extra styles, specifically for the table borders and a hover state on the cells:

:where(.c-tbl tr td:first-of-type) {
  border-inline-start-width: 1px;
}
:where(.c-tbl tr th:last-of-type) {
  border-inline-color: hsl(200, 60%, 40%);
}
:where(.c-tbl tr th:first-of-type) {
  border-inline-start-color: hsl(200, 60%, 40%);
}
:where(.c-tbl thead th:first-of-type) {
  border-start-start-radius: 0.5rem;
}
:where(.c-tbl thead th:last-of-type) {
  border-start-end-radius: 0.5rem;
}
:where(.c-tbl tbody tr:last-of-type td:first-of-type) {
  border-end-start-radius: 0.5rem;
}
:where(.c-tbl tr:last-of-type td:last-of-type) {
  border-end-end-radius: 0.5rem;
}
/* hover */
@media (hover: hover) {
  :where(.c-tbl) tr:hover td {
    background-color: hsl(200, 60%, 95%);
  }
}

Now we can create variations of our table component by injecting other styles before or after our generic styles (courtesy of the specificity-stripping powers of :where), either by overwriting the .c-tbl element or by adding a BEM-style modifier-class (e.g. c-tbl--purple):

<table class="c-tbl c-tbl--purple">
.c-tbl--purple th {
  background-color: hsl(330, 50%, 40%)
}
.c-tbl--purple td {
  border-color: hsl(330, 40%, 80%);
}
.c-tbl--purple tr th:last-of-type {
  border-inline-color: hsl(330, 50%, 40%);
}
.c-tbl--purple tr th:first-of-type {
  border-inline-start-color: hsl(330, 50%, 40%);
}

Cool! But notice how we keep repeating colors? And what if we want to change the border-radius or the border-width? That would end up with a lot of repeated CSS.

Let’s move all of these to CSS custom properties and, while we’re at it, we can move all configurable properties to the top of the component’s “scope“ — which is the table element itself — so we can easily play around with them later.

CSS Custom Properties

I’m going to switch things up in the HTML and use a data-component attribute on the table element that can be targeted for styling.

<table data-component="table" id="table">

That data-component will hold the generic styles that we can use on any instance of the component, i.e. the styles the table needs no matter what color variation we apply. The styles for a specific table component instance will be contained in a regular class, using custom properties from the generic component.

[data-component="table"] {
  /* Styles needed for all table variations */
}
.c-tbl--purple {
  /* Styles for the purple variation */
}

If we place all the generic styles in a data-attribute, we can use whatever naming convention we want. This way, we don’t have to worry if your boss insists on naming the table’s classes something like .BIGCORP__TABLE, .table-component or something else.

In the generic component, each CSS property points to a custom property. Properties, that have to work on child-elements, like border-color, are specified at the root of the generic component:

:where([data-component="table"]) {
  /* These will will be used multiple times, and in other selectors */
  --tbl-hue: 200;
  --tbl-sat: 50%;
  --tbl-bdc: hsl(var(--tbl-hue), var(--tbl-sat), 80%);
}

/* Here, it's used on a child-node: */
:where([data-component="table"] td) {
  border-color: var(--tbl-bdc);
}

For other properties, decide whether it should have a static value, or be configurable with its own custom property. If you’re using custom properties, remember to define a default value that the table can fall back to in the event that a variation class is missing.

:where([data-component="table"]) {
  /* These are optional, with fallbacks */
  background-color: var(--tbl-bgc, transparent);
  border-collapse: var(--tbl-bdcl, separate);
}

If you’re wondering how I’m naming the custom properties, I’m using a component-prefix (e.g. --tbl) followed by an Emmett-abbreviation (e.g. -bgc). In this case, --tbl is the component-prefix, -bgc is the background color, and -bdcl is the border collapse. So, for example, --tbl-bgc is the table component’s background color. I only use this naming convention when working with component properties, as opposed to global properties which I tend to keep more general.

Now, if we open up DevTools, we can play around with the custom properties. For example, We can change --tbl-hue to a different hue value in the HSL color, set --tbl-bdrs: 0 to remove border-radius, and so on.

A :where CSS rule set showing the custom properties of the table showing how the cascade’s specificity scan be used in context.

When working with your own components, this is the point in time you’ll discover which parameters (i.e. the custom property values) the component needs to make things look just right.

We can also use custom properties to control column alignment and width:

:where[data-component="table"] tr > *:nth-of-type(1)) {
  text-align: var(--ca1, initial);
  width: var(--cw1, initial);
  /* repeat for column 2 and 3, or use a SCSS-loop ... */
}

In DevTools, select the table and add these to the element.styles selector:

element.style {
  --ca2: center; /* Align second column center */
  --ca3: right; /* Align third column right */
}

Now, let’s create our specific component styles, using a regular class, .c-tbl (which stands for “component-table” in BEM parlance). Let’s toss that class in the table markup.

<table class="c-tbl" data-component="table" id="table">

Now, let’s change the --tbl-hue value in the CSS just to see how this works before we start messing around with all of the property values:

.c-tbl {
  --tbl-hue: 330;
}

Notice, that we only need to update properties rather than writing entirely new CSS! Changing one little property updates the table’s color — no new classes or overriding properties lower in the cascade.

Notice how the border colors change as well. That’s because all the colors in the table inherit from the --tbl-hue variable

We can write a more complex selector, but still update a single property, to get something like zebra-striping:

.c-tbl tr:nth-child(even) td {
  --tbl-td-bgc: hsl(var(--tbl-hue), var(--tbl-sat), 95%);
}

And remember: It doesn’t matter where you load the class. Because our generic styles are using :where, the specificity is wiped out, and any custom styles for a specific variation will be applied no matter where they are used. That’s the beauty of using :where to take control of the cascade!

And best of all, we can create all kinds of table components from the generic styles with a few lines of CSS.

Purple table with zebra-striped columns
Light table with a “noinlineborder” parameter… which we’ll cover next

Adding parameters with another data-attribute

So far, so good! The generic table component is very simple. But what if it requires something more akin to real parameters? Perhaps for things like:

  • zebra-striped rows and columns
  • a sticky header and sticky column
  • hover-state options, such as hover row, hover cell, hover column

We could simply add BEM-style modifier classes, but we can actually accomplish it more efficiently by adding another data-attribute to the mix. Perhaps a data-param that holds the parameters like this:

<table data-component="table" data-param="zebrarow stickyrow">

Then, in our CSS, we can use an attribute-selector to match a whole word in a list of parameters. For example, zebra-striped rows:

[data-component="table"][data-param~="zebrarow"] tr:nth-child(even) td {
  --tbl-td-bgc: var(--tbl-zebra-bgc);
}

Or zebra-striping columns:

[data-component="table"][data-param~="zebracol"] td:nth-of-type(odd) {
  --tbl-td-bgc: var(--tbl-zebra-bgc);
}

Let’s go nuts and make both the table header and the first column sticky:


[data-component="table"][data-param~="stickycol"] thead tr th:first-child,[data-component="table"][data-param~="stickycol"] tbody tr td:first-child {
  --tbl-td-bgc: var(--tbl-zebra-bgc);
  inset-inline-start: 0;
  position: sticky;
}
[data-component="table"][data-param~="stickyrow"] thead th {
  inset-block-start: -1px;
  position: sticky;
}

Here’s a demo that allows you to change one parameter at a time:

The default light theme in the demo is this:

.c-tbl--light {
  --tbl-bdrs: 0;
  --tbl-sat: 15%;
  --tbl-th-bgc: #eee;
  --tbl-th-bdc: #eee;
  --tbl-th-c: #555;
  --tbl-th-tt: normal;
}

…where data-param is set to noinlineborder which corresponds to these styles:

[data-param~="noinlineborder"] thead tr > th {
  border-block-start-width: 0;
  border-inline-end-width: 0;
  border-block-end-width: var(--tbl-bdw);
  border-inline-start-width: 0;
}

I know my data-attribute way of styling and configuring generic components is very opinionated. That’s just how I roll, so please feel free to stick with whatever method you’re most comfortable working with, whether it’s a BEM modifier class or something else.

The bottom line is this: embrace :where and :is and the cascade-controlling powers they provide. And, if possible, construct the CSS in such a way that you wind up writing as little new CSS as possible when creating new component variations!

Cascade Layers

The last cascade-busting tool I want to look at is “Cascade Layers.” At the time of this writing, it’s an experimental feature defined in the CSS Cascading and Inheritance Level 5 specification that you can access in Safari or Chrome by enabling the #enable-cascade-layers flag.

Bramus Van Damme sums up the concept nicely:

The true power of Cascade Layers comes from its unique position in the Cascade: before Selector Specificity and Order Of Appearance. Because of that we don’t need to worry about the Selector Specificity of the CSS that is used in other Layers, nor about the order in which we load CSS into these Layers — something that will come in very handy for larger teams or when loading in third-party CSS.

Perhaps even nicer is his illustration showing where Cascade Layers fall in the cascade:

Credit: Bramus Van Damme

At the beginning of this article, I mentioned ITCSS — a way of taming the cascade by specifying the load-order of generic styles, components etc. Cascade Layers allow us to inject a stylesheet at a given location. So a simplified version of this structure in Cascade Layers looks like this:

@layer generic, components;

With this single line, we’ve decided the order of our layers. First come the generic styles, followed by the component-specific ones.

Let’s pretend that we’re loading our generic styles somewhere much later than our component styles:

@layer components {
  body {
    background-color: lightseagreen;
  }
}

/* MUCH, much later... */

@layer generic { 
  body {
    background-color: tomato;
  }
}

The background-color will be lightseagreen because our component styles layer is set after the generic styles layer. So, the styles in the components layer “win” even if they are written before the generic layer styles.

Again, just another tool for controlling how the CSS cascade applies styles, allowing us more flexibility to organize things logically rather than wrestling with specificity.

Now you’re in control!

The whole point here is that the CSS cascade is becoming a lot easier to wrangle, thanks to new features. We saw how the :where and :is pseudo-selectors allows us to control specificity, either by stripping out the specificity of an entire ruleset or taking on the specificity of the most specific argument, respectively. Then we used CSS Custom Properties to override styles without writing a new class to override another. From there, we took a slight detour down data-attribute lane to help us add more flexibility to create component variations merely by adding arguments to the HTML. And, finally, we poked at Cascade Layers which should prove handy for specifying the loading order or styles using @layer.

If you leave with only one takeaway from this article, I hope it’s that the CSS cascade is no longer the enemy it’s often made to be. We are gaining the tools to stop fighting it and start leaning into even more.


Header photo by Stephen Leonardi on Unsplash


Don’t Fight the Cascade, Control It! originally published on CSS-Tricks. You should get the newsletter and become a supporter.

A Guide To Newly Supported, Modern CSS Pseudo-Class Selectors

Pseudo-class selectors are the ones that begin with the colon character “:” and match based on a state of the current element. The state may be relative to the document tree, or in response to a change of state such as :hover or :checked.

:any-link

Although defined in Selectors Level 4, this pseudo-class has had cross-browser support for quite some time. The any-link pseudo-class will match an anchor hyperlink as long as it has a href. It will match in a way equivalent to matching both :link and :visited at once. Essentially, this may reduce your styles by one selector if you are adding basic properties such as color that you’d like to apply to all links regardless of their visited status.

:any-link {
  color: blue;
  text-underline-offset: 0.05em;
}

An important note about specificity is that :any-link will win against a as a selector even if a is placed lower in the cascade since it has the specificity of a class. In the following example, the links will be purple:

:any-link {
  color: purple;
}

a {
  color: red;
}

So if you introduce :any-link, be aware that you will need to include it on instances of a as a selector if they will be in direct competition for specificity.

:focus-visible

I’d bet that one of the most common accessibility violations across the web is removing outline on interactive elements like links, buttons, and form inputs for their :focus state. One of the main purposes of that outline is to serve as a visual indicator for users who primarily use keyboards to navigate. A visible focus state is critical as a way-finding tool as those users tab across an interface and to help reinforce what is an interactive element. Specifically, the visible focus is covered in the WCAG Success Criterion 2.4.11: Focus Appearance (Minimum).

The :focus-visible pseudo-class is intended to only show a focus ring when the user agent determines via heuristics that it should be visible. Put another way: browsers will determine when to apply :focus-visible based on things like input method, type of element, and context of the interaction. For testing purposes via a desktop computer with keyboard and mouse input, you should see :focus-visible styles attached when you tab into an interactive element but not when you click it, with the exception of text inputs and textareas which should show :focus-visible for all focus input types.

Note: For more details, review the working draft of the :focus-visible spec.

The latest versions of Firefox and Chromium browsers seem to now be handling :focus-visible on form inputs according to the spec which says that the UA should remove :focus styles when :focus-visible matches. Safari is not yet supporting :focus-visible so we need to ensure a :focus style is included as a fallback to avoid removing the outline for accessibility.

Given a button and text input with the following set of styles, let’s see what happens:

input:focus,
button:focus {
  outline: 2px solid blue;
  outline-offset: 0.25em;
}

input:focus-visible {
  outline: 2px solid transparent;
  border-color: blue;
}

button:focus:not(:focus-visible) {
  outline: none;
}

button:focus-visible {
  outline: 2px solid transparent;
  box-shadow: 0 0 0 2px #fff, 0 0 0 4px blue;
}

Chromium and Firefox

  • input
    Correctly remove :focus styles when elements are focused via mouse input in favor of :focus-visible resulting in changing the border-color and hiding the outline on keyboard input
  • button
    Does not only use :focus-visible without the extra rule for button:focus:not(:focus-visible) that removes the outline on :focus, but will allow visibility of the box-shadow only on keyboard input

Safari

  • input
    Continues using only the :focus styles
  • button
    This seems to now be partially respecting the intent of :focus-visible on the button by hiding the :focus styles on click, but still showing the :focus styles on keyboard interaction

So for now, the recommendation would be to continue including :focus styles and then progressively enhance up to using :focus-visible which the demo code allows. Here’s a CodePen for you to continue testing with:

See the Pen Testing application of :focus-visible by Stephanie Eckles.

:focus-within

The :focus-within pseudo-class has support among all modern browsers, and acts almost like a parent selector but only for a very specific condition. When attached to a containing element and a child element matches for :focus, styles can be added to the containing element and/or any other elements within the container.

A practical enhancement to use this behavior for is styling a form label when the associated input has focus. For this to work, we wrap the label and input in a container, and then attach :focus-within to that container as well as selecting the label:

.form-group:focus-within label {
  color: blue;
}

This results in the label turning blue when the input has focus.

This CodePen demo also includes adding an outline directly to the .form-group container:

See the Pen Testing application of :focus-within by Stephanie Eckles.

:is()

Also known as the “matches any” pseudo-class, :is() can take a list of selectors to try to match against. For example, instead of listing heading styles individually, you can group them under the selector of :is(h1, h2, h3).

A couple of unique behaviors about the :is() selector list:

  • If a listed selector is invalid, the rule will continue to match the valid selectors. Given :is(-ua-invalid, article, p) the rule will match article and p.
  • The computed specificity will equal that of the passed selector with the highest specificity. For example, :is(#id, p) will have the specificity of the #id — 1.0.0 — whereas :is(p, a) will have a specificity of 0.0.1.

The first behavior of ignoring invalid selectors is a key benefit. When using other selectors in a group where one selector is invalid, the browser will throw out the whole rule. This comes into play for a few instances where vendor prefixes are still necessary, and grouping prefixed and non-prefixed selectors causes the rule to fail among all browsers. With :is() you can safely group those styles and they will apply when they match and be ignored when they don’t.

To me, grouping heading styles as previously mentioned is already a big win with this selector. It’s also the type of rule that I would feel comfortable using without a fallback when applying non-critical styles, such as:

:is(h1, h2, h3) {
  line-height: 1.2;
}

:is(h2, h3):not(:first-child) {
  margin-top: 2em;
}

In this example (which comes from the document styles in my project SmolCSS), having the greater line-height inherited from base styles or lacking the margin-top is not really a problem for non-supporting browsers. It’s simply less than ideal. What you wouldn’t want to use :is() for quite yet would be critical layout styles such as Grid or Flex that significantly control your interface.

Additionally, when chained to another selector, you can test whether the base selector matches a descendent selector within :is(). For example, the following rule selects only paragraphs that are direct descendants of articles. The universal selector is being used as a reference to the p base selector.

p:is(article > *)

For the best current support, if you’d like to start using it you’ll also want to double-up on styles by including duplicate rules using :-webkit-any() and :matches(). Remember to make these individual rules, or even the supporting browser will throw it out! In other words, include all of the following:

:matches(h1, h2, h3) { }

:-webkit-any(h1, h2, h3) { }

:is(h1, h2, h3) { }

Worth mentioning at this point is that along with the newer selectors themselves is an updated variation of @supports which is @supports selector. This is also available as @supports not selector.

Note: At present (of the modern browsers), only Safari does not support this at-rule.

You could check for :is() support with something like the following, but you’d actually be losing out on supporting Safari since Safari supports :is() but doesn’t support @supports selector.

@supports selector(:is(h1)) {
  :is(h1, h2, h3) {
    line-height: 1.1;
  }
}

:where()

The pseudo-class :where() is almost identical to :is() except for one critical difference: it will always have zero-specificity. This has incredible implications for folks who are building frameworks, themes, and design systems. Using :where(), an author can set defaults and downstream developers can include overrides or extensions without specificity clashing.

Consider the following set of img styles. Using :where(), even with a higher specificity selector, the specificity remains zero. In the following example, which color border do you think the image will have?

:where(article img:not(:first-child)) {
    border: 5px solid red;
}

:where(article) img {
  border: 5px solid green;
}

img {
  border: 5px solid orange;
}

The first rule has zero specificity since its wholly contained within :where(). So directly against the second rule, the second rule wins. Introducing the img element-only selector as the last rule, it’s going to win due to the cascade. This is because it will compute to the same specificity as the :where(article) img rule since the :where() portion does not increase specificity.

Using :where() alongside fallbacks is a little more difficult due to the zero-specificity feature since that feature is likely why you would want to use it over :is(). And if you add fallback rules, those are likely to beat :where() due to its very nature. And, it has better overall support than the @supports selector so trying to use that to craft a fallback isn’t likely to provide much (if any) of a gain. Basically, be aware of the inability to correctly create fallbacks for :where() and carefully check your own data to determine if it’s safe to begin using for your unique audience.

You can further test :where() with the following CodePen that uses the img selectors from above:

See the Pen Testing :where() specificity by Stephanie Eckles.

Enhanced :not()

The base :not() selector has been supported since Internet Explorer 9. But Selectors Level 4 enhances :not() by allowing it to take a selector list, just like :is() and :where().

The following rules provide the same result in supporting browsers:

article :not(h2):not(h3):not(h4) {
  margin-bottom: 1.5em;
}

article :not(h2, h3, h4) {
  margin-bottom: 1.5em;
}

The ability of :not() to accept a selector list has great modern browser support.

As we saw with :is(), enhanced :not() can also contain a reference to the base selector as a descendent using *. This CodePen demonstrates this ability by selecting links that are not descendants of nav.

See the Pen Testing :not() with a descendent selector by Stephanie Eckles.

Bonus: The previous demo also includes an example of chaining :not() and :is() to select images that are not adjacent siblings of either h2 or h3 elements.

Proposed but “at risk” — :has()

The final pseudo-class that is a very exciting proposal but has no current browser implementing it even in an experimental way is :has(). In fact, it is listed in the Selector Level 4 Editor’s Draft as “at-risk” which means that it is recognized to have difficulties in completing its implementation and so it may be dropped from the recommendation.

If implemented, :has() would essentially be the “parent selector” that many CSS folks have longed to have available. It would work with logic similar to a combination of both :focus-within and :is() with descendent selectors, where you are looking for the presence of descendants but the applied styling would be to the parent element.

Given the following rule, if navigation contained a button, then the navigation would have decreased top and bottom padding:

nav {
  padding: 0.75rem 0.25rem;

nav:has(button) {
  padding-top: 0.25rem;
  padding-bottom: 0.25rem;
}

Again, this is not currently implemented in any browser even experimentally — but it is fun to think about! Robin Rendle provided additional insights into this future selector over on CSS-Tricks.

Honorable Mention from Level 3: :empty

A useful pseudo-class you may have missed from Selectors Level 3 is :empty which matches an element when it has no child elements, including text nodes.

The rule p:empty will match <p></p> but not <p>Hello</p>.

One way you can use :empty is to hide elements that are perhaps placeholders for dynamic content that is populated with JavaScript. Perhaps you have a div that will receive search results, and when it’s populated it will have a border and some padding. But with no results yet, you don’t want it to take up space on the page. Using :empty you can hide it with:

.search-results:empty {
  display: none;
}

You may be thinking about adding a message in the empty state and be tempted to add it with a pseudo-element and content. The pitfall here is that messages may not be available to users of assistive technology which are inconsistent on whether they can access content. In other words, to make sure a “no results” type of message is accessible, you would want to add that as a real element like a paragraph (an aria-label would no longer be accessible for a hidden div).

Resources for Learning About Selectors

CSS has many more selectors inclusive of pseudo-classes. Here are a few more places to learn more about what’s available:

CSS4

Tab Atkins in 2012:

There has never been a CSS4. There will never be a CSS4. CSS4 is not a thing that exists.

Rachel Andrew in 2016:

While referring to all new CSS as CSS3 worked for a short time, it doesn’t reflect the reality of where CSS is today. If you read something about CSS3 Selectors, then what is actually being described is something that is part of the CSS Selectors Level 3 specification. In fact CSS Selectors is one of the specifications that is marked as completed and a Recommendation. The CSS Working Group is now working on Selectors Level 4 with new proposed features plus the selectors that were part of Level 3 (and CSS 1 and 2). It’s not CSS4, but Level 4 of a single specification. One small part of CSS.

Jen Simmons in 2018:

Many people are waiting for CSS4 to come out. Where is it? When will it arrive? The answer is never. CSS4 will never happen. It's not actually a thing.


So CSS3 was a unique one-off opportunity. Rather than one big spec, break them into parts and start them all off at "Level 3" but then let them evolve separately. That was very on purpose, so things could move quicker independently.

The problem? It was almost too effective. CSS3, and perhaps to a larger degree, "HTML5", became (almost) household names. It was so successful, it's leaving us wanting to pull that lever again. It was successful on a ton of levels:

  • It pushed browser technology forward, particularly on technologies that had been stale for too long.
  • It got site owners to think, "hey maybe it's a good time to update our website."
  • It got educators to think, "hey maybe it's a good time to update our curriculums."

It was good for the web overall, good for websites taking advantage of it, and there was money to be made along the way. I bet it would be staggering to see how much money was made in courses and conferences waving the CSS3 flag.

Peter-Paul Koch in 2020:

I am proposing that we web developers, supported by the W3C CSS WG, start saying “CSS4 is here!” and excitedly chatter about how it will hit the market any moment now and transform the practice of CSS.

Of course “CSS4” has no technical meaning whatsoever. All current CSS specifications have their own specific versions ranging from 1 to 4, but CSS as a whole does not have a version, and it doesn’t need one, either.

Regardless of what we say or do, CSS 4 will not hit the market and will not transform anything. It also does not describe any technical reality.

Then why do it? For the marketing effect.

I think he's probably right. If we all got together on it, it could have a similar good-for-everybody bang the way CSS3 did.

If it's going to happen, what will give it momentum is if there is a single clear message about what it is. CSS3 was like:

  • border-radius
  • gradients
  • animations and transitions
  • transforms
  • box-shadow

Oh gosh, it's hard to remember now. But at the time it was a pretty clear set of things that represented what there was to learn and they were all fairly exciting.

What would we put under the CSS4 flag?

  • Flexbox? (Too old?)
  • Grid
  • Everything new with color (like this and this)
  • Independent transforms
  • Variable fonts
  • Offset paths
  • Let's get nesting done!
  • Houdini stuff? (Not ready enough?)
  • Shadow DOM selectors?

Lemme just say I will personally spearhead this thing if container queries can get done and we make that a part of it.

What else? Wanna refute anything on my list?

The post CSS4 appeared first on CSS-Tricks.