3
\$\begingroup\$

I'm migrating to web development and wanted to learn CSS animations better. I was playing Prey last night and realized the "throbber" on the elevator screen would be interesting to reimplement. For anyone not familar with the game, it can be seen briefly here (although that's not required for the review).

Here's the end result as a snippet:

.throbber-wrapper {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr 1fr;
  justify-items: center;
  align-items: center;

  background-color: #2eb9e8;

  width: 10em;
  height: 10em;
}

.throbber-wrapper > div {
  display: inline-block;

  background-color: #93e2ff;

  // For the sake of the animation so it starts without full boxes waiting to be animated.
  width: 0;
  height: 0;
  opacity: 0;

  animation: cellShrink 1000ms infinite;
}

@keyframes cellShrink {
  0% {
    width: 100%;
    height: 100%;
    opacity: 100%;
  }

  100% {
    width: 10%;
    height: 10%;
    opacity: 10%;
  }
}

.throbber-wrapper .b2 {
     animation-delay: 62.5ms;
}
 .throbber-wrapper .b3 {
     animation-delay: 125ms;
}
 .throbber-wrapper .b4 {
     animation-delay: 187.5ms;
}
 .throbber-wrapper .b8 {
     animation-delay: 250ms;
}
 .throbber-wrapper .b7 {
     animation-delay: 312.5ms;
}
 .throbber-wrapper .b6 {
     animation-delay: 375ms;
}
 .throbber-wrapper .b5 {
     animation-delay: 437.5ms;
}
 .throbber-wrapper .b9 {
     animation-delay: 500ms;
}
 .throbber-wrapper .b10 {
     animation-delay: 562.5ms;
}
 .throbber-wrapper .b11 {
     animation-delay: 625ms;
}
 .throbber-wrapper .b12 {
     animation-delay: 687.5ms;
}
 .throbber-wrapper .b16 {
     animation-delay: 750ms;
}
 .throbber-wrapper .b15 {
     animation-delay: 812.5ms;
}
 .throbber-wrapper .b14 {
     animation-delay: 875ms;
}
 .throbber-wrapper .b13 {
     animation-delay: 937.5ms;
}
 
<div class="throbber-wrapper">
  <div class="b1"></div>
  <div class="b2"></div>
  <div class="b3"></div>
  <div class="b4"></div>
  <div class="b5"></div>
  <div class="b6"></div>
  <div class="b7"></div>
  <div class="b8"></div>
  <div class="b9"></div>
  <div class="b10"></div>
  <div class="b11"></div>
  <div class="b12"></div>
  <div class="b13"></div>
  <div class="b14"></div>
  <div class="b15"></div>
  <div class="b16"></div>
</div>

The color isn't perfect, but that's not my main concern. The main challenge when writing this was getting the timing of the squares right and written in a sane way (I think I only achieved the first goal there). A full cycle is one second, divided by 16 cells gives 62.5ms, so I decided to delay the animation of each cell by 62.5ms longer than the previous cell. That unfortunately meant manually IDing each cell, and manually writing delays for each cell. I was able to reduce the duplication a bit using SCSS nesting, but it's still atrocious.

What I'd like help with:

  • This code has a disgusting amount of duplication. I would like to know about any tricks that can neaten it up.
  • Any other CSS, SCSS, or even HTML tricks that can help make this code cleaner.

HTML (this is a component, not a full page):

<div class="throbber-wrapper">
  <div class="b1"></div>
  <div class="b2"></div>
  <div class="b3"></div>
  <div class="b4"></div>
  <div class="b5"></div>
  <div class="b6"></div>
  <div class="b7"></div>
  <div class="b8"></div>
  <div class="b9"></div>
  <div class="b10"></div>
  <div class="b11"></div>
  <div class="b12"></div>
  <div class="b13"></div>
  <div class="b14"></div>
  <div class="b15"></div>
  <div class="b16"></div>
</div>

SCSS:

.throbber-wrapper {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr 1fr;
  justify-items: center;
  align-items: center;

  background-color: #2eb9e8;

  width: 10em;
  height: 10em;
}

.throbber-wrapper > div {
  display: inline-block;

  background-color: #93e2ff;

  // For the sake of the animation so it starts without full boxes waiting to be animated.
  width: 0;
  height: 0;
  opacity: 0;

  animation: cellShrink 1000ms infinite;
}

@keyframes cellShrink {
  0% {
    width: 100%;
    height: 100%;
    opacity: 100%;
  }

  100% {
    width: 10%;
    height: 10%;
    opacity: 10%;
  }
}

.throbber-wrapper {
  & .b2 {
    animation-delay: 62.5ms;
  }

  & .b3 {
  animation-delay: 125ms;
  }

  & .b4 {
    animation-delay: 187.5ms;
  }

  & .b8 {
    animation-delay: 250ms;
  }

  & .b7 {
  animation-delay: 312.5ms;
  }

  & .b6 {
    animation-delay: 375ms;
  }

  & .b5 {
    animation-delay: 437.5ms;
  }

  & .b9 {
    animation-delay: 500ms;
  }

  & .b10 {
  animation-delay: 562.5ms;
  }

  & .b11 {
    animation-delay: 625ms;
  }

  & .b12 {
    animation-delay: 687.5ms;
  }

  & .b16 {
    animation-delay: 750ms;
  }

  & .b15 {
  animation-delay: 812.5ms;
  }

  & .b14 {
    animation-delay: 875ms;
  }

  & .b13 {
    animation-delay: 937.5ms;
  }
}
\$\endgroup\$

2 Answers 2

2
\$\begingroup\$

Since you're using SCSS, why not use a SCSS @each loop with :nth-child() for setting the animation delay? It would also remove the need for classes on your child elements.

Store the animation order of elements in a list and loop over it. Use the index of your position in the loop for an :nth-child() selector, and the current value as the animation delay multiplier.

SCSS:

@keyframes cellShrink {
  0% {
    width: 100%;
    height: 100%;
    opacity: 100%;
  }
  100% {
    width: 10%;
    height: 10%;
    opacity: 10%;
  }
}
.throbber-wrapper {
  display: grid;
  grid-template-columns: 1fr 1fr 1fr 1fr;
  justify-items: center;
  align-items: center;
  background-color: #2eb9e8;
  width: 10em;
  height: 10em;
}
.throbber-wrapper div {
  display: inline-block;
  background-color: #93e2ff;
  width: 0;
  height: 0;
  opacity: 0;
  animation: cellShrink 1000ms infinite;
}
$order: 1, 2, 3, 4, 8, 7, 6, 5, 9, 10, 11, 12, 16, 15, 14, 13;
@each $block in $order {
  $n: index($order, $block);
  .throbber-wrapper div:nth-child(#{$n}) {
    animation-delay: #{($block - 1) * 62.5}ms;
  }
}

HTML:

<div class="throbber-wrapper">
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
  <div></div>
</div>
\$\endgroup\$
5
  • \$\begingroup\$ Oh cool, I haven't gotten to loops yet (I ended up getting side tracked). Does this work though? I haven't run it, but it looks like the order would be off on every second row. \$\endgroup\$ Mar 5, 2022 at 22:10
  • 1
    \$\begingroup\$ Ah, I forgot you had it going boustrophedon. I’ll edit my answer. \$\endgroup\$
    – Sean
    Mar 5, 2022 at 23:22
  • \$\begingroup\$ Lmao. TIL there's a word for this pattern. That's good to know. I've never seen that word before. And I'm assuming you could fix that with a conditional in the loop, or something like nested loops where the inner alternates direction? I haven't fully explored what SCSS is capable of. \$\endgroup\$ Mar 5, 2022 at 23:40
  • \$\begingroup\$ @Carcigenicate I've updated my answer. Instead of conditionals or nested loops, just storing the animation order in a list is probably the simplest approach. \$\endgroup\$
    – Sean
    Mar 6, 2022 at 16:00
  • \$\begingroup\$ I didn't know SCSS had lists either. Apparently, I got side-tracked far too early. Thank you! This is a huge help. \$\endgroup\$ Mar 6, 2022 at 16:08
1
\$\begingroup\$

Thank to @RemasteredArch on Reddit for the hint.

They suggested using variables and calc to calculate the delays. After learning how to do that, I got it working, and it got rid of ~60 lines of manually written and calculated delays. It also lets me easily change the animation speed now (I might even be able to make it an input to the component).

The highlights:

  1. I made some variables. Unfortunately for now, the --n-cells variables isn't useful when creating the grid, and is only used for calculating delays:

    .throbber-wrapper {
        --full-animation-cycle-time: 1000ms;
        --n-cells: 16;
        . . .
    
  2. The entire massive nested .throbber-wrapper { block at the bottom was replaced by:

    .throbber-wrapper > div {
        animation-delay: calc(var(--order) * var(--full-animation-cycle-time) / var(--n-cells));
    }
    

Now the speed of the animation can be changed by changing --full-animation_cycle-time, and all the delays don't need to be recalculated for the animation to run properly.

Unfortunately, snippets don't appear to support variables (?), so I can't show it here but it runs identically.

HTML:

<div class="throbber-wrapper">
  <div style="--order: 1"></div>
  <div style="--order: 2"></div>
  <div style="--order: 3"></div>
  <div style="--order: 4"></div>
  <div style="--order: 8"></div>
  <div style="--order: 7"></div>
  <div style="--order: 6"></div>
  <div style="--order: 5"></div>
  <div style="--order: 9"></div>
  <div style="--order: 10"></div>
  <div style="--order: 11"></div>
  <div style="--order: 12"></div>
  <div style="--order: 16"></div>
  <div style="--order: 15"></div>
  <div style="--order: 14"></div>
  <div style="--order: 13"></div>
</div>

SCSS:

.throbber-wrapper {
  --full-animation-cycle-time: 1000ms;
  --n-cells: 16;

  display: grid;
  grid-template-columns: 1fr 1fr 1fr 1fr;
  justify-items: center;
  align-items: center;

  width: 100%;
  height: 100%;
}

.throbber-wrapper > div {
  display: inline-block;

  background-color: #0d6efc;

  // For the sake of the animation so it starts without full boxes waiting to be animated.
  width: 0;
  height: 0;
  opacity: 0;

  animation: cellShrink var(--full-animation-cycle-time) infinite;
}

@keyframes cellShrink {
  0% {
    width: 100%;
    height: 100%;
    opacity: 100%;
  }

  100% {
    width: 10%;
    height: 10%;
    opacity: 10%;
  }
}

.throbber-wrapper > div {
  animation-delay: calc(var(--order) * var(--full-animation-cycle-time) / var(--n-cells));
}
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.