Search code examples
htmlcssreactjstransition

Why does omitting a 0ms sleep break my css transition?


I was trying to implement the FLIP animation, to see if I understood it properly.

In this codepen (pardon the awful code, I was just hacking around), if I comment out the sleep, the smooth transition no longer works. The div changes position abruptly. This is strange because the sleep is for 0ms.

import React, { useRef, useState } from "https://esm.sh/react@18";
import ReactDOM from "https://esm.sh/react-dom@18";

let first = {}
let second =  {}

const sleep = async (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const App = () => {
  const [start, setStart] = useState(true);
  
  const boxRefCb = async el => {
    if (!el) return;

    el.style.transition = "";
    const x = parseInt(el?.getBoundingClientRect().x, 10);
    const y = parseInt(el?.getBoundingClientRect().y, 10);
    first = { x: second.x, y: second.y };
    second = { x, y };
    
    const dx = first.x - second.x;
    const dy = first.y - second.y;

    const transStr = `translate(${dx}px, ${dy}px)`;
    el.style.transform = transStr;
    await sleep(0); // comment me out
    el.style.transition = "transform .5s";
    el.style.transform = "";
  }
  
  return (
    <>
    <div style={{ display: "flex", gap: "1rem", padding: "3rem"}}>
      <div ref={ start ? boxRefCb : null } style={{ visibility: start ? "" : "hidden", width: 100, height: 100, border: "solid 1px grey" }}></div>
      <div  ref={ !start ? boxRefCb : null } style={{ visibility: !start ? "" : "hidden", width: 100, height: 100, border: "solid 1px grey" }}></div>
    </div>
      
    <button style={{ marginLeft: "3rem"}} onClick={() => setStart(start => !start)}>start | {start.toString()}</button>
    </>
  );
}

ReactDOM.render(<App />,
document.getElementById("root"))

I suspect this is some event loop magic that I don't understand. Could someone shed some light onto this for me please?


Solution

  • What happens is that the browser may have time to recalculate the CSSOM boxes (a.k.a "perform a reflow"), during that sleep. Without it, your transform rule isn't ever really applied.
    Indeed, browsers will wait until it's really needed before applying the changes you made, and update the whole page box model, because doing so can be very expensive.
    When you do something like

    element.style.color = "red";
    element.style.color = "yellow";
    element.style.color = "green";
    

    all the CSSOM will see is the latest state, "green". The other two are just discarded.

    So in your code, when you don't let the event loop actually loop, the transStr value is never seen either.

    However, relying on a 0ms setTimeout is a call to issues, there is nothing that does ensure that the styles will get recalculated at that time. Instead, it's better to force a recalc manually. Some DOM methods/properties will do so synchronously. But remember that a reflow can be a very expensive operation, so be sure to use it sporadically, and if you have multiple places in your code in need of this, be sure to concatenate them all so that a single reflow is performed.

    const el = document.querySelector(".elem");
    const move = () => {
      el.style.transition = "";
      const transStr = `translate(150px, 0px)`;
      el.style.transform = transStr;
      const forceReflow = document.querySelector("input").checked;
      if (forceReflow) {
        el.offsetWidth;
      }
      el.style.transition = "transform .5s";
      el.style.transform = "";
    }
    document.querySelector("button").onclick = move;
    .elem {
      width: 100px;
      height: 100px;
      border: 1px solid grey;
    }
    .parent {
      display: flex;
      padding: 3rem;
    }
    <label><input type=checkbox checked>force reflow</label>
    <button>move</button>
    <div class=parent>
      <div class=elem></div>
    </div>

    Or with OP's code.