Search code examples
htmlcsscss-animationslinear-gradients

How to animate linear-gradient without using background-position?


I'm trying to create content loader but got a performance problem with background animation. It's smooth when there are only a few elements on the screen but dramatically drops fps while increasing stub elements count to 20-30. Now I know that animating background-position property is a bad idea and it is better to use transforms for this. but how can I do this? I'd like to keep seamless animation. Gradient should be relative to screen, not to container.

Here is some code:

const cardsRoot = document.getElementById('cards')
const addButton = document.getElementById('add')
const card = document.getElementsByClassName('card')[0]
let cardsCount = 1

addButton.addEventListener('click', () => {
  cardsRoot.innerHTML = ''
  cardsCount++
  for (let i = 0; i < cardsCount; i++) {
    let cardClone = card.cloneNode(true)
    cardsRoot.appendChild(cardClone)
  }
})
body {
  padding: 40px;
}

.card {
  display: flex;
  margin-top: 20px;
}

.stub {
  width: 300px;
  height: 12px;
  margin: 8px;
  border-radius: 8px;
  background: linear-gradient(to right, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0.1) 10%, rgba(0, 0, 0, 0.04) 20%) fixed;
  animation: stub 1.3s linear infinite;
  margin-bottom: 8px;
}

.circle {
  width: 40px;
  height: 40px;
  margin-right: 12px;
  border-radius: 20px;
}

@keyframes stub {
  0% { background-position: 0vw; }
  100% { background-position: 100vw; }
}
<button id="add">
  ADD CARD
</button>

<div id="cards">
  <div class="card">
    <div>
      <div class="stub circle"></div>
    </div>
    <div>
      <div class="stub"></div>
      <div class="stub"></div>
      <div class="stub"></div>
    </div> 
  </div>
</div>

And demo: https://jsfiddle.net/3da4uzm2/57/


Solution

  • You can replace the animation using pseudo element where you apply translation. The trick is to consider fixed element to replace background-attachment:fixed, then you make the element twice bigger as the screen and you translate it from left to right.

    const cardsRoot = document.getElementById('cards')
    const addButton = document.getElementById('add')
    const card = document.getElementsByClassName('card')[0]
    let cardsCount = 1
    
    addButton.addEventListener('click', () => {
      cardsRoot.innerHTML = ''
      cardsCount++
      for (let i = 0; i < cardsCount; i++) {
        let cardClone = card.cloneNode(true)
        cardsRoot.appendChild(cardClone)
      }
    })
    body {
      padding: 40px;
    }
    
    .card {
      display: inline-flex;
      margin-top: 20px;
    }
    
    .stub {
      width: 150px;
      height: 12px;
      margin: 8px;
      border-radius: 8px;
      margin-bottom: 8px;
      position:relative;
      z-index:0;
      /*overflow:hidden; this is no more working, using mask instead */
      -webkit-mask:linear-gradient(#fff 0 0);
      /* OR clip-path:inset(0) */
    }
    .stub:before {
      content:"";
      position:fixed;
      z-index:-1;
      top:0;
      right:0;
      width:200vw;
      bottom:0;
      background: 
        linear-gradient(rgba(0, 0, 0, 0.04),rgba(0, 0, 0, 0.04)) left/50% 100%,
        linear-gradient(to right, rgba(0, 0, 0, 0.04), rgba(0, 0, 0, 0.1) 10%, rgba(0, 0, 0, 0.04) 20%) right/50% 100%;
      background-repeat:no-repeat;
      animation: stub 1.3s linear infinite;
      pointer-events:none;
    }
    
    .circle {
      width: 40px;
      height: 40px;
      margin-right: 12px;
      border-radius: 20px;
    }
    
    @keyframes stub {
      0% { transform:translate(0); }
      100% { transform:translate(50%); }
    }
    <button id="add">
      ADD CARD
    </button>
    
    <div id="cards">
      <div class="card">
        <div>
          <div class="stub circle"></div>
        </div>
        <div>
          <div class="stub"></div>
          <div class="stub"></div>
          <div class="stub"></div>
        </div> 
      </div>
      <div class="card">
        <div>
          <div class="stub circle"></div>
        </div>
        <div>
          <div class="stub"></div>
          <div class="stub"></div>
          <div class="stub"></div>
        </div> 
      </div>
    </div>

    To better understand what is happening here is a simplified version with only one element where I also changed the gradient colors.

    body:before {
      content:"";
      position:fixed; /*relative to the screen*/
      z-index:-1;
      top:0;
      right:0;
      width:200vw; /*2x100vw*/
      bottom:0;
      background: 
        /*will cover the left area while sliding*/
        linear-gradient(red,red) left/50% 100%, /*the red should be green*/
        /*the main gradient*/
        linear-gradient(to right, green, blue 10%, green 20%) right/50% 100%;
      background-repeat:no-repeat;
      animation: stub 3s linear infinite;
    }
    @keyframes stub {
      0% { transform:translate(0); }
      100% { transform:translate(50%); } /*50% will be 200vw/2 = 100vw*/
    }

    Related to understand the trick behind the background values: Using percentage values with background-position on a linear-gradient