Skip to main content
Ben Nadel
On User Experience (UX) Design, JavaScript, ColdFusion, Node.js, Life, and Love.

Applying Multiple Animation @keyframes To Support Prefers-Reduced-Motion In CSS

By Ben Nadel on

Yesterday, I demonstrated that four-sided positioning plays nicely with scale() transformations in CSS. That demo used both the opacity and transform properties in order to "enter" a modal window into view. After I posted that, I started to think about the prefers-reduced-motion CSS media query. And, I wanted to revisit yesterday's post, looking at how we might honor a user's preference for reduced motion by applying multiple @keyframes to the modal window in CSS using media queries.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

When I first stated to think about this problem, my initial thought was to nest the @keyframes block inside a @media block. This way, the default definition of the animation would use a "reduced motion" configuration; and then, we'd progressively enhance the definition to use the motion-oriented properties as well:

.modal {
	/* Four-sided positioning, plays nicely with scale() transformations. */
	bottom: 0px ;
	left: 0px ;
	position: fixed ;
	right: 0px ;
	top: 0px ;

	/* Animating the modal element into view. */
	animation-duration: 1s ; /* NOTE: Absurdly SLOW for demo purposes. */
	animation-fill-mode: both ;
	animation-iteration-count: 1 ;
	animation-name: modal-enter ;
	animation-timing-function: ease-out ;
}

/* By default, we'll use the REDUCED MOTION version of the animation. */
@keyframes modal-enter {
	from {
		opacity: 0 ;
	}
	to {
		opacity: 1 ;
	}
}

/*
	Then, if the user has NO PREFERENCE for motion, we can OVERRIDE the
	animation definition to include both the motion and non-motion properties.
*/
@media ( prefers-reduced-motion: no-preference ) {
	@keyframes modal-enter {
		from {
			opacity: 0 ;
			transform: scale( 0.7 ) ;
		}
		to {
			opacity: 1 ;
			transform: scale( 1.0 ) ;
		}
	}
}

As you can see, the initial definition of @keyframes modal-enter uses the opacity property on its own. The animation is then enhanced to use both opacity and transform if the user has no motion preference.

This approach works fine. But, I didn't love the fact that I had to define opacity in both animations. I was also seeing some conflicting evidence on the web about whether or not nested @at rules were well supported. So, another thought that I had was to define the scale() value using CSS custom properties; and then override the property using a media query:

/* By default, we'll use the REDUCED MOTION version of the animation. */
:root {
	--transform-start: 1.0 ;
	--transform-end: 1.0 ;
}

/*
	Then, if the user has NO PREFERENCE for motion, we can OVERRIDE the
	animation definition to include both the motion and non-motion properties.
*/
@media ( prefers-reduced-motion: no-preference ) {
	:root {
		--transform-start: 0.7 ;
		--transform-end: 1.0 ;
	}
}

@keyframes modal-enter {
	from {
		opacity: 0 ;
		transform: scale( var( --transform-start ) ) ;
	}
	to {
		opacity: 1 ;
		transform: scale( var( --transform-end ) ) ;
	}
}

This also works; and, I don't have to repeat myself in terms of the property list; however, it just feels quite verbose. And, I know that IE11 doesn't support custom properties (and InVision's V6 platform still supports IE11). I'm also not very familiar with CSS custom properties (mostly because I can't use them at work).

So, my final thought was to apply two different @keyframes to the modal window element based on the reduce motion media query - one set would define the reduce-motion animation properties; and, the other set would define the full-motion animation properties:

<!doctype html>
<html lang="en">
<head>
	<meta charset="utf-8" />
	<title>
		Applying Multiple Animation @keyframes To Support Prefers-Reduced-Motion In CSS
	</title>
	<link rel="stylesheet" type="text/css" href="./demo.css">
	<style type="text/css">

		.modal {
			/* Four-sided positioning, plays nicely with scale() transformations. */
			bottom: 0px ;
			left: 0px ;
			position: fixed ;
			right: 0px ;
			top: 0px ;

			/*
				Animating the modal element into view: our modal-enter animation is going
				to use the REDUCED MOTION animation by default. Then, it will become
				"progressively enhanced" to use the FULL MOTION animation properties
				depending on the user's preference.
			*/
			animation-duration: 1s ; /* NOTE: Absurdly SLOW for demo purposes. */
			animation-fill-mode: both ;
			animation-iteration-count: 1 ;
			animation-name: modal-enter-reduced-motion ; /* Start with reduced motion. */
			animation-timing-function: ease-out ;
		}

		/*
			If the user has no preference (the default settings in the OS), enhance the
			modal window to use BOTH the REDUCED MOTION and the FULL MOTION properties.
		*/
		@media ( prefers-reduced-motion: no-preference ) {
			.modal {
				animation-name:
					modal-enter-reduced-motion,
					modal-enter-full-motion
				;
			}
		}

		/* Reduce motion only uses opacity, but DOESN'T MOVE the elements around. */
		@keyframes modal-enter-reduced-motion {
			from {
				opacity: 0 ;
			}
			to {
				opacity: 1 ;
			}
		}

		@keyframes modal-enter-full-motion {
			from {
				transform: scale( 0.7 ) ;
			}
			to {
				transform: scale( 1.0 ) ;
			}
		}

	</style>
</head>
<body>

	<h1>
		Applying Multiple Animation @keyframes To Support Prefers-Reduced-Motion In CSS
	</h1>

	<p>
		<a class="toggle">Open modal</a>
	</p>

	<!--
		This modal window will use FIXED positioning and have a four-sided (top, right,
		bottom, left) arrangement. It will also fade into view using CSS transitions.
	-->
	<template>
		<div class="modal">
			<a class="toggle">Close modal</a>
		</div>
	</template>


	<!-- Load scripts. -->
	<script type="text/javascript" src="../../vendor/jquery/3.6.0/jquery-3.6.0.min.js"></script>
	<script type="text/javascript">

		var modal = null;
		var template = $( "template" );

		// We'll use event-delegation so that we can capture the click event in the
		// modal, which isn't even rendered yet.
		$( document ).on( "click", ".toggle", toggleModal );

		// I show / hide the modal window by adding it to or removing it from the DOM
		// (Document Object Model) tree, respectively.
		function toggleModal() {

			if ( modal ) {

				modal.remove();
				modal = null;

			} else {

				modal = $( template.prop( "content" ).firstElementChild.cloneNode( true ) )
					.appendTo( document.body )
				;

			}

		}

	</script>

</body>
</html>

To be fair, this approach is also quite verbose. But, it also works. And, if we run this demo in the browser using different OS (Operating System) settings for reduced motion, we get the following output:

Reduced motion preference implemented using multiple @keyframes animation in CSS.

As you can see, when the reduced-motion setting is enabled, we only apply the opacity-based animation. And, the reduce-motion setting is disabled, we apply both the opacity-based and the transform-based animations.

Ultimately, all three of these approaches work (depending on browser support). And, all three of these approaches have benefits and drawbacks. But, for whatever reason, I kind of prefer the last approach - applying multiple @keyframes animations to aggregate the full set of animated properties. There's a certain "simplicity" to it that I can't articulate.



Reader Comments

What has two thumbs and hopes you leave a comment? This Guy! (Ben Nadel).

Post A Comment

You — Get Out Of My Dreams, Get Into My Blog
Live in the Now
Oops!
Comment Etiquette: Please do not post spam. Please keep the comments on-topic. Please do not post unrelated questions or large chunks of code. And, above all, please be nice to each other - we're trying to have a good conversation here.