0

There are several StackOverflow posts about why changing an element's display from 'none' to 'box/flex/etc.' and making a CSS transition kick-in doesn't work. The suggested solution, which makes sense, is to use requestAnimationFrame to make the CSS transition kick-in after the reflow.

One answer with a good explanation can be found here.

I've come across an interesting case where that doesn't work: when doing so in a 'transitionend' callback. Somehow, the requestAnimationFrame isn't enough in this case.

Quick setup: A blue and red box. Blue box is faded in with this technique and it works. On blue box's transitionend, it is hidden and we use the same technique to show the red box. The red box shows instantly instead of fading in/having the opacity transition. Also, added an onClick for the red box to redo the transition in the same manner - and it works.

let elem = document.getElementById("elem");
let elem2 = document.getElementById("elem2");

function addFadeElem2() {
  elem2.classList.add("fade");
}

function switchElements() {
    elem.classList.remove("fade") ;
    elem2.style.display = "block";
    window.requestAnimationFrame(addFadeElem2);
}

elem.style.display = "block";
window.requestAnimationFrame(() => {
  elem.classList.add("fade");
});

function onTransitionEnd() {
  switchElements();
  //elem.offsetHeight; // Works with forced reflow
  elem.removeEventListener("transitionend", onTransitionEnd);
}

elem.addEventListener("transitionend", onTransitionEnd);

function refade() {
  elem2.style.display = "none";
  elem2.classList.remove("fade");
  window.requestAnimationFrame(() =>
  {
    elem2.style.display = "block";
    window.requestAnimationFrame(addFadeElem2);
  });
}
#elem {
  background-color: blue;
}

#elem2 {
  background-color: red;
}

.box {
  display: none;
  opacity : 0;
    transition: opacity 1s;
    transition-timing-function: ease-in;
  width: 300px;
  height: 250px;
}

.box.fade {
  transition-timing-function: ease-out;
    opacity: 1;
}
<div id="elem" class="box">aaaaa</div>
<div id="elem2" class="box" onclick="refade()">bbbbbbb</div>

CodePen

If I force a reflow with offsetHeight, it works as expected. (of course if this was executed right after setting display: block, there is no longer a need for the requestAnimationFrame at all). Likewise, "double-requestAnimationFrameing" also works.

Here are screenshots from devtools profiling:

Showing blue box / initial page load, transition works

Switch to showing red box, transition doesn't work

Noticeably when the transition doesn't work, the requestAnimationFrame callback occurs before there was a reflow with only display: box taken into account. But why is that the case after a transitionend event and/or why is that not the case during page load or onClick? (Is the onClick a valid example of the technique working, or is it tainted by the display: block being set in a requestAnimationFrame callback).

I'm either missing or misunderstanding some fundementals.

1 Answers1

0

That's because the transtionend events are fired right before the animation frame callbacks.

If you look at the event loop's processing model's "update the rendering" steps, you'll see that (currently), the step #10 reads

For each fully active Document in docs, update animations and send events for that Document, passing in now as the timestamp.

That's where the transitionend events will get fired.

And just a couple of steps below, #13 is

For each fully active Document in docs, run the animation frame callbacks for that Document, passing in now as the timestamp.

So, as I explained in that answer you linked to, when using requestAnimationFrame, the UA may have updated the layout if it deemed it had time in between your call and the actual execution of the callback.
This may be true for instance if you do call requestAnimationFrame at the beginning of the "frame", so that it schedules far in the future.

However, when calling requestAnimationFrame() from a transitionend event, you actually scheduled your callback to fire right after, if you don't have a resize callback, it will literally fire synchronously:

ontransitionend = (evt) => {
  const t1 = performance.now();
  const t2 = performance.now();
  requestAnimationFrame(() => {
    const t3 = performance.now();
    console.log("synchronous elapsed: %sms", t2 - t1);
    console.log("rAF elapsed: %sms", t3 - t2);
  });
};
document.body.offsetLeft; // trigger a reflow for our transition to kick in ;)
document.body.style.opacity = 1;
body {
  transition: opacity .1s;
  opacity: 0.9;
}

And in such a short time you can be pretty sure the browser won't have triggered automatically a reflow.

So at the risk of repeating myself, to ensure that a reflow did occur and that your transition will kick in, force the reflow yourself, using one of these: https://gist.github.com/paulirish/5d52fb081b3570c81e3a

Kaiido
  • 123,334
  • 13
  • 219
  • 285
  • Thank you so much for the (2nd) detailed response, @Kaiido. To make sure I'm following: in layman's terms, my assumption that changing `display` will necessarily cause a layout update before the `requestAnimationFrame` callback was incorrect. Depending on when the `display` (or any layout-affecting) change occurs, it may not effect for the upcoming paint. An for `transitionend` specifically, it is so close to when the `requestAnimationFrame` callback occurs that it is likely (for me, empirically - deterministic) the layout will not be updated in-between. – Johnny Fang Aug 24 '21 at 16:30
  • The change of the display rule will have effect at least during the painting, which happens a few steps after in [my link](https://html.spec.whatwg.org/multipage/webappapis.html#update-the-rendering) (substep #16). But rAF callbacks are fired before that painting. – Kaiido Aug 25 '21 at 01:11