Reduced motion
Some people turn motion down in their system's settings, because it makes them unwell or pulls their eye away. Wardic follows that setting unless told otherwise: what moves an element across the page or resizes it jumps to where it's going, and the rest, such as opacity and colour, still animates.
import { createReducedMotion, w, WardicConfig } from "@wardic/solid/unstable/v2"
import { createSignal, For } from "solid-js"
const SETTINGS = ["user", "always", "never"] as const
/** One move under each setting: under reduced motion the box jumps across, and still fades. */
export default function ReducedMotion() {
const [setting, setSetting] = createSignal<(typeof SETTINGS)[number]>("always")
const [moved, setMoved] = createSignal(false)
// The visitor's own setting, as their system gives it.
const prefers = createReducedMotion()
return (
<div class="sample-stack">
<div class="sample-tabs" role="group" aria-label="reducedMotion">
<For each={SETTINGS}>
{(value) => (
<button class="sample-tab" aria-pressed={setting() === value ? "true" : "false"} onClick={() => setSetting(value)}>
{value}
</button>
)}
</For>
</div>
<button class="sample-button" onClick={() => setMoved(!moved())}>
Move
</button>
<WardicConfig reducedMotion={setting()}>
<w.div class="sample-box" animate={{ x: moved() ? 160 : 0, opacity: moved() ? 0.4 : 1 }} transition={{ duration: 0.8, ease: "easeInOut" }}>
box
</w.div>
</WardicConfig>
<p class="sample-note">Your system {prefers() ? "asks" : "doesn't ask"} for reduced motion.</p>
</div>
)
}import { createReducedMotion, w, WardicConfig } from "@wardic/solid"
import { createSignal, For } from "solid-js"
const SETTINGS = ["user", "always", "never"] as const
/** One move under each setting: under reduced motion the box jumps across, and still fades. */
export default function ReducedMotion() {
const [setting, setSetting] = createSignal<(typeof SETTINGS)[number]>("always")
const [moved, setMoved] = createSignal(false)
// The visitor's own setting, as their system gives it.
const prefers = createReducedMotion()
return (
<div class="sample-stack">
<div class="sample-tabs" role="group" aria-label="reducedMotion">
<For each={SETTINGS}>
{(value) => (
<button class="sample-tab" aria-pressed={setting() === value ? "true" : "false"} onClick={() => setSetting(value)}>
{value}
</button>
)}
</For>
</div>
<button class="sample-button" onClick={() => setMoved(!moved())}>
Move
</button>
<WardicConfig reducedMotion={setting()}>
<w.div class="sample-box" animate={{ x: moved() ? 160 : 0, opacity: moved() ? 0.4 : 1 }} transition={{ duration: 0.8, ease: "easeInOut" }}>
box
</w.div>
</WardicConfig>
<p class="sample-note">Your system {prefers() ? "asks" : "doesn't ask"} for reduced motion.</p>
</div>
)
}import { MotionConfig, useReducedMotion } from "motion/react"
import * as m from "motion/react-m"
import { useState } from "react"
const SETTINGS = ["user", "always", "never"] as const
/** One move under each setting: under reduced motion the box jumps across, and still fades. */
export default function ReducedMotion() {
const [setting, setSetting] = useState<(typeof SETTINGS)[number]>("always")
const [moved, setMoved] = useState(false)
// The visitor's own setting, as their system gives it.
const prefers = useReducedMotion()
return (
<div className="sample-stack">
<div className="sample-tabs" role="group" aria-label="reducedMotion">
{SETTINGS.map((value) => (
<button key={value} className="sample-tab" aria-pressed={setting === value} onClick={() => setSetting(value)}>
{value}
</button>
))}
</div>
<button className="sample-button" onClick={() => setMoved(!moved)}>
Move
</button>
<MotionConfig reducedMotion={setting}>
{/* Motion reads the setting as an element mounts: the key mounts the box again when it changes. */}
<m.div key={setting} className="sample-box" animate={{ x: moved ? 160 : 0, opacity: moved ? 0.4 : 1 }} transition={{ duration: 0.8, ease: "easeInOut" }}>
box
</m.div>
</MotionConfig>
<p className="sample-note">Your system {prefers ? "asks" : "doesn't ask"} for reduced motion.</p>
</div>
)
}The setting
reducedMotion, on the WardicProvider or a <WardicConfig> anywhere under it, the nearest one winning:
"user", the default: follow the visitor's system."always": reduce, whatever the system says."never": don't reduce.
It's read as each animation starts, so a change applies from the next one, and a change to the system's setting is followed as it happens. Motion's default is "never", and it reads the setting once, as each element mounts.
What changes
- Keys that move or resize snap:
x,y,z,scale,rotate,skewand their axes, andwidth,height,top,left,rightandbottom. Each waits out the delay it would have had, stagger included, and lands as the other keys start. - Every other key animates as it would: opacity, colours, filters and shadows.
- Where it lands is where the animation would end: a spring's or tween's target, keyframes' last stop, where an
inertiaglide would rest, and with arepeat, where its last run would finish. A key that repeats forever lands where its first run ends, and the keys that still animate repeat on. - Layout animations land on the new box at once, and a
layoutIdcrossfade switches at once.onLayoutAnimationStartandonLayoutAnimationCompleteare both called, in that frame. - Exits are reduced the same way.
- Drag isn't reduced: the element follows the pointer, and on release it glides on, or springs back, as Motion's does. It's the visitor's own gesture carrying on, not motion they didn't ask for.
- Live values aren't reduced:
createSpringandcreateFollowValuefollow their source as they would, and a scroll value is the scroll. To calm one, read the setting, below.
To keep one animation moving whatever the setting, give its transition respectReducedMotion: false, as for a spinner, which says something is happening:
<w.div animate={{ rotate: 360 }} transition={{ duration: 1, ease: "linear", repeat: Infinity, respectReducedMotion: false }} />Reading it
createReducedMotion()is the visitor's system setting, as an accessor: Motion'suseReducedMotion. On the server, and as the page hydrates, it'sfalse.createReducedMotionConfig()is whether animations here are reduced: the nearestreducedMotionover the system's setting. It's Motion'suseReducedMotionConfig.
Read one to change what moves, not only how:
const reduced = createReducedMotionConfig()
// A card that slides in, or only fades in when motion is reduced.
<w.div initial={reduced() ? { opacity: 0 } : { opacity: 0, x: 60 }} animate={{ opacity: 1, x: 0 }} />Before the JavaScript
On a server-rendered page, the entrances that play from the first paint carry both plays, the reduced one under @media (prefers-reduced-motion: reduce), so the browser picks one before any script has run. See Server rendering.
Skipping animations
skipAnimations on a <WardicConfig> is something else: every key lands on its next frame, with no delay, exits, layout animations and a drag's release included. It wins over reducedMotion. It's for tests, and for a page that wants no motion at all; see Testing.
Testing it
Give the test's provider reducedMotion="always", or, in a browser test, emulate the system setting, as Playwright's page.emulateMedia({ reducedMotion: "reduce" }) does.