hệ thốngVS_CODE
blog index
post.mdx/EN

Honoring prefers-reduced-motion in a portfolio full of motion

Adding a hook and gating every chaos effect, glitch shake, and ASCII keyframe behind a single user preference — without losing the personality of the site.

3 phút đọcaccessibility · framer-motion · react

This portfolio is loud. Chaos tilt on hover, glitch shake, scanlines, an ASCII identity that does an RGB-split dance, animated cursor trails. Most users love it. Some users get nauseous.

The web has a standard for this — prefers-reduced-motion: reduce — and I wasn't honoring it.

The hook

I added one hook with useSyncExternalStore:

const QUERY = "(prefers-reduced-motion: reduce)";
 
const subscribe = (cb: () => void) => {
  const mql = window.matchMedia(QUERY);
  mql.addEventListener("change", cb);
  return () => mql.removeEventListener("change", cb);
};
 
const getSnapshot = () => window.matchMedia(QUERY).matches;
const getServerSnapshot = () => false;
 
export function usePrefersReducedMotion() {
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

useSyncExternalStore instead of useState + useEffect because it gives a correct SSR snapshot (false on the server, real value after hydration) and avoids the flash where motion plays for a frame before being disabled.

The application

Every animation that meaningfully moves now reads this flag:

  • Chaos tilt → return early in the pointer-move handler
  • Glitch shake → swap keyframes for static { x: 0, y: 0, filter: "none" }
  • ASCII RGB shadow layers → animate to opacity: 0 instead of looping
  • Hover-driven scroll-linked animations → skip the spring

What I didn't change

The static layout. The colors. The cursor effects. The scanline overlay. None of these move; they're decoration.

Reduced motion isn't "remove everything fun." It's "stop moving things that don't need to move to communicate."

Sẵn sàng