Svelte-Cubed: Creating an Accessible and Consistent Experience Across Devices
Tags: svelte, threejs, beginners, tutorial
Published: 2022-05-10 17:13:25

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:

- Part One: [Svelte-Cubed: An Introduction to 3D in the Browser](https://dev.to/alexwarnes/svelte-cubed-an-introduction-to-3d-in-the-browser-1ea3)

- Part Two: [Svelte-Cubed: Adding Motion to 3D Scenes](https://dev.to/alexwarnes/svelte-cubed-adding-motion-to-3d-scenes-51lo)

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](https://twitter.com/tomayac)) in [prefers-reduced-motion: Sometimes less movement is more](https://web.dev/prefers-reduced-motion/)

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](https://twitter.com/geoffrich_)) explained in his post [A Svelte store for prefers-reduced-motion](https://geoffrich.net/posts/svelte-prefers-reduced-motion-store/). 

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.

```javascript
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);
  };
});

```

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).

```javascript
import { reducedMotion } from "./stores"
	
let scale = tweened(1, {duration: $reducedMotion ? 0 : 2000, easing: elasticOut});
```

That’s it? That’s it!

**Second**: if a user _does not_ prefer reduced motion, we can update the rotation on every frame.

```javascript
if(!$reducedMotion){
  SC.onFrame(() => {
    rotate += .01;
  })
}
```

How can you test it? Geoff has us covered: 

> _Here's how to simulate the setting in [Chrome](https://developers.google.com/web/updates/2019/10/devtools#userpreferences) DevTools and where to enable the setting in various [OSes and Firefox](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion#user_preferences)_ 

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/h47k89gvr051xkhu1ehc.gif)

## 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](https://discoverthreejs.com/book/first-steps/animation-loop/#fixed-and-dynamic-frames) chapter from the open source book _Discover three.js_ by Lewy Blue ([twitter](https://twitter.com/lewy_blue)). 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.

```javascript
const clock = new THREE.Clock();
SC.onFrame(() => {
  rotate += .01 * clock.getDelta();
})
```

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
```html
<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>

```

### stores.js
```javascript
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);
  };
});

```