Css Transition Doesn't Start/Callback Isn't Called

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

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 reflowanimdiv.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" });

CSS Transition is not being triggered as expected

You need to get rid of the display: none and let css take care of the transitions more.

I set the .np-collapsible to this in order to let the element always exist:

.np-collapsible:not(.np-expanded) {
height: 0;
overflow: hidden;
}

I set the transition class to not make any changes that would start up a transition (it only includes the transition property).

Then in the JS, the transition is done similarly to what you had originally, but the main difference is that I use menu.scrollHeight to get the height of the menu in order to avoid having extra transitions to get the height normally.

I also added the ability to contract the menu to the function. In case of contracting the menu, you have to remove np-expanded class before the transition due to the :not(.np-expanded) selector earlier stopping the overflow: none.

  // Retrieve the height of the menu
var targetHeight = menu.scrollHeight + 'px';
if (menu.classList.contains('np-expanded')) {
// It's already expanded, time to contract.
targetHeight = 0;
menu.classList.remove('np-expanded');
button.setAttribute('aria-expanded', false);
}
// Enable transition
menu.classList.add('np-transitioning');
menu.addEventListener('transitionend', function(event) {
// Disable transition
menu.classList.remove('np-transitioning');

// Indicate that the menu is now expanded
if (targetHeight) {
menu.classList.add('np-expanded');
button.setAttribute('aria-expanded', true);
}
}, {
once: true
});

// Set the height to execute the transition
menu.style.height = targetHeight;

Here's a working example:

var button = document.querySelector('.np-trigger');var menu = document.querySelector(button.dataset.target);
button.addEventListener('click', function(event) { expand();}, false);
function expand() { if (isTransitioning()) { // Don't do anything during a transition return; }
// Retrieve the height of the menu var targetHeight = menu.scrollHeight + 'px'; if (menu.classList.contains('np-expanded')) { // It's already expanded, time to contract. targetHeight = 0; menu.classList.remove('np-expanded'); button.setAttribute('aria-expanded', false); } // Enable transition menu.classList.add('np-transitioning'); menu.addEventListener('transitionend', function(event) { // Disable transition menu.classList.remove('np-transitioning');
// Indicate that the menu is now expanded if (targetHeight) { menu.classList.add('np-expanded'); button.setAttribute('aria-expanded', true); } }, { once: true });
// Set the height to execute the transition menu.style.height = targetHeight;}
function isTransitioning() { if (menu.classList.contains('np-transitioning')) { return true; }
return false;}
.np-collapsible:not(.np-expanded) {  height: 0;  overflow: hidden;}
.np-transitioning { transition: height 0.25s ease;}
.navigation-menu { display: flex; flex-direction: column; position: fixed; top: 4rem; left: 1rem; width: 270px;}
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous"><button class="btn btn-dark np-trigger" data-target="#menu">Menu</button>
<nav id="menu" class="bg-dark navigation-menu np-collapsible"> <a class="nav-link" href="#">Link</a> <a class="nav-link" href="#">Link</a> <a class="nav-link" href="#">Link</a></nav>

css transition doesn't work if element start hidden

To understand plainly the situation, you need to understand the relation between the CSSOM and the DOM.

In a previous Q/A, I developed a bit on how the redraw process works.

Basically, there are three steps, DOM manipulation, reflow, and paint.

  • The first (DOM manipulation) is just modifying a js object, and is all synchronous.
  • The second (reflow, a.k.a layout) is the one we are interested in, and a bit more complex, since only some DOM methods and the paint operation need it. It consists in updating all the CSS rules and recalculating all the computed styles of every elements on the page.

    Being a quite complex operation, browsers will try to do it as rarely as possible.
  • The third (paint) is only done 60 times per seconds at max (only when needed).

CSS transitions work by transitioning from a state to an other one. And to do so, they look at the last computed value of your element to create the initial state.

Since browsers do recalculate the computed styles only when required, at the time your transition begins, none of the DOM manipulations you applied are effective yet.

So in your first scenario, when the transition's initial state is calculated we have

.b { computedStyle: {display: none} }

... and that's it.

Because, yes, that's how powerful display: none is for the CSSOM; if an element has display: none, then it doesn't need to be painted, it doesn't exist.

So I'm not even sure the transition algorithm will kick in, but even if it did, the initial state would have been invalid for any transitionable value, since all computed values are just null.

Your .a element being visible since the beginning doesn't have this issue and can be transitioned.

And if you are able to make it work with a delay (induced by $.animate), it's because between the DOM manip' that did change the display property and the execution of this delayed DOM manip' that does trigger the transition, the browser did trigger a reflow (e.g because the screen v-sync kicked in between and that the paint operation fired).


Now, it is not part of the question, but since we do understand better what happens, we can also control it better.

Indeed, some DOM methods do need to have up-to-date computed values. For instance Element.getBoundingClientRect, or element.offsetHeight or getComputedStyle(element).height etc. All these need the entire page to have updated computed values so that the boxing are made correctly (for instance an element could have a margin pushing it more or less, etc.).

This means that we don't have to be in the unknown of when the browser will trigger this reflow, we can force it to do it when we want.

But remember, all the elements on the page needs to be updated, this is not a small operation, and if browsers are lenient to do it, there is a good reason.

So better use it sporadically, at most once per frame.

Luckily, the Web APIs have given us the ability to hook some js code just before this paint operation occurs: requestAnimationFrame.

So the best is to force our reflow only once in this pre-paint callback, and to call everything that needs the updated values from this callback.

$('button').on('click',function(){  $('.b').show(); // apply display:block synchronously    requestAnimationFrame(() => { // wait just before the next paint    document.body.offsetHeight; // force a reflow    // trigger the transitions    $('.b').css('right','80%');    $('.a').css('right','80%');  });})
body {  width:800px;  height:800px;}
div { width:50px; height:50px; background-color:#333; position:absolute; display:none; right:5%; top:0; transition:right .5s cubic-bezier(0.645, 0.045, 0.355, 1); color: white;}
.a { display:block; top:60px;}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script><div class='a'>A</div><div class='b'>B</div><button>Launch</button>

Callback on CSS transition

I know that Safari implements a webkitTransitionEnd callback that you can attach directly to the element with the transition.

Their example (reformatted to multiple lines):

box.addEventListener( 
'webkitTransitionEnd',
function( event ) {
alert( "Finished transition!" );
}, false );

Chrome on OSX: CSS transitions applied immediately to second clone of div

This is because setTimeout(/**/, 0) does not guarantee that the callback will be executed on a subsequent frame. Which could (depending on browser implementation and computer speed) result in the style being applied on the same frame as the node being inserted into the DOM.

In theory, you should use requestAnimationFrame instead, which is exactly meant for this type of situations.

However, in the fiddle you linked, it only worked if I doubled the requestAnimationFrame which is imperceptible but still undesirable... IDK if it's a fluke of JSFiddle or what...

function move(color){
let clone=document.getElementById("test").cloneNode(true);
clone.id=color;
clone.style.display="block";
clone.style.backgroundColor=color;
document.getElementById("main").prepend(clone);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
clone.style.left="500px";
})
})
}

here's a snippet: I find the same thing in the SO snippet

function move(color) {
let clone = document.getElementById("test").cloneNode(true);
clone.id = color;
clone.style.display = "block";
clone.style.backgroundColor = color;
document.getElementById("main").prepend(clone);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
clone.style.left = "500px";
})
})
}


setTimeout(() => move("red"), 500);
setTimeout(() => move("green"), 750);
#main {
display: block;
width: 100vw;
height: 100vh;
background-color: blue;
}

.test {
position: absolute;
display: none;
width: 100px;
height: 100px;
background-color: red;
transition: left 1s ease;
transform: scale(1);
left: 0px;
}
<div id="main"></div>
<div id="test" class="test"></div>

Why am i getting this behaviour in js where I apply style changes through js. Shouldn't in both cases transition take place?

It is kinda weird to me still but you need to "refresh" the CSS changes.
To do that, you need to read an element's property, for example el.innerText, before applying your new style.

 function move(x,y,delay){
let el = document.getElementById('myDiv');
el.style.transform = `translateX(${x}%)`
el.style.transition = `transform ${delay}s linear`
el.innerText;
el.style.transform = `translateX(${y}%)`
}

move(100,200,1)
 .myDiv{
height: 50px;
width: 50px;
background: blue;
}
<div class="myDiv" id="myDiv">
Content
</div>

Callback on CSS animation end

$('#my_object').animate_scale().fadeOut(2000);

if you want .fadeOut() to wait for animate_scale() to finish, animate_scale needs to be queued:

Queue your plugin:

Usually, when you chain fx methods like i.e:

$("#ball").animate({left:200}).fadeOut();

you'll see the ball animate, and only once that animation is finished --- it'll fade out.

Why? Cause jQuery will stach animate and than fadeOut into a queue array and wait each to resolve before triggering the next Method.

To replicate the same behavior within your plugin:

jsFiddle demo (Queue in action!)

$.fn.animate_scale = function( callback ) {
var $this = this;
return $this.queue(function() {
$this.addClass('animate_scale').on("animationend", function() {
$this.dequeue();
if (typeof callback == 'function') callback.call( $this );
});
});
};


$('#my_object').animate_scale(function() {
console.log( "Scale is done!" );
}).fadeOut( 2000 ); // fadeOut will wait for animate_scale to dequeue (complete)

I don't need queue stacking

If you want your plugin to unobstructively (simultaneously) process other chained fx Methods,

use just the callback:

jsFiddle demo (no Queue)

$.fn.animate_scale = function( callback ) {
var $this = $(this);
return $this.addClass('animate_scale').on("animationend", function() {
if (typeof callback == 'function') callback.call( this );
});
};

$('#my_object').animate_scale(function(){
console.log("Scale done.");
// use $(this).fadeOut(2000); here!! cause otherwise...
}).fadeOut(2000); // ...if chained here, will fade immediately!!!!!


Related Topics



Leave a reply



Submit