CSS transition doesn’t start/callback isn’t called

I have a large game project that used extensive jquery in its code. Some time ago I stripped out all of the jquery and replaced it with pure JS, but the one thing I had trouble with was replacing the .animation calls for projectiles in the game.

It appeared that I should replace them with CSS transitions, and the game needed to know when the transition was done, so I needed to add a callback to the transition. All well and good, except when I assigned new location values for the projectile, the transition was skipped entirely and no callback was called. For some ungodly reason, it started working if I wrapped the change to its css position in a SetTimeout for 1ms–and even then, sometimes it would still skip the transition.

Now it usually works, except about one time in 10 the transition will play but then the callback will not be called. I don’t know why the settimeout helps to be there, and I don’t know why the callback sometimes doesn’t work. Can anyone help me understand?

let tablehtml="<div id=""+animid+'" style="position: absolute; left: ' + ammocoords.fromx + 'px; top: ' + ammocoords.fromy + 'px; background-image:url(\'graphics/' + ammographic.graphic + '\');background-repeat:no-repeat; background-position: ' + ammographic.xoffset + 'px ' + ammographic.yoffset + 'px; transition: left '+duration+'ms linear 0s, top '+duration+'ms linear 0s;"><img src="https://stackoverflow.com/questions/77695607/graphics/spacer.gif" width="32" height="32" /></div>';

document.getElementById('combateffects').innerHTML += tablehtml;
let animdiv = document.getElementById(animid);
animdiv.addEventListener("transitionend", function(event) { 
  FinishFirstAnimation();
}, false);

setTimeout(function() { Object.assign(animdiv.style, {left: ammocoords.tox+"px", top: ammocoords.toy+"px" }); }, 1); // THIS IS A TOTAL KLUDGE
// For some reason, the transition would not run if the 1ms pause was not there. It would skip to the end, and not
// fire the transitionend event. This should not be necessary.

For a comprehensive explanation of why this happens, see this Q/A, and this one.

Basically, at the time you set the new style the browser still has not applied the one set inline, your element’s computed style still has its display value set to ""
, because it’s what elements that are not in the DOM default to.
Its left and top computed values are still 0px, even though you did set it in the markup.

This means that when the transition property will get applied before next frame paint, left and top will already be the ones you did set, and thus the transition will have nothing to do: it will not fire.

To circumvent it, you can force the browser to perform this recalc. Indeed a few DOM methods need the styles to be up to date, and thus browsers will be forced to trigger what is also called a reflow.
Element.offsetHeight getter is one of these method:

let tablehtml = `
<div id="spanky" 
  style="position: absolute; 
    left: 10px; 
    top: 10px; 
    background-color:blue; 
    width:20px; 
    height:20px;
    transition: left 1000ms linear 0s, top 1000ms linear 0s;">
</div>`;

document.body.innerHTML += tablehtml;

let animdiv = document.getElementById('spanky');
animdiv.addEventListener("transitionend", function(event) { 
  animdiv.style.backgroundColor="red";
}, false);
// force a reflow
animdiv.offsetTop;
// now animdiv will have all the inline styles set
// it will even have a proper display

animdiv.style.backgroundColor="green";
Object.assign(animdiv.style, {
  left: "100px", 
  top: "100px" 
});

But really, the best is probably to directly use the Web Animation API, which is supported almost everywhere (but in long dead IE). This will take care of doing the right thing at the right time.

let tablehtml = `
<div id="spanky" 
  style="
    position: absolute; 
    background-color:blue; 
    width:20px; 
    height:20px;
  "
</div>`;

document.body.innerHTML += tablehtml;

let animdiv = document.getElementById("spanky");
const anim = animdiv.animate([
    { left: "10px",  top: "10px"  },
    { left: "100px", top: "100px" }
  ],
  {
    duration: 1000,
    fill: "forwards"
  });

anim.finished.then(() => {
  animdiv.style.backgroundColor = "green";
});

it has to do with the timing of when the new element is actually “painted” …

I know this is a kludge too but …

the one way I’ve found to guarantee 100% success is to wait just under two frames (at 60fps that’s about 33.333ms – but the setTimeout in browsers these days has a artificial “fudge” added due to spectre or something – anyway …

requestAmiationFrame(() => requestAnimationFrame() => { ... your code ...})) does the same thing except it could be as little as 16.7ms delay, i.e. just over one frame

let tablehtml = `
	<div id="spanky" 
  	style="position: absolute; 
    	left: 10px; 
      top: 10px; 
      background-color:blue; 
      width:20px; 
      height:20px;
      transition: left 1000ms linear 0s, top 1000ms linear 0s;">
</div>`;

document.body.innerHTML += tablehtml;
let animdiv = document.getElementById('spanky');
animdiv.addEventListener("transitionend", function(event) { 
  animdiv.style.backgroundColor="red";
}, false);
requestAnimationFrame(() => requestAnimationFrame(() => {
  animdiv.style.backgroundColor="green";
	Object.assign(animdiv.style, {
		left: "100px", 
  	top: "100px" 
	}); 
}));

having a single requestAnimationFrame failed about 1 in 10 for me, but the double request makes it impossible to fail

I was able to get rid of setTimeout() by following the Web Animation Approach suggested by Kaiido.
But for me anim.finished.then(()=>{}); is not invoked after the frame is actually painted. It took 2ms to invoke this callback.

For my case anim.onfinish=()=>{} makes sure the frame is painted and it took 16ms to invoke the callback.

Leave a Comment