Animating Page and View Transitions with Accessibility in Mind

Page Transitions are becoming a standard part of screen-based user interfaces' design and development process. With the View Transitions API making its way into a majority of browsers, we must remember to consider accessibility requirements and build with them in mind.

# Only YOU can prevent Motion Fires

"Make it bounce!", "Make it fly!", "Set it on fire!" are becoming the new "Make my logo bigger". Animations have been bringing flat, intangible user interfaces (UIs) to life for years. Being part of the industry for almost 25 years, I remember the monotonous days typing away on ActionScript and lining up keyframes in Flash. Now, with the advent of nearly all major web browsers supporting the CSS View Transitions Spec, this comes easier than a cake in a bake shop.

The View Transitions API provides a mechanism for easily creating animated transitions between different DOM states while also updating the DOM contents in a single step.

Where once there were constraints that required us to carefully craft animations and page transitions, we can now easily add motion to our applications. Great news, because it gives us extra time to progressively (de)enhance applications for users with special needs.

# Vestibular Impairment

There are a subset of accessibility (a11y) considerations when designing and implementing animations and page transitions. A study published in the National Library of Medicine found that around 35% of adults over 40 years old experience balance dysfunction.

Vestibular Disorders affect the sensory system which helps keep our sense of balance and spatial orientation. The a11y Project briefly describes the affliction as such:

People with vestibular disorders have a problem with their inner ear. It affects their balance as well as their visual perception of their world around them.

With animations and motion now prevalent on the internet, imagine how it must feel ducking and shielding one's eyes from the endless, flaming torment of thousand-pixels wide fly-ins and bouncing elastic buttons.

Let's take a peek at the great responsibility we must embrace as all-powerful animation-slinging user interface engineers we have become.

# Reducing Motion for Vestibular Impairment

Using JavaScript's matchMedia and CSS's @media features, we can reduce or remove animations, transitions, and motion that could affect our users.

# tl;dr — The Goal

Below are before and after examples of this in action on dgrebb.com. The CSS, JavaScript, and Animated Image sections below contain solutions and examples to tailor for your flavor of web development.

# Before

Still frame: A image still of the dgrebb.com home page
Animated Browsing dgrebb.com with motion on

Both custom fade and fly effects can be seen during transitions between pages on this site.

# After

Still frame: A image still of the dgrebb.com home page
Animated Browsing dgrebb.com with motion reduced

With prefers-reduced-motion: reduce set, these effects are no longer apparent.

Removing global DOM manipulation, custom transitions, and reducing CSS property transition time, users are now able to browse through pages without a motion-heavy experience. For dgrebb.com, I've adjusted:

  • Animated Images
  • CSS Property and View Transitions
  • CSS and SVG Animations
  • Animated JavaScript DOM Manipulations

Let's take a look at images.

# Animated Images

A number of image formats support sequenced frames, perceived as "animations", including GIF, WebP, APNG, and the lesser-known FLIF, MNG, and AVIF. While animated images provide a medium to convey rich details about a complex scene or interaction, they are a jarring nuisance to some users. There is a way to enhance this experience with a11y in mind.

# Playing and Pausing Images

Browser APIs now include the Web Animations API. Unfortunately, sequenced images aren't supported. Including an animated image on a web page, they will play automatically, without a user option to pause.

Thanks to the fine folks at CSS Tricks, a simple copy, paste, and adjust solution is available, and gets the job done. In fact, this technique is in use at the top of this page!

By default, we'll display a still image and add a "play/pause" button, which swaps between the still frame and animation on click.

The best part — there is no need to reach for your JavaScript multitool! With a little CSS, the rarely-used details and summary elements get the job done:

AnimatedImage.html
<figure class="animated-image">
  <div class="animation-player">
    <img
      src="https://imgtr.ee/images/2023/10/21/34cdee1935e9677e0defa5ae50ad73a4.jpeg"
      alt="Still frame of motion sickness"
      loading="lazy"
    />
    <details>
      <summary aria-label="Click to play the animation">
        Play/Pause
      </summary>
      <div class="animation">
        <img
          src="https://media4.giphy.com/media/WS0I3awIzkDWQyClKF/giphy.gif"
          alt="Animated {altText}}"
          loading="lazy"
        />
      </div>
    </details>
  </div>
  <figcaption>Buckets of fun!</figcaption>
</figure>

With some positioning trickery and the details element's open attribute, we can display a still frame while in the open attribute is not included on the details element.

Note

The details element's open attribute — which displays the contained content — is truly boolean. It's either there or not. There is no open="false". Only open="".

These styles give an idea of the overall player and how it works:

animated-image.css
.animated-image {
  position: relative;
}

.animation-player > img {
  z-index: 1;
}

.animated-image summary {
  z-index: 2;
  position: absolute;
  top: 1rem;
  left: 1rem;
  background-color: rgba(0, 0, 0, 0.5);
  color: white;
  padding: .5rem 1rem;
  border-radius: 1rem;
  cursor: pointer;
}

/* 
 * @@@ the animated image @@@
 * shown when `details[open]`
 * positioned to cover the still image
*/
.animation img {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
}

/* @@@ play button @@@ */
summary {
  .pause-icon {
    display: none;
  }
  .play-icon {
    display: inline;
  }
}

/* @@@ pause button @@@ */
[open] summary {
  &:hover {
    opacity: 0.77;
  }
  .play-icon {
    display: none;
  }
  .pause-icon {
    display: inline;
  }
}

To enable automatic playing of these images, simply add the open attribute to details in the default element state. Combining this with a prefers-reduced-motion media query, the image can be set to play automatically for all users except those who prefers-reduced-motion: reduce.

Play around with this example on JSFiddle, if you are so inclined. Let's look at CSS animation and transition properties next.

# CSS Transitions & Animations

Browsers set the prefers-reduced-motion CSS media feature value based on a user's system and/or application motion reduction settings. By querying this, we can easily reduce the duration of CSS transitions and animation properties.

Start by setting transition and animation properties extremely low. This virtually removes motion from the page. An important note here, setting a duration protects functionality involving eventlisteners bound to the animationEnd event, as an example.

Equally as important and future-proofing, we can also set View Transitions API "old" and "new" state animation duration.

transitions.css
@media (prefers-reduced-motion: reduce) {
  *,
  ::before,
  ::after,
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation-duration: 0.001s !important;
    animation-iteration-count: 1 !important;
    transition-delay: 0.001s !important;
    transition-duration: 0.001s !important;
  }
}

How far you choose to go wildcarding elements in your application or website requires some thought and reflection on your potential audience. If animation or transitions are a UI affordance, drawing your users' attention to important actions, think about increasing their duration—thus slowing them down—as another option.

Important

Update: Keep this snippet around for situations where other media queries (namely breakpoints) override CSS specificity.

Be mindful that dynamically loaded CSS via JavaScript can sometimes override !important rules, especially in frameworks like Svelte and Astro. Also look out for any CSS transition properties set with a :hover, :focus,:active, etc. selectors — they will need to be unset forprefers-reduced-motion: reduce`.

catch-all-media.css
@media screen and (prefers-reduced-motion: reduce) and (width >= 1px) {
  *,
  ::before,
  ::after,
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation-duration: 0.001s !important;
    animation-iteration-count: 1 !important;
    transition-delay: 0.001s !important;
    transition-duration: 0.001s !important;
    scroll-behavior: auto !important;
  }
}

# JavaScript matchMedia

JavaScript provides a handy window method — matchMedia(), which takes a parameter of mediaQueryString, much like CSS mediaQuery selectors work. @media (prefers-reduced-motion: reduce) {} becomes window.matchMedia('(prefers-reduced-motion: reduce)').matches;, which returns true or false based on a user's motion preferences.

Let's use this to enhance our JavaScript-assisted page and element transitions, and turn them off if reduced motion is set in the browser.

# Svelte Directives

For built-in Svelte transitions via import {fade} from 'svelte/transition', we can exit a transition before it starts via a matchMedia helper. Let's create and add a custom move function to our transition in and out directives.

Transition.svelte
<script>
  import { fade, fly } from 'svelte/transition';
  import { circInOut } from 'svelte/easing';
  
  function prefersReducedMotion() {
    return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
  }
  
  let motion = !prefersReducedMotion();
  let show = false;
  
  function move(node, options) {
    if (motion === false) return false;
    return options.fn(node, options);
  }
</script>

<button on:click={() => { show = !show }} on:keydown={() => { show = !show }}>
  Show Me!
</button>

{#if show}
  <h3 
    in:move={{ fn: fly, x: -333, duration: 500, easing: circInOut }}
    out:move={{ fn: fade, duration: 500 }}
  >
    Motion Intensifies!
  </h3>
{/if}

Here, we pass the element node and transition options into move. move then determines if a local motion variable is truthy, and if so, proceeds with the transition function and options we pass from the element being transitioned. If motion has been set to false, we exit the helper and never apply motion or animated effects.

# Demo and Example

I've provided a Svelte REPL (Read-Eval-Print-Loop) of this solution on svelte.dev. Using Windows, macOS, or browser preferences, the effects of "Reduced Motion" can be explored there.

Because this example is a sandboxed environment, the page must be refreshed to see a system settings change. REPLs only have access to your browser's queryable attributes on load. Try it out and experience how easy it is to (progressively) reduce (or add!) motion.

This website itself will semi-immediately reflect system settings changes. However, be aware that mount is when transitions become aware of their next out transition. For me, this is acceptable in supporting a user's choice to reduce motion in the middle of navigating a site.

# Custom Svelte Transitions

For custom transitions, apply the same logic. Exit a transition before it starts if'(prefers-reduced-motion: reduce)'is set. We make use of the same prefersReducedMotion helper function within our custom transitions like so:

PageTransition.svelte
1
<script>
2
  import { navigating } from "$app/stores";
3
  import {
4
    elementOutsideViewport,
5
    prefersReducedMotion,
6
    scrollTop,
7
  } from "@utils";
8

9
  export let transitionKey;
10
  let navigatingTo;
11

12
  function setDuration() {
13
    return {
14
      duration: prefersReducedMotion() === true ? 0 : 500,
15
    };
16
  }
17

18
  async function animateOutroStart() {
19
    if (prefersReducedMotion() === true) return;
20
    const header = document.querySelector(".header");
21
    navigatingTo = $navigating?.to.route.id;
22
    document.body.classList.toggle("animating", true);
23
    if (elementOutsideViewport(header)) {
24
      header.classList.toggle("scroll-transition", true);
25
    }
26
  }
27

28
  function animateOutroEnd() {
29
    if (navigatingTo === "/") return;
30
    scrollTop();
31
  }
32

33
  function animateIntroStart() {
34
    if (prefersReducedMotion() === true) return;
35
    header.classList.toggle('scroll-transition', false);
36
  }
37

38
  function animateIntroEnd() {
39
    if (prefersReducedMotion() === true) return;
40
    setTimeout(() => {
41
      document.body.classList.toggle("animating", false);
42
    }, 333);
43
  }
44
</script>
45

46
{#key transitionKey}
47
  <div
48
    class="transition-container"
49
    transition:setDuration|global
50
    on:outrostart="{animateOutroStart}"
51
    on:outroend="{animateOutroEnd}"
52
    on:introstart="{animateIntroStart}"
53
    on:introend="{animateIntroEnd}"
54
  >
55
    <slot />
56
  </div>
57
{/key}

On lines 19, 34, and 38, we return from the transition, rather than manipulating the DOM, which would apply fade in and out animations

Because I use custom transition functions, and eventListeners on onintrostart, onintroend, onoutrostart, and onoutroend, this is the way motion is reduced on dgrebb.com. Because matchMedia is a window method, we need to either a) call the helper inside of our transition functions, or b) import and call onMount() to set a local variable on page load.

Motion preferences can also be captured in global application state depending on preference and the complexity of your transition ecosystem. Another option for a more global approach; one could include a motion toggle or checkbox in the UI, or include the same in a user account settings page.

I prefer supporting changes to motion preferences on the fly, so the small performance hit and repeated code are acceptable (to me).

# In Closing

Remember to keep your empathetic wits about you when it comes to animation and page or element-level transitions. The last thing we need is a monitor thrown from a balcony, or set on fire, from any frustration or serious illness our beloved users might experience.

Back to Top