New and Old CSS Features

10 min read
csslogic functionsstylesnew featurescss features

Sample Image

New capabilities are appearing in CSS that allow for creating more complex styles and logic right inside CSS itself. It seems that preprocessors like Sass, Less, and other solutions will soon not be particularly needed.

Let's take a closer look at the and, or, xor, not operators in the context of CSS and other features such as if, @function, color-mix, @scope, and @layer.

New function if()

This is the most anticipated function being introduced into the CSS specification. It allows changing a property value depending on a condition right inside the string.

Important: Currently works only in Chrome Canary with the "Experimental Web Platform features" flag. - https://caniuse.com/?search=if

Syntax: if(condition, value-if-true, value-if-false)

Example (Inline If):

.container {
  /* If variable --variant equals 'dark', background is black, otherwise white */
  background-color: if(style(--variant: dark), black, white);
  
  /* Nesting is also planned */
  padding: if(style(--size: large), 20px, if(style(--size: medium), 10px, 5px));
}

Logic in Media Queries (@media, @supports)

Logical operators have been used here for a long time and are supported by all browsers.

AND

The and keyword is used. Both conditions must be true.

/* Screen wider than 600px AND orientation landscape */
@media (min-width: 600px) and (orientation: landscape) {
  body { background: lightblue; }
}

OR

The or keyword is used (in new specifications) or a comma , (classic way).

/* Device supports hover OR width greater than 1000px */
@media (hover: hover), (min-width: 1000px) {
  .btn { display: block; }
}

NOT

Inverts the condition.

/* Apply styles everywhere EXCEPT color screens */
@media not all and (color) {
  body { border: 1px solid black; }
}

Logic in Selectors

CSS allows building element selection logic.

NOT (Negation)

The pseudo-class :not().

/* All buttons that do NOT have the class .disabled */
button:not(.disabled) {
  cursor: pointer;
}

OR

Pseudo-classes :is() and :where(). They select an element if it matches at least one selector from the list.

/* Select element if it is h1 OR h2 OR h3 */
:is(h1, h2, h3) {
  font-weight: bold;
}

AND

There is no word and in selectors. Logical "AND" is achieved by writing selectors without a space (chaining).

/* Element has class .box AND class .active */
.box.active {
  border-color: red;
}

Complex Logic: XOR (Exclusive OR)

It is worth noting that there is no built-in XOR operator in CSS. XOR Logic: True if only one of the conditions is met, but not both at once.

How to implement? We have to combine not and and.

Example of XOR in selectors: Suppose we need to style an element if it has class .a OR class .b, but not both at once.

/* (A and not B) OR (B and not A) */
:is(.a:not(.b), .b:not(.a)) {
  color: purple;
}

The "Space Toggle" Trick (Hack for IF/ELSE)

While the if() function has not become a standard, developers use a trick with CSS variables to create switches.

Essence: A CSS variable can have a value of initial (empty, breaking the property) or a space (valid value).

:root {
  /* Switches */
  --ON: initial;  
  --OFF: ; 
}

.card {
  /* Setting: enable dark theme */
  --is-dark: var(--ON); 

  /* Logic: 
     If --is-dark = initial (ON), the fallback (black) will work.
     If --is-dark = " " (OFF), the first value (white) will work.
  */
  background-color: var(--is-dark, white) var(--is-dark, black); /* Doesn't work directly this simply, requires a complex var() stack */
}

@function

This is an absolutely new (experimental) feature that is just appearing in CSS standards. It is part of the same new specification as if(). Now we will be able to write functions that work directly in the browser.

Main difference from SCSS: they are dynamic, can be recalculated when variables change, screen sizes change, etc.

Syntax: Function name must start with dashes (like a variable), for example --my-func.

/* Function declaration */
@function --negate(--value) {
  result: calc(-1 * var(--value));
}

/* Usage */
.box {
  margin-left: --negate(20px); /* Result: -20px */
}

and another example:

@function --lighten-if-dark(--color type(<color>), --amount type(<percentage>)) {
  /* Using if() inside a function! */
  result: if(
    media(prefers-color-scheme: dark), 
    color-mix(in srgb, var(--color), white var(--amount)),
    var(--color)
  );
}

.button {
  /* If theme is dark, color becomes lighter by 20%, otherwise remains blue */
  background: --lighten-if-dark(blue, 20%);
}

Experimental support https://caniuse.com/?search=%40function

color-mix

color-mix() is a very powerful native CSS function that allows mixing two colors in a specific proportion right in the browser.

It is a "killer" of many Sass functions (like darken(), lighten(), mix()) because it works with CSS variables in real time.

Syntax:

color-mix(in <color-space>, <color-1> <percentage>?, <color-2> <percentage>?)

Mandatory parameter - color space (usually srgb or oklch).

/* Simple example: mix red and blue equally */
.box {
  background: color-mix(in srgb, red, blue); /* Result: purple */
}

Main Use Cases

A. Transparency for variables (Killer Feature)

Previously, if you had a color in a hex variable (--primary: #ff0000), you couldn't just add transparency to it. You had to split it into R, G, B channels. Now this is done in one line:

:root {
  --primary: #ff0000;
}

.overlay {
  /* Mix color with "transparent" color at 50% */
  background: color-mix(in srgb, var(--primary), transparent 50%);
  
  /* This is analogous to rgba(#ff0000, 0.5), but works with any variable! */
}

B. Automatic Palette Generation (Hover/Active)

Instead of manually picking colors for button states, you can calculate them automatically.

.btn {
  background-color: var(--color);
}

.btn:hover {
  /* On hover mix with white (lighten by 10%) */
  background-color: color-mix(in srgb, var(--color), white 10%);
}

.btn:active {
  /* On click mix with black (darken by 10%) */
  background-color: color-mix(in srgb, var(--color), black 10%);
}

Why this is cool: You only change one variable --color, and all states (:hover, :active) are recalculated automatically.

C. Creating Shades (Theming)

You can create an entire site theme by setting just one accent color.

:root {
  --brand: #3498db;
  
  /* Light background (mix with white 90%) */
  --brand-light: color-mix(in srgb, var(--brand), white 90%);
  
  /* Dark text (mix with black 60%) */
  --brand-dark:  color-mix(in srgb, var(--brand), black 60%);
}

Good browser support - https://caniuse.com/?search=color-mix

@Layer

@layer (cascade layers) is a revolution in managing selector weight (specificity). It is a tool that allows you to tell the browser: "I don't care how 'fat' the selector written in that library is, my styles in the theme layer must be more important".

But what problem are we solving? Previously, to override styles of some library (e.g., Bootstrap) or old legacy code, we had to:

  • Write long selector chains: body .container .card button { ... }.
  • Use !important (which is bad).
  • Watch the file inclusion order.

With @layer, you simply create priority layers.

Syntax

There are several ways to create a layer:

As a block

    @layer base {
      body { margin: 0; }
    }

On import (very convenient for third-party libraries)

/* All Bootstrap will go into 'vendor' layer and won't interfere with your styles */
@import url('bootstrap.css') layer(vendor);

Best Practice: It is best to declare the layer order at the very beginning of the CSS file so as not to get confused later.

@layer reset, lib, theme, components, utilities;

How it works

Imagine you are sorting styles into folders (layers). The order of layers is more important than the weight of selectors inside them.

In normal CSS, an ID selector #id always beats a class selector .class. But not with @layer!

/* Declare layer order: from weak to strong */
@layer framework, custom;

/* framework layer (weak priority) */
@layer framework {
  #app {
    background: blue; /* This selector has huge weight (ID) */
  }
}

/* custom layer (strong priority) */
@layer custom {
  .box {
    background: red; /* This selector has small weight (class) */
  }
}

/* HTML: <div id="app" class="box"></div> */
/* RESULT: Background will be RED. */

Important nuance: Unlayered styles. This is the most common mistake for beginners. Styles written just like that (outside any @layer) always have the highest priority.

@layer my-layer {
  p { color: red !important; }
}

/* This style is NOT in a layer */
p { color: blue; }

/* Result: text will be BLUE (unless !important is used in layer,
   but even !important in layers works trickily).
   Normal styles are considered the "final layer". */

@layer is supported in all modern browsers (Chrome, Firefox, Safari, Edge) for a couple of years now. This is not an experimental feature, you can safely use it. More details here https://caniuse.com/?search=%40layer

@scope

@scope is the long-awaited feature of native style isolation. If @layer manages the "weight" (priority) of styles, then @scope manages their scope of action (geography in the DOM).

The main task of @scope:

  • Make styles apply ONLY to a specific piece of HTML (component).
  • Make sure styles don't "leak" deeper than necessary.

This is a native replacement for methodologies like BEM (.block__element) and tools like CSS Modules (Scoped CSS), but working right in the browser.

Basic Syntax

We set a root, inside which styles work.

/* Styles apply ONLY inside element with class .card */
@scope (.card) {
  /* This img is selected only if it is inside .card */
  img {
    border-radius: 10px;
  }

  /* :scope — is a reference to the root itself (.card) */
  :scope {
    background: white;
    padding: 20px;
  }
}

What is the difference from nesting .card img { ... }? Nesting increases selector weight (specificity). @scope does not. This allows for easier style overriding.

Main Feature: "Donut Scoping"

Imagine you have a .card component, and inside it there is a .content zone where arbitrary text is inserted (e.g., from a CMS). You want to style the card frame, but don't want these styles to affect the content inside .content.

This is called a "scope cutout" (lower boundary).

Syntax: @scope (from here) to (up to here)

/* Apply styles INSIDE .card, BUT NOT INSIDE .content */
@scope (.card) to (.content) {
  
  p {
    color: gray; /* Colors card paragraphs */
  }
  
  /* If inside .content there is a <p>, this style will NOT apply to it!
     The browser will "stop" at the .content boundary */
}

This solves the eternal pain: "Why do my global styles for .title break the header inside the comments widget?". With @scope you simply exclude the widget internals.

Proximity Principle

@scope changes how the browser resolves style conflicts. In normal CSS, the "heavier selector" wins. In Scoped CSS, all other things being equal, the one closer in the DOM tree wins.

Example: We have two themes: dark (global) and light (local inside dark).

<div class="dark-theme">
  <a href="#">I am a global theme link</a>
  
  <div class="light-theme">
    <a href="#">I am a local theme link</a>
  </div>
</div>
@scope (.dark-theme) {
  a { color: white; }
}

@scope (.light-theme) {
  a { color: black; }
}

Even if selectors have the same weight, the link inside .light-theme will be black, because the .light-theme scope is geometrically closer to it in HTML than .dark-theme. Previously, we had to write .dark-theme .light-theme a { ... }.

New Generation Inline Styles

@scope can be used directly inside HTML in the <style> tag. This is super convenient for Vue/React/Svelte, if they switched to native mechanisms.

<div class="my-component">
  <style>
    @scope {
      :scope { color: red; }
      span { font-weight: bold; }
    }
  </style>
  
  <span>I am bold and red</span>
</div>
<div>
  <span>I am normal, styles above don't touch me</span>
</div>

Support is not so good yet - https://caniuse.com/?search=%40scope


That's all. Hope this article was useful to you. ❤️

New and Old CSS Features | Frontend Tales