DEV Community

Cover image for Svelte-Cubed: Creating an Accessible and Consistent Experience Across Devices
Alex Warnes
Alex Warnes

Posted on

Svelte-Cubed: Creating an Accessible and Consistent Experience Across Devices

This article is the third in a beginner series on creating 3D scenes with svelte-cubed and three.js. If you want to learn how we got here you can start from the beginning:

In this short article, we’re going to look at two sort-of unrelated topics. However, both fall under the umbrella of improving user experience:

  1. Using prefers-reduced-motion to conditionally animate/transition things in our scene

  2. Using threejs clock and getDelta() to render the same motion for users with different device frame rates

Here’s the REPL where part two left off to get you started:
https://svelte.dev/repl/9b3b351fe187421b84a6f1616e2c9e3d

Conditional Motion: prefers-reduced-motion

Why does it matter?

Not everyone likes decorative animations or transitions, and some users outright experience motion sickness when faced with parallax scrolling, zooming effects, and so on. The user preference media query prefers-reduced-motion lets you design a motion-reduced variant of your site for users who have expressed this preference.

- Thomas Steiner (twitter) in prefers-reduced-motion: Sometimes less movement is more

And we want our scenes to be enjoyable for everyone, so first let’s figure out how to detect this preference in JavaScript so we can use it in our Svelte component. I’ll be using an approach from Geoff Rich (twitter) explained in his post A Svelte store for prefers-reduced-motion.

In a new javascript file we’ll call stores.js we can steal all of Geoff’s code (and cite it for future reference!) and paste it in.

import { readable } from "svelte/store";

/*
    Source: Geoff Rich, 
    "A Svelte store for prefers-reduced-motion", 
    URL: https://geoffrich.net/posts/svelte-prefers-reduced-motion-store/
*/
const reducedMotionQuery = '(prefers-reduced-motion: reduce)';

const getInitialMotionPreference = () => window.matchMedia(reducedMotionQuery).matches;

export const reducedMotion = readable(getInitialMotionPreference(), set => {
  const updateMotionPreference = event => {
    set(event.matches);
  };

  const mediaQueryList = window.matchMedia(reducedMotionQuery);
  mediaQueryList.addEventListener('change', updateMotionPreference);

  return () => {
    mediaQueryList.removeEventListener('change', updateMotionPreference);
  };
});

Enter fullscreen mode Exit fullscreen mode

The reducedMotion store provides a boolean that we can subscribe and react to anywhere in our application if the value changes. How can we use it? Well, anywhere we animate we can first check the preference and adjust as needed. Our motion is coming from two sources: the tweened store and our SC.onFrame() callback.

First: if a user prefers reduced motion, our tweened store duration will be 0 (i.e. it will go from value a to value b instantly).

import { reducedMotion } from "./stores"

let scale = tweened(1, {duration: $reducedMotion ? 0 : 2000, easing: elasticOut});
Enter fullscreen mode Exit fullscreen mode

That’s it? That’s it!

Second: if a user does not prefer reduced motion, we can update the rotation on every frame.

if(!$reducedMotion){
  SC.onFrame(() => {
    rotate += .01;
  })
}
Enter fullscreen mode Exit fullscreen mode

How can you test it? Geoff has us covered:

Here's how to simulate the setting in Chrome DevTools and where to enable the setting in various OSes and Firefox

Since you’re only simulating the setting in devtools, you’ll need to keep devtools open and refresh the REPL to see how things change.

GIF showing animated and non-animated transitions when simulating the prefers-reduced-motion browser setting in Chrome

Handling Variable Frame Rates Across Devices

What do all those words mean? Much smarter people can explain it better than me, but I’ll take a shot and then point you to a better explanation.

Some computer/monitor setups have powerful graphics and some do not. For example, our Octahedron is rotating 0.1 radians on each frame. So a setup running at 60 frames per second (fps) is watching your Octahedron rotate 0.1 radians 60 times every second. A less powerful setup running at 30fps is watching your Octahedron rotate 0.1 radians only 30 times every second. Those are very different experiences!

For a more accurate and in-depth explanation checkout The Animation Loop chapter from the open source book Discover three.js by Lewy Blue (twitter). If you are new to three.js I highly recommend taking some time to go through the whole book!

Basically we need a way to standardize our hardcoded value inside SC.onFrame based on the current frame rate. We can do just that using the threejs Clock and the method getDelta() and multiplying our value by delta.

const clock = new THREE.Clock();
SC.onFrame(() => {
  rotate += .01 * clock.getDelta();
})
Enter fullscreen mode Exit fullscreen mode

Wow, that’s slow. But it’s slow for everyone! Now we can adjust our rotation value to get the rate we want (try 0.5).

In the next article we’ll shift away from our Octahedron friend and dive into loading one or more glTF models into the scene!

References

REPL: https://svelte.dev/repl/c301f0ac026d45bdbf4facf55b921d1f?version=3.48.0

Octo.svelte

<script>
    import * as THREE from "three";
    import * as SC from "svelte-cubed";
    import { tweened } from "svelte/motion"
    import { elasticOut } from "svelte/easing"
    import { reducedMotion } from "./stores"

    let scaleType = "MEDIUM";
    let scale = tweened(1, {duration: $reducedMotion ? 0 : 2000, easing: elasticOut});

    // reactive statement to update scale based on scaleType
    $: if (scaleType === "SMALL"){
        $scale = .25;
    } else if (scaleType === "MEDIUM"){
        $scale = 1;
    } else if (scaleType === "LARGE") {
        $scale = 1.75;
    }

    let rotate = 0;
    if(!$reducedMotion){
        const clock = new THREE.Clock();
        SC.onFrame(() => {
            rotate += 0.5 * clock.getDelta();
        })
    }
</script>

<SC.Canvas background={new THREE.Color('seagreen')}>
    <SC.AmbientLight
    color={new THREE.Color('white')}
    intensity={.5}
  />  
    <SC.DirectionalLight
    color={new THREE.Color('white')}
    intensity={.75}
    position={[10, 10, 10]}
  />

  <!-- MESHES -->
   <SC.Mesh
    geometry={new THREE.OctahedronGeometry()}
    material={new THREE.MeshStandardMaterial({
      color: new THREE.Color('salmon')
    })}
        rotation={[rotate, rotate, rotate]}
    scale={[$scale, $scale, $scale]}
  />

  <!-- CAMERA -->
  <SC.PerspectiveCamera near={1} far={100} fov={55}>

    </SC.PerspectiveCamera>
    <SC.OrbitControls />

</SC.Canvas>

<div class="controls">
    <label>
    SMALL
        <input type="radio" bind:group={scaleType} value="SMALL" />
    </label>
    <label>
    MEDIUM
        <input type="radio" bind:group={scaleType} value="MEDIUM" />
    </label>
    <label>
    LARGE
        <input type="radio" bind:group={scaleType} value="LARGE" />
    </label>
</div>

<style>
    .controls {
        position: absolute;
        top: .5rem;
        left: .5rem;
        background: #00000088;
        padding: .5rem;
        color: white;
    }
</style>

Enter fullscreen mode Exit fullscreen mode

stores.js

import { readable } from "svelte/store"

/*
    Source: Geoff Rich, 
    "A Svelte store for prefers-reduced-motion", 
    URL: https://geoffrich.net/posts/svelte-prefers-reduced-motion-store/
*/
const reducedMotionQuery = '(prefers-reduced-motion: reduce)';

const getInitialMotionPreference = () => window.matchMedia(reducedMotionQuery).matches;

export const reducedMotion = readable(getInitialMotionPreference(), set => {
  const updateMotionPreference = event => {
    set(event.matches);
  };

  const mediaQueryList = window.matchMedia(reducedMotionQuery);
  mediaQueryList.addEventListener('change', updateMotionPreference);

  return () => {
    mediaQueryList.removeEventListener('change', updateMotionPreference);
  };
});

Enter fullscreen mode Exit fullscreen mode

Discussion (0)