Skip to content
Advertisement

Timing in my React text animation gets worse on subsequent loops through an array

I have React code with a CSS animation in a codesandbox and on my staging site.

You will notice that over time, the animation timing drifts. After a certain number of loops it presents the text too early and is not in sync with the animation.

I have tried changing the timing making the array switch happen faster and slower.

Any ideas would be greatly appreciated.

import "./styles.css";
import styled, { keyframes } from "styled-components";
import React, { useEffect, useState } from "react";

const animation = keyframes`
  0% { opacity: 0; transform: translateY(-100px) skewX(10deg) skewY(10deg) rotateZ(30deg); filter: blur(10px); }
  25% { opacity: 1; transform: translateY(0px) skewX(0deg) skewY(0deg) rotateZ(0deg); filter: blur(0px); }
  75% { opacity: 1; transform: translateY(0px) skewX(0deg) skewY(0deg) rotateZ(0deg); filter: blur(1px); }
  100% { opacity: 0; transform: translateY(-100px) skewX(10deg) skewY(10deg) rotateZ(30deg); filter: blur(10px); }
`;

const StaticText = styled.div`
  position: absolute;
  top: 100px;
  h1 {
    color: #bcbcbc;
  }
  span {
    color: red;
  }
  h1,
  span {
    font-size: 5rem;
    @media (max-width: 720px) {
      font-size: 3rem;
    }
  }
  width: 50%;
  text-align: center;
  left: 50%;
  margin-left: -25%;
`;

const Animate = styled.span`
  display: inline-block;

  span {
    opacity: 0;
    display: inline-block;
    animation-name: ${animation};
    animation-duration: 3s;
    animation-timing-function: cubic-bezier(0.075, 0.82, 0.165, 1);
    animation-fill-mode: forwards;
    animation-iteration-count: infinite;
    font-weight: bold;
  }
  span:nth-child(1) {
    animation-delay: 0.1s;
  }
  span:nth-child(2) {
    animation-delay: 0.2s;
  }
  span:nth-child(3) {
    animation-delay: 0.3s;
  }
  span:nth-child(4) {
    animation-delay: 0.4s;
  }
  span:nth-child(5) {
    animation-delay: 0.5s;
  }
`;

export default function App() {
  const array = ["wood", "cork", "leather", "vinyl", "carpet"];

  const [text, setText] = useState(array[0].split(""));

  const [countUp, setCountUp] = useState(0);

  useEffect(() => {
    const id = setTimeout(() => {
      if (countUp === array.length -1) {
        setCountUp(0);
      } else {
        setCountUp((prev) => prev + 1);
      }
    }, 3000);

    return () => {
      clearTimeout(id);
    };
  }, [countUp]);

  useEffect(() => {
    setText(array[countUp].split(""));
  }, [countUp]);

  return (
    <div className="App">
      <StaticText>
        <h1>More than just</h1>
        <Animate>
          {text.map((item, index) => (
            <span key={index}>{item}</span>
          ))}
        </Animate>
      </StaticText>
    </div>
  );
}

Answer

There are multiple potential issues here. For one, the animation runs for up to 3.5 seconds (due to the delay) but the text changes every 3 seconds, so the text change would trigger before the last character finishes animating.

Even if the text and animation were both set to 3s, the problem is that CSS animation and setTimeout/setInterval timing aren’t perfect. These should be considered rough estimates. A setTimeout can take 3s to fire, or 3.1s, and even if it fires on time, React has to do work before another one is set. Drift can and will occur, so the animation should run in an event-driven manner whenever the text changes, not as an infinite loop that we assume will stay in sync with React and the timeout.

Adjustments you can try to fix these issues with include:

  1. Remove the animation-iteration-count: infinite; property. This holds us accountable for triggering the animation in response to re-renders, not in a separate, likely-out-of-sync loop.

  2. Change the setTimeout timeout to 3500, or something that is at least as large as the longest animation duration to make sure the animation isn’t chopped off partway through.

  3. Provide random keys to your letter <span>s to force rerenders as described in How to trigger a CSS animation on EVERY TIME a react component re-renders. To be precise, that could be <span key={Math.random()}>{item}</span>.

    You can have key clashes using Math.random(), so using an incrementing state counter or integrating Date.now() for keys is a more robust way to go here.

Advertisement