Detail 14 · UX
Motion you can switch off
A settings island with three switches: a calm version for devices that ask for less motion, everything still for visitors who switch it off, and the hover that stays either way. 8 minutes, 2 demos.
On
Rises, sharpens, settles
Calm
Your device asks for less: it fades in place
Off
Your switch: it's just there
01 · The idea
Motion is the visitor’s to turn down
Everything here moves, and for most visitors that’s the point. Not for all of them: some people get dizzy or sick from movement on screen, and some just came to read. A site this animated owes them a way out, and the way out has to be easy to find, say what it does, and leave the site working.
So there are two ways to less motion. A device can ask for it, with the system’s reduce-motion setting, and the site answers with a calm version. Or a visitor can switch animations off here, in the settings, and then nothing moves at all.
02 · The switches
Three switches that explain themselves
The settings are an island in the header’s far corner, opening from it like the music’s: animations, page transitions and sound. Under each switch, a line says what it does right now, and it morphs as the switch flips: “Everything moves” becomes “Off: things just appear (demos still play)”.
A switch that can’t be flipped says why instead of just greying out. Page transitions are animations too, so with the animations off their switch locks and reads “Off, with the animations”. And when a device asks for less motion, the animations switch locks on the calm version: the device has the last word.
function linesFor({ animations, transitions, sound }, device) {
return [
{
key: "animations",
label: "Animations",
on: animations && !device,
// A device that asks for less motion has the last word
locked: device,
line: device ? "Calm: your device asks for less motion" : animations ? "Everything moves" : "Off: things just appear (demos still play)",The choice is kept in this browser, so it’s still there next week. That raised its own problem: a visitor who switched something off and forgot finds a quiet site and no reason why. So while anything is off, the closed pill wears a small ember dot in its corner. And every switch is heard as it flips, a little higher going on; switching the sound itself on clicks, switching it off doesn’t.
03 · Calm and off
Calm is not off
The two asks sound alike and aren’t. A device asking for less motion still wants to see that something changed, just without being moved around: in the calm version, things fade in place instead of flying, pages change straight away, the parallax stops, and the browser’s own scrolling takes over from the eased one. A visitor switching animations off wants none of it: every change lands at its end at once, there’s no intro, and text swaps whole instead of morphing.
Below, the same card comes and goes three ways. Point at the pills while it does.
On
Rises, sharpens, settles
Calm
Your device asks for less: it fades in place
Off
Your switch: it's just there
The demos are the one exception to “off”. They still play, and say so under their box, with a switch to have the animations back: the animation is what each one is there to show.
Most of the calm version is one condition where a motion starts. The parallax hook simply returns nothing, and the layers move with the page:
export function useParallax(speed) {
const reduceMotion = useReducedMotion();
const { scrollY } = useScroll();
const y = useTransform(scrollY, (scroll) => scroll * speed);
return speed && !reduceMotion ? y : undefined;
}Here is every motion on the site and its calm version. Two aren’t done yet: the reveals still zoom in from 105%, the kind of motion the setting is there to stop, and the islands still spring open through a blur, with only their squish dropped.
- The intro’s signatureWritten stroke by stroke, over 3.2 s
- Page changesThe page sinks, is sketched, then builds in
- ParallaxEach layer at its own speed
- Smooth scrollingEased by Lenis
- The stringsRing with the music
- The grainJumps ten times a second
- Music notesSlip out of the island on each note
- RevealsBlur in, zooming from 105%
- IslandsSpring open through a blur
04 · What stays
A hover still answers
The first version of the off switch stopped everything, hovers included. It was faithful to the word and wrong in use: a button that doesn’t react when you point at it doesn’t look still, it looks broken. A hover, a focus ring, a press are the page answering you, not motion for its own sake.
So they stay the same in every mode, on, calm and off. Each is written in CSS and marked interact, and the rule that stills everything else steps round it:
:root[data-still] :not([data-live-motion], [data-live-motion] *, .interact),
:root[data-still] :not([data-live-motion], [data-live-motion] *)::before,
:root[data-still] :not([data-live-motion], [data-live-motion] *)::after {
transition: none !important;They’re quick enough not to count as motion: 150 milliseconds on an ease-out, a press sinking a few percent. What a hover reveals is different: a drawing playing, a note appearing, text morphing. That’s a change like any other, and it follows the switch.
The browser’s own smooth scroll stays too. A link to a section further down still glides there, as it does on any site; only the eased, weighted scrolling on top of it goes.
05 · One place
One place that knows
Every animation on the site asks one file whether it may move: lib/motion.jsx. It knows the device’s setting and the visitor’s switch, and hands out the calm version, the off state, and animate() and springs that land at once when off. A wrapper round the whole site tells Motion to skip animations, and marks the page still for the CSS. That mark goes on before the first paint, from a line of script in the page’s head, so not even the first frame moves:
const STILL_SCRIPT = `try{if(JSON.parse(localStorage.getItem("sujoy-settings")||"{}").animations===false)document.documentElement.setAttribute("data-still","")}catch(e){}`;The library’s switch got most of the way. The rest were things it doesn’t say, each found by flipping the switch and watching something go wrong:
- It’s read once, as an element appears. Flipped later, the islands already on the page went on animating until a reload. Now a flip reaches every element already there.
- Layout animations ignore it. The navbar’s logo kept its 1.2-second resize between pages with everything else switched off; it’s given a duration of zero by hand.
- An exit needs something to land from. With animations off, an element leaving with no value of its own to go from never left at all.
- A setting that starts an effect replays it. Switching animations off blurred every island at once, none of them opening: the setting was listed as a reason to play the blur. Now it’s read when the blur plays, never a reason to start one.
- New options rebuild the smooth scroll. It’s built once, and the switch flips a setting on the one already running.
function applyToMounted(off) {
for (const element of document.querySelectorAll("*")) {
const visualElement = visualElementStore.get(element);
if (visualElement) visualElement.shouldSkipAnimations = off && !element.closest("[data-live-motion]");
}
}06 · Decisions
Why it’s built this way
The device has the last word
If a device asks for less motion, no switch on the site overrides it.
Calm and off are different
Calm still fades so a change is seen. Off lands everything at once.
A hover isn't motion
Feedback to the pointer stays in every mode. Without it, a still site reads as a broken one.
Say what it does now
Every switch has a line under it, and a locked one says why.
07 · Takeaways
What to keep
- Offer both: answer the device’s reduce-motion setting with a calm version, and give visitors a switch of their own.
- Keep hover, focus and press in every mode; switch off motion, not feedback.
- Put the decision in one place, and flip the switch on a page that’s already open: that’s where the bugs are.
Open the settings in the header’s far corner, switch animations off, then change pages.