javascriptreactjsanimationframer-motion

Expericing visual glitches in an infinite looping animation with React and Framer Motion


I'm trying to create an infinite looping animation for a list of items using React and Framer Motion. The intended sequence is:

  1. Left column items move down, with the last item fading out.
  2. The top item from the right column moves to the top of the left column.
  3. Right column items move up to fill the space, and a new item fades in at the bottom.

Diagram:

enter image description here

The animation works, but I'm encountering visual glitches where items flash or momentarily appear in the wrong order during the loop which is affecting the smoothness of the animation.

Expected behavior: Continuous, smooth transitions between states without any flashing or incorrect ordering.

Actual behavior: Items occasionally flash or appear out of order between loops.

Code:

import React, { useEffect, useState } from "react";
import { motion, useAnimationControls } from "framer-motion";

const ITEMS_LIST = [
  "Item 1",
  "Item 2",
  "Item 3",
  "Item 4",
  "Item 5",
  "Item 6",
  "Item 7",
  "Item 8",
  "Item 9",
  "Item 10",
  "Item 11",
  "Item 12",
];

function getItemAt(index: number) {
  // Handle positive and negative indices with modulo
  const adjustedIndex =
    ((index % ITEMS_LIST.length) + ITEMS_LIST.length) % ITEMS_LIST.length;
  return ITEMS_LIST[adjustedIndex];
}

export default function App() {
  const [loopIndex, setLoopIndex] = useState<number>(0);

  const controlsLeftItem1 = useAnimationControls();
  const controlsLeftItem2 = useAnimationControls();
  const controlsLeftItem3 = useAnimationControls();
  const controlsLeftItem4 = useAnimationControls();

  const controlsRightItem1 = useAnimationControls();
  const controlsRightItem2 = useAnimationControls();
  const controlsRightItem3 = useAnimationControls();
  const controlsRightItem4 = useAnimationControls();
  const controlsRightItem5 = useAnimationControls();
  const controlsRightItem6 = useAnimationControls();
  const controlsRightItem7 = useAnimationControls();
  const controlsRightItem8 = useAnimationControls();

  const leftItem1 = getItemAt(loopIndex - 1);
  const leftItem2 = getItemAt(loopIndex - 2);
  const leftItem3 = getItemAt(loopIndex - 3);
  const leftItem4 = getItemAt(loopIndex - 4);

  const rightItem1 = getItemAt(loopIndex);
  const rightItem2 = getItemAt(loopIndex + 1);
  const rightItem3 = getItemAt(loopIndex + 2);
  const rightItem4 = getItemAt(loopIndex + 3);
  const rightItem5 = getItemAt(loopIndex + 4);
  const rightItem6 = getItemAt(loopIndex + 5);
  const rightItem7 = getItemAt(loopIndex + 6);
  const rightItem8 = getItemAt(loopIndex + 7);

  useEffect(() => {
    Promise.all([
      // Animate left column
      controlsLeftItem1.start(
        { y: "calc(100% + 0.75rem)" },
        { type: "spring", duration: 1 }
      ),
      controlsLeftItem2.start(
        { y: "calc(100% + 0.75rem)" },
        { type: "spring", duration: 1 }
      ),
      controlsLeftItem3.start(
        { y: "calc(100% + 0.75rem)" },
        { type: "spring", duration: 1 }
      ),
      controlsLeftItem4.start(
        { y: "calc(100% + 0.75rem)", opacity: 0 },
        { type: "spring", duration: 1 }
      ),

      // Animate active item from right to left
      controlsRightItem1.start(
        { x: "calc((100% + 122px) * -1)" },
        { type: "spring", duration: 1, delay: 0.25 }
      ),

      // Animate right column
      controlsRightItem2.start(
        { y: "calc((100% + 0.75rem) * -1)" },
        { type: "spring", duration: 1, delay: 0.5 }
      ),
      controlsRightItem3.start(
        { y: "calc((100% + 0.75rem) * -1)" },
        { type: "spring", duration: 1, delay: 0.5 }
      ),
      controlsRightItem4.start(
        { y: "calc((100% + 0.75rem) * -1)" },
        { type: "spring", duration: 1, delay: 0.5 }
      ),
      controlsRightItem5.start(
        { y: "calc((100% + 0.75rem) * -1)" },
        { type: "spring", duration: 1, delay: 0.5 }
      ),
      controlsRightItem6.start(
        { y: "calc((100% + 0.75rem) * -1)" },
        { type: "spring", duration: 1, delay: 0.5 }
      ),
      controlsRightItem7.start(
        { y: "calc((100% + 0.75rem) * -1)" },
        { type: "spring", duration: 1, delay: 0.5 }
      ),
      controlsRightItem8.start(
        { y: "calc((100% + 0.75rem) * -1)", opacity: 1 },
        { type: "spring", duration: 1, delay: 1 }
      ),
    ]).then(() => {
      window.setTimeout(() => {
        setLoopIndex((old) => old + 1);

        window.setTimeout(() => {
          controlsLeftItem1.set({ y: "calc(0% + 0rem)" });
          controlsLeftItem2.set({ y: "calc(0% + 0rem)" });
          controlsLeftItem3.set({ y: "calc(0% + 0rem)" });
          controlsLeftItem4.set({ y: "calc(0% + 0rem)", opacity: 1 });

          controlsRightItem1.set({ x: "calc((0% + 0px) * -1)" });
          controlsRightItem2.set({ y: "calc((0% + 0rem) * -1)" });
          controlsRightItem3.set({ y: "calc((0% + 0rem) * -1)" });
          controlsRightItem4.set({ y: "calc((0% + 0rem) * -1)" });
          controlsRightItem5.set({ y: "calc((0% + 0rem) * -1)" });
          controlsRightItem6.set({ y: "calc((0% + 0rem) * -1)" });
          controlsRightItem7.set({ y: "calc((0% + 0rem) * -1)" });
          controlsRightItem8.set({ y: "calc((0% + 0rem) * -1)", opacity: 0 });
        });
      }, 1000);
    });
  }, [loopIndex]);

  return (
    <div className="relative w-[670px] pb-[3.75rem] pt-3">
      <div className="absolute right-0 top-0 bg-red-300 text-white">
        {loopIndex}
      </div>
      <div className="relative">
        <div className="absolute inset-0 z-0 rounded-3xl border-[5px] border-white/20 bg-white/35" />
        <div className="relative z-10 flex justify-between pr-6">
          <ul className="flex flex-col gap-3 p-7">
            <li className="pb-5 opacity-50">
              <Item label="Static 1" />
            </li>
            <motion.li animate={controlsLeftItem1}>
              <Item label={leftItem1} />
            </motion.li>
            <motion.li animate={controlsLeftItem2}>
              <Item label={leftItem2} />
            </motion.li>
            <motion.li animate={controlsLeftItem3}>
              <Item label={leftItem3} />
            </motion.li>
            <motion.li animate={controlsLeftItem4}>
              <Item label={leftItem4} />
            </motion.li>
            <li className="pt-5 opacity-50">
              <Item label="Static 2" />
            </li>
            <li className="opacity-50">
              <Item label="Static 3" />
            </li>
          </ul>
          <ul className="-mb-20 -mt-3 flex flex-col gap-3">
            <li className="opacity-50">
              <Item label="Static 1" />
            </li>
            <li className="opacity-50">
              <Item label="Static 2" />
            </li>
            <motion.li animate={controlsRightItem1}>
              <Item label={rightItem1} />
            </motion.li>
            <motion.li animate={controlsRightItem2}>
              <Item label={rightItem2} />
            </motion.li>
            <motion.li animate={controlsRightItem3}>
              <Item label={rightItem3} />
            </motion.li>
            <motion.li animate={controlsRightItem4}>
              <Item label={rightItem4} />
            </motion.li>
            <motion.li animate={controlsRightItem5}>
              <Item label={rightItem5} />
            </motion.li>
            <motion.li animate={controlsRightItem6}>
              <Item label={rightItem6} />
            </motion.li>
            <motion.li animate={controlsRightItem7}>
              <Item label={rightItem7} />
            </motion.li>
            <motion.li animate={controlsRightItem8}>
              <Item label={rightItem8} />
            </motion.li>
          </ul>
        </div>
      </div>
    </div>
  );
}

function Item({ label }: { label: string }) {
  return (
    <div className="flex h-12 w-[248px] flex-shrink-0 flex-grow-0 items-center gap-4 rounded-xl bg-white/50 px-4">
      <span className="flex-shrink-0 flex-grow-0">
        <div className="w-6 h-6 rounded-full bg-white" />
      </span>
      <span className="flex-1 text-sm font-medium uppercase leading-none tracking-widest">
        {label}
      </span>
    </div>
  );
}

You can see the issue in action here:

enter image description here

Here's a link to the CodeSandbox:

https://codesandbox.io/p/sandbox/quizzical-heisenberg-lyr7ct

Questions:

Any advice would be greatly appreciated! I'm relatively new to React, so if there are more "React-like" approaches to handle this, please tell me!


Solution

  • Solved!

    I fixed the glitches by:

    1. Removing the use of the useAnimationControls.set({...}) and moving values to the <motion.li initial={{...}}> prop.
    2. I then added unique keys to each of the motion elements to trigger a re-render when the loopIndex state gets updated e.g. <motion.li key={`left-${loopIndex}-1`}>
    3. I added a cleanup function to the useEffect.

    Here's the resulting code:

    import React, { useEffect, useState } from "react";
    import { motion, useAnimationControls } from "framer-motion";
    
    const ITEMS_LIST = [
      "Item 1",
      "Item 2",
      "Item 3",
      "Item 4",
      "Item 5",
      "Item 6",
      "Item 7",
      "Item 8",
      "Item 9",
      "Item 10",
      "Item 11",
      "Item 12",
    ];
    
    function getItemAt(index: number) {
      // Handle positive and negative indices with modulo
      const adjustedIndex =
        ((index % ITEMS_LIST.length) + ITEMS_LIST.length) % ITEMS_LIST.length;
      return ITEMS_LIST[adjustedIndex];
    }
    
    export default function App() {
      const [loopIndex, setLoopIndex] = useState<number>(0);
    
      const controlsLeftItem1 = useAnimationControls();
      const controlsLeftItem2 = useAnimationControls();
      const controlsLeftItem3 = useAnimationControls();
      const controlsLeftItem4 = useAnimationControls();
    
      const controlsRightItem1 = useAnimationControls();
      const controlsRightItem2 = useAnimationControls();
      const controlsRightItem3 = useAnimationControls();
      const controlsRightItem4 = useAnimationControls();
      const controlsRightItem5 = useAnimationControls();
      const controlsRightItem6 = useAnimationControls();
      const controlsRightItem7 = useAnimationControls();
      const controlsRightItem8 = useAnimationControls();
    
      const leftItem1 = getItemAt(loopIndex - 1);
      const leftItem2 = getItemAt(loopIndex - 2);
      const leftItem3 = getItemAt(loopIndex - 3);
      const leftItem4 = getItemAt(loopIndex - 4);
    
      const rightItem1 = getItemAt(loopIndex);
      const rightItem2 = getItemAt(loopIndex + 1);
      const rightItem3 = getItemAt(loopIndex + 2);
      const rightItem4 = getItemAt(loopIndex + 3);
      const rightItem5 = getItemAt(loopIndex + 4);
      const rightItem6 = getItemAt(loopIndex + 5);
      const rightItem7 = getItemAt(loopIndex + 6);
      const rightItem8 = getItemAt(loopIndex + 7);
    
      useEffect(() => {
        let timeout: null | number = null;
        Promise.all([
          // Animate left column
          controlsLeftItem1.start(
            { y: "calc(100% + 0.75rem)" },
            { type: "spring", duration: 1 }
          ),
          controlsLeftItem2.start(
            { y: "calc(100% + 0.75rem)" },
            { type: "spring", duration: 1 }
          ),
          controlsLeftItem3.start(
            { y: "calc(100% + 0.75rem)" },
            { type: "spring", duration: 1 }
          ),
          controlsLeftItem4.start(
            { y: "calc(100% + 0.75rem)", opacity: 0 },
            { type: "spring", duration: 1 }
          ),
    
          // Animate active item from right to left
          controlsRightItem1.start(
            { x: "calc((100% + 122px) * -1)" },
            { type: "spring", duration: 1, delay: 0.25 }
          ),
    
          // Animate right column
          controlsRightItem2.start(
            { y: "calc((100% + 0.75rem) * -1)" },
            { type: "spring", duration: 1, delay: 0.5 }
          ),
          controlsRightItem3.start(
            { y: "calc((100% + 0.75rem) * -1)" },
            { type: "spring", duration: 1, delay: 0.5 }
          ),
          controlsRightItem4.start(
            { y: "calc((100% + 0.75rem) * -1)" },
            { type: "spring", duration: 1, delay: 0.5 }
          ),
          controlsRightItem5.start(
            { y: "calc((100% + 0.75rem) * -1)" },
            { type: "spring", duration: 1, delay: 0.5 }
          ),
          controlsRightItem6.start(
            { y: "calc((100% + 0.75rem) * -1)" },
            { type: "spring", duration: 1, delay: 0.5 }
          ),
          controlsRightItem7.start(
            { y: "calc((100% + 0.75rem) * -1)" },
            { type: "spring", duration: 1, delay: 0.5 }
          ),
          controlsRightItem8.start(
            { y: "calc((100% + 0.75rem) * -1)", opacity: 1 },
            { type: "spring", duration: 1, delay: 0.5 }
          ),
        ]).then(() => {
          timeout = window.setTimeout(() => {
            setLoopIndex((old) => old + 1);
          }, 1000);
        });
    
        return () => {
          controlsLeftItem1.stop();
          controlsLeftItem2.stop();
          controlsLeftItem3.stop();
          controlsLeftItem4.stop();
    
          controlsRightItem1.stop();
          controlsRightItem2.stop();
          controlsRightItem3.stop();
          controlsRightItem4.stop();
          controlsRightItem5.stop();
          controlsRightItem6.stop();
          controlsRightItem7.stop();
          controlsRightItem8.stop();
    
          if (timeout !== null) window.clearTimeout(timeout);
        };
      }, [loopIndex]);
    
      return (
        <div className="relative w-[670px] pb-[3.75rem] pt-3">
          <div className="absolute right-0 top-0 bg-red-300 text-white">
            {loopIndex}
          </div>
          <div className="relative">
            <div className="absolute inset-0 z-0 rounded-3xl border-[5px] border-white/20 bg-white/35" />
            <div className="relative z-10 flex justify-between pr-6">
              <ul className="flex flex-col gap-3 p-7">
                <li className="pb-5 opacity-50">
                  <Item label="Static 1" />
                </li>
                <motion.li
                  key={`left-${loopIndex}-1`}
                  animate={controlsLeftItem1}
                  initial={{ y: "calc(0% + 0rem)" }}
                >
                  <Item label={leftItem1} />
                </motion.li>
                <motion.li
                  key={`left-${loopIndex}-2`}
                  animate={controlsLeftItem2}
                  initial={{ y: "calc(0% + 0rem)" }}
                >
                  <Item label={leftItem2} />
                </motion.li>
                <motion.li
                  key={`left-${loopIndex}-3`}
                  animate={controlsLeftItem3}
                  initial={{ y: "calc(0% + 0rem)" }}
                >
                  <Item label={leftItem3} />
                </motion.li>
                <motion.li
                  key={`left-${loopIndex}-4`}
                  animate={controlsLeftItem4}
                  initial={{ y: "calc(0% + 0rem)", opacity: 1 }}
                >
                  <Item label={leftItem4} />
                </motion.li>
                <li className="pt-5 opacity-50">
                  <Item label="Static 2" />
                </li>
                <li className="opacity-50">
                  <Item label="Static 3" />
                </li>
              </ul>
              <ul className="-mb-20 -mt-3 flex flex-col gap-3">
                <li className="opacity-50">
                  <Item label="Static 1" />
                </li>
                <li className="opacity-50">
                  <Item label="Static 2" />
                </li>
                <motion.li
                  key={`right-${loopIndex}-1`}
                  animate={controlsRightItem1}
                  initial={{ x: "calc((0% + 0px) * -1)" }}
                >
                  <Item label={rightItem1} />
                </motion.li>
                <motion.li
                  key={`right-${loopIndex}-2`}
                  animate={controlsRightItem2}
                  initial={{ y: "calc((0% + 0rem) * -1)" }}
                >
                  <Item label={rightItem2} />
                </motion.li>
                <motion.li
                  key={`right-${loopIndex}-3`}
                  animate={controlsRightItem3}
                  initial={{ y: "calc((0% + 0rem) * -1)" }}
                >
                  <Item label={rightItem3} />
                </motion.li>
                <motion.li
                  key={`right-${loopIndex}-4`}
                  animate={controlsRightItem4}
                  initial={{ y: "calc((0% + 0rem) * -1)" }}
                >
                  <Item label={rightItem4} />
                </motion.li>
                <motion.li
                  key={`right-${loopIndex}-5`}
                  animate={controlsRightItem5}
                  initial={{ y: "calc((0% + 0rem) * -1)" }}
                >
                  <Item label={rightItem5} />
                </motion.li>
                <motion.li
                  key={`right-${loopIndex}-6`}
                  animate={controlsRightItem6}
                  initial={{ y: "calc((0% + 0rem) * -1)" }}
                >
                  <Item label={rightItem6} />
                </motion.li>
                <motion.li
                  key={`right-${loopIndex}-7`}
                  animate={controlsRightItem7}
                  initial={{ y: "calc((0% + 0rem) * -1)" }}
                >
                  <Item label={rightItem7} />
                </motion.li>
                <motion.li
                  key={`right-${loopIndex}-8`}
                  animate={controlsRightItem8}
                  initial={{ y: "calc((0% + 0rem) * -1)", opacity: 0 }}
                >
                  <Item label={rightItem8} />
                </motion.li>
              </ul>
            </div>
          </div>
        </div>
      );
    }
    
    function Item({ label }: { label: string }) {
      return (
        <div className="flex h-12 w-[248px] flex-shrink-0 flex-grow-0 items-center gap-4 rounded-xl bg-white/50 px-4">
          <span className="flex-shrink-0 flex-grow-0">
            <div className="w-6 h-6 rounded-full bg-white" />
          </span>
          <span className="flex-1 text-sm font-medium uppercase leading-none tracking-widest">
            {label}
          </span>
        </div>
      );
    }
    

    You can see the result here:

    enter image description here

    And here's a link to the updated CodeSandbox:

    https://codesandbox.io/p/sandbox/react-animation-fixed-t6r38c