How Cypress Freezes CSS Animations And You Can Too

How Cypress disables CSS animations to take deterministic screenshots, and how you could use the same approach during the tests to stop skip transitions.

Let's take a web application with beautiful CSS animations showing time of day. Each click of the button sets a different CSS class which triggers CSS transitions.

Time of day via CSS animations

🖼 You can find the example application in the repo bahmutov/css-animation-cypress-example. It is based on this Codepen created by Olivia Ng.

CSS Animations

To create the transitions, the application sets a different CSS class name

public/script.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$('.option').on('click', function () {
$('.option').removeClass('active')
$(this).addClass('active')
var type = $(this).data('option')
if (type === 'day') {
$('.time').attr('class', 'time day')
} else if (type === 'night') {
$('.time').attr('class', 'time night')
} else if (type === 'dusk') {
$('.time').attr('class', 'time dusk')
} else if (type === 'sunset') {
$('.time').attr('class', 'time sunset')
}
})

The application CSS specifies the transition duration applied to different elements. For example, to move the sun and the clouds, the application uses SVG shapes. Each shape will change its "fill" property in five seconds.

public/style.css
1
2
3
4
5
6
path,
polygon,
circle,
rect {
transition: fill 5s ease;
}

Similarly, the ".sun" and other CSS classes will change its styles when applied in five seconds

public/style.css
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.sun {
top: 120px;
margin-left: -20px;
transition: all 5s ease;
transform: scale(0.2);
}
.clouds {
top: 50px;
right: -500px;
transition: all 5s ease 0.1s;
}
.night .stars,
.night .moon {
opacity: 1;
transition: all 5s ease 0.5s;
}

Cypress tests

Ok, pretty slick. But how do the slow transitions affect the Cypress tests? Well, Cypress test does not "know" about five seconds it takes to finish the transition after clicking each button. Thus a typical test will show a weird start of the transition before abruptly starting a new one.

cypress/integration/spec.js
1
2
3
4
5
6
7
8
9
10
11
const times = ['Day', 'Sunset', 'Night', 'Dusk']

it('goes through the day', () => {
cy.visit('public/index.html')
times.forEach((time) => {
cy.contains(time)
.click()
// add one second delay to show the animation in progress
.wait(1000, { log: false })
})
})

The test only waits one second before the next step

We could slow down the test by waiting five seconds, but what if we could speed up the animations instead? Or disable them completely?

Cypress screenshots

If you every used cy.screenshot command, you might have noticed the option disableTimersAndAnimations in its documentation page:

The cy.screenshot command mentions CSS animations

Hmm, does cy.screenshot know how to disable CSS animations?! Let's try it out.

1
2
3
4
5
6
7
8
9
10
11
12
it('takes a screenshot', () => {
cy.visit('public/index.html')

times.forEach((time) => {
cy.contains(time).click()
// notice we are taking a screenshot immediately
// without waiting for anything to finish updating
cy.get('.window').screenshot(time, {
overwrite: true,
})
})
})

Look at the screenshots - they all show the page as if the CSS animations ran to the finish!

Somehow cy.screenshot shows the final transition in each image

How does it do it? How does it bypass waiting 5 seconds?

Let's search the Cypress source code for "disableTimersAndAnimations". This search result seems very relevant.

Looks like cy.screenshot is injecting something into the application's frame to disable CSS animations

Let's click on the search result to find the utility method addCssAnimationDisabler. It seems to just add a style to overwrite all CSS transitions in the page and set their duration to zero!

How Cypress skips CSS animations during screenshots

Disabling animations from out test

Nice, we can do the same thing ourselves from the Cypress test. We can use jQuery bundled with Cypress under Cypress.$ to make the code simpler.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
it('disables animations', () => {
cy.visit('public/index.html')
cy.get('body').invoke(
'append',
Cypress.$(`
<style id="__cypress-animation-disabler">
*, *:before, *:after {
transition-property: none !important;
animation: none !important;
}
</style>
`),
)

times.forEach((time) => {
cy.contains(time).click().wait(1000, { log: false })
})
})

The test shows the nice final state of each animation.

The Cypress test with disabled CSS animations

Note: I should record a video showing what I have explained in this blog post. Subscribe to my YouTube channel to find the video when it comes out.