csscolorscss-transitionsbackground-color

How to avoid mismatched background colors during CSS transitions with transparency?


Scenario

I recently made an interesting observation in my CSS that took me a little while to understand exactly what was happening.

For example, consider the following code snippet, in which the parent and child elements receive exactly the same background-color at a given scroll value. Both elements also have the same transition defined for the background property.

As you scroll, notice how the background color of the child elements clearly differs from the background color of the parent element during the transition.

function toggleClassOnScroll() {
  const scrollPosition = window.scrollY;
  const threshold = 40;

  if (scrollPosition > threshold) {
    document.body.classList.add("js-scrolled");
  } else {
    document.body.classList.remove("js-scrolled");
  }
}

toggleClassOnScroll();
window.addEventListener("scroll", toggleClassOnScroll);
:root {
  --scroll-transition: background 1s ease;
  --scroll-background: darkgrey;
}

nav {
  background-color: transparent;
  transition: var(--scroll-transition);
}

.js-scrolled nav {
  background-color: var(--scroll-background);
}

nav a {
  background-color: transparent;
  transition: var(--scroll-transition);
}

.js-scrolled nav a {
  background-color: var(--scroll-background);
}

/* setup (visual) */

body {
  min-height: 200vh;
}

nav {
  position: fixed;
  top: 0;
  left: 0;
  display: flex;
  justify-content: center;
  border-bottom: 2px dashed var(--scroll-background);
  width: 100%;
}

nav ul {
  display: flex;
  gap: 1rem;
  margin: 0;
  padding: 0;
  list-style-type: none;
}

nav a {
  position: relative;
  display: block;
  padding: 0.75em;
  font-size: 1.5rem;
  text-decoration: none;
  color: black;
}

nav a:before {
  content: "";
  position: absolute;
  inset: 0;
  background-color: inherit;
  mix-blend-mode: multiply;
  opacity: 0;
}

nav a:hover::before,
nav a:focus::before {
  opacity: 1;
}
<nav>
  <ul>
    <li><a href="#">Colors</a></li>
    <li><a href="#">Transparency</a></li>
    <li><a href="#">Transitions</a></li>
    <li><a href="#">About</a></li>
  </ul>
</nav>

Please note that in my use case, it is necessary that the child elements also receive the background color because of the mix-blend-mode hover effect on the nav item links.

Findings

I think I have an explanation for this (rendering) behavior of the different background colors during the transition:

Since both background colors have the initial value set to transparent, an alpha channel is introduced, which then technically animates from 0 to 1, resulting in the full color defined on scroll.
So it should be the same as defining the background-color in, e.g., rgb() notation with an initial alpha value of 0 and setting this color with an alpha value of 1 on scroll.

rgb(169 169 169 / 0) -> rgb(169 169 169 / 1)

Because of the overlapping background colors with transparency, we get different colors during the transition.
This can be illustrated with a simple Venn diagram:

4-set Venn diagram in blue transparent Attribution: Amousey, CC0, via Wikimedia Commons

Solution missing

Depending on the defined background-color, transition-duration, and transition-timing-function, I personally find this behavior visually quite disturbing.

I was wondering if there is a way (or a tool) to prevent the visual difference during the transition.
Perhaps there is a mathematical function that can be used to calculate this based on the target color, the animation and the overlapping layer?
Or am I thinking too complicated? Is this situation actually common, and is there a ("simpler") solution?


Solution

  • You might set background-color of link element on :hover only:

    function toggleClassOnScroll() {
      const scrollPosition = window.scrollY;
      const threshold = 40;
    
      if (scrollPosition > threshold) {
        document.body.classList.add("js-scrolled");
      } else {
        document.body.classList.remove("js-scrolled");
      }
    }
    
    toggleClassOnScroll();
    window.addEventListener("scroll", toggleClassOnScroll);
    :root {
      --scroll-transition: background 1s ease;
      --scroll-background: darkgrey;
    }
    
    nav {
      background-color: transparent;
      transition: var(--scroll-transition);
    }
    
    .js-scrolled nav {
      background-color: var(--scroll-background);
    }
    
    .js-scrolled nav a:hover,
    .js-scrolled nav a:focus {
      background-color: var(--scroll-background);
    }
    
    /* setup (visual) */
    
    body {
      min-height: 200vh;
    }
    
    nav {
      position: fixed;
      top: 0;
      left: 0;
      display: flex;
      justify-content: center;
      border-bottom: 2px dashed var(--scroll-background);
      width: 100%;
    }
    
    nav ul {
      display: flex;
      gap: 1rem;
      margin: 0;
      padding: 0;
      list-style-type: none;
    }
    
    nav a {
      position: relative;
      display: block;
      padding: 0.75em;
      font-size: 1.5rem;
      text-decoration: none;
      color: black;
    }
    
    nav a:before {
      content: "";
      position: absolute;
      inset: 0;
      background-color: inherit;
      mix-blend-mode: multiply;
      opacity: 0;
    }
    
    nav a:hover::before,
    nav a:focus::before {
      opacity: 1;
    }
    <nav>
      <ul>
        <li><a href="#">Colors</a></li>
        <li><a href="#">Transparency</a></li>
        <li><a href="#">Transitions</a></li>
        <li><a href="#">About</a></li>
      </ul>
    </nav>

    Also I think your code is a bit excessive, but probably there's a reason for that.