Motion That Helps

Durations, easings and properties that feel native, honouring prefers-reduced-motion, and animating to explain a change.

Syntax@media (prefers-reduced-motion: reduce) { .fade-enter-active { transition-duration: 1ms } }

A transition earns its place when it answers a question the user would otherwise have to work out: where did this come from, where did it go, what just changed. A new row that slides up from the button that created it is explaining itself. The same row fading in from nowhere is decoration, and costs the same frames.

Numbers that feel native

Small changes in place — a message appearing, a checkbox filling — read well at 120 to 180ms. Something travelling across the screen, like a drawer or a page, wants 200 to 300ms. Past about 400ms the interface feels slow, and the delay is paid on every single use. Make leaves shorter than enters: the user has already decided, and waiting to watch something go is irritating.

For easing, ease-out suits things arriving, because it starts fast and settles. ease-in suits things leaving. linear is for crossfades and spinners, and looks mechanical on anything that moves.

Animate the cheap properties

Stick to transform and opacity. Animating width, height, top or margin makes the browser recompute layout on every frame; a list that is smooth with three rows stutters at thirty.

prefers-reduced-motion

Some people have motion reduced at the operating system level, often because movement makes them ill. CSS exposes it as @media (prefers-reduced-motion: reduce). Reduce means reduce, not remove: keep the opacity crossfade so the change is still noticeable, and drop the travel and the scale. Keep the durations in custom properties so one override covers the whole app.

Example

Example · vue
<script setup lang="ts">
interface Props {
  message: string | null
}

const props = defineProps<Props>()
</script>

<template>
  <transition name="toast">
    <p v-if="props.message !== null" class="toast" role="status">
      {{ props.message }}
    </p>
  </transition>
</template>

<style scoped>
.toast {
  padding: 0.5rem 0.75rem;
}

.toast-enter-active {
  transition: opacity 180ms ease-out, transform 180ms ease-out;
}

.toast-leave-active {
  transition: opacity 120ms ease-in;
}

.toast-enter-from {
  opacity: 0;
  transform: translateY(6px);
}

.toast-leave-to {
  opacity: 0;
}

@media (prefers-reduced-motion: reduce) {
  .toast-enter-active,
  .toast-leave-active {
    transition-duration: 1ms;
  }

  .toast-enter-from {
    transform: none;
  }
}
</style>

When to use it

  • Pick a duration for a dropdown that still feels quick on the hundredth use.
  • Honour a user's reduced-motion setting across every transition from one stylesheet.
  • Show where a newly created record went by moving it to its place rather than fading it in.
  • Decide whether an animation is worth it before adding it to a form people use all day.

More examples

Motion tokens in one place

Imported once in main.ts. Three durations and two easings are enough for a whole application, and because they are custom properties the reduced-motion override lives in one block instead of in every component. Custom properties pierce scoped styles, so a component's own <style scoped> can read them without any extra wiring. Movement still has to be removed per component, because transform values are not durations.

Example · css
/* src/assets/motion.css */
:root {
  --motion-fast: 120ms;
  --motion-base: 180ms;
  --motion-slow: 260ms;
  --ease-out: cubic-bezier(0.2, 0, 0, 1);
  --ease-in: cubic-bezier(0.4, 0, 1, 1);
}

@media (prefers-reduced-motion: reduce) {
  :root {
    --motion-fast: 1ms;
    --motion-base: 1ms;
    --motion-slow: 1ms;
  }
}

/* used from any component's <style scoped> */
.panel-enter-active {
  transition: opacity var(--motion-base) var(--ease-out),
    transform var(--motion-base) var(--ease-out);
}

.panel-leave-active {
  transition: opacity var(--motion-fast) var(--ease-in);
}

Reading the setting in JavaScript

CSS cannot reach a JavaScript animation, so when you animate in a hook you have to ask for the preference yourself. matchMedia returns a list that keeps reporting changes, and the change listener means the value follows the setting without a reload. onScopeDispose removes the listener when the component or effect scope that called the composable goes away. Note that window is touched at call time, so this runs in the browser only — on a server-rendered page, call it from a lifecycle hook that runs on the client.

Example · ts
// src/composables/useReducedMotion.ts
import { onScopeDispose, ref, type Ref } from 'vue'

export function useReducedMotion(): { reduced: Ref<boolean> } {
  const query = window.matchMedia('(prefers-reduced-motion: reduce)')
  const reduced = ref(query.matches)

  function update(event: MediaQueryListEvent): void {
    reduced.value = event.matches
  }

  query.addEventListener('change', update)
  onScopeDispose(() => {
    query.removeEventListener('change', update)
  })

  return { reduced }
}

// With the operating system set to reduce motion:
// Output: true

Skipping a JavaScript animation when motion is reduced

With :css="false" there are no classes for a media query to override, so the check happens in the hook. Calling done() immediately on enter skips the animation without skipping the state change — the panel still opens. The leave keeps a 1ms animation rather than returning early, which keeps the code paths identical; either is fine as long as done() is always called, since the element is only removed from the DOM when it is.

Example · vue
<script setup lang="ts">
import { ref } from 'vue'
import { useReducedMotion } from '@/composables/useReducedMotion'

const { reduced } = useReducedMotion()
const open = ref(false)

function onEnter(el: Element, done: () => void): void {
  if (reduced.value) {
    done()
    return
  }
  const animation = el.animate(
    [
      { opacity: 0, transform: 'scale(0.96)' },
      { opacity: 1, transform: 'none' }
    ],
    { duration: 180, easing: 'ease-out' }
  )
  animation.onfinish = () => done()
}

function onLeave(el: Element, done: () => void): void {
  const duration = reduced.value ? 1 : 120
  const animation = el.animate([{ opacity: 1 }, { opacity: 0 }], {
    duration,
    easing: 'ease-in'
  })
  animation.onfinish = () => done()
}
</script>

<template>
  <button type="button" @click="open = !open">Filters</button>

  <transition :css="false" @enter="onEnter" @leave="onLeave">
    <form v-if="open" class="filters">
      <label>
        Minimum rating
        <input type="number" min="1" max="5" />
      </label>
    </form>
  </transition>
</template>

<style scoped>
.filters {
  padding: 0.75rem;
}
</style>

Discussion

  • Be the first to comment on this lesson.