Skip to content

fix(motion): honour reduced motion without stranding content - #24

Merged
tkodev merged 2 commits into
mainfrom
fix/reduced-motion
Jul 16, 2026
Merged

fix(motion): honour reduced motion without stranding content#24
tkodev merged 2 commits into
mainfrom
fix/reduced-motion

Conversation

@tkodev

@tkodev tkodev commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Why

With prefers-reduced-motion: reduce, the about page rendered blank below the hero. The cause was not about-specific: Reveal is used on all four routes, so home, works, experience and work details were all affected.

Reveal branched on useReducedMotion() at render time. That hook cannot run on the server, so SSR always emitted the animated branch, with framer serialising hidden into inline style="opacity:0; filter:blur(8px); transform:translateY(24px)". The client, under reduced motion, rendered a plain styleless div. React reconciles the element but does not patch up inline-style mismatches (it says so in the hydration warning), so the server's opacity: 0 stayed in the DOM permanently.

The fix keeps server and client markup identical and varies only the transition. Under reduced motion the element must still animate: the animation is the only thing that clears the SSR-rendered start state.

What

The same principle throughout: keep the fade, drop the travel. Opacity carries no vestibular cost; the travel is what reduced motion is asking about.

Change Why
constants/motion.ts revealItemReduced snaps the rise and de-blur to duration: 0 and fades opacity; both variants share one revealHidden state so markup hydrates identically
atoms/reveal.tsx Static branch removed; always renders the motion component, swaps variants
atoms/marquee.tsx motion-reduce:paused froze the track inside overflow-hidden, putting every entry past the right edge permanently out of reach. Becomes a plain scroller and drops its seam duplicate
atoms/reticle.tsx The outer logo marks translated 100% of their own height; they now fade in place like the middle one
themes/theme.css vaul animates the drawer panel from its own injected stylesheet, out of reach of motion-reduce: utilities and with no prop to disable it, so its slide keyframes swap for a fade
docs/02-prd/03-solution.md Records the marquee and reveal behaviour under §Edge cases

Accordions are deliberately untouched: 0.2s, user-initiated, and the height growth is the disclosure feedback.

Verification

Driven in Chromium via Playwright against both reducedMotion: reduce and no-preference:

  • Zero hydration errors; zero Reveal wrappers left below opacity 1 on /about (previously all of them).
  • Marquee under reduce: overflow-x: auto, animation off, duplicate display: none, 944px of reachable scroll. Under no-preference: unchanged.
  • Reticle under reduce: all three logo groups on fade-in. Under no-preference: boot-down / fade-in / boot-up stagger intact.

pnpm type:check and pnpm lint:check clean.

Not covered

  • The reduced-motion marquee scroller has no explicit tabIndex. Chrome 127+ and Firefox make scroll containers keyboard-focusable automatically, but that is not universal, and adding it conditionally needs a client component.
  • atoms/counter.tsx uses the same branch-on-reduced shape. Far less severe (text mismatches are re-rendered by React rather than stranded), but it is the same latent pattern.

tkodev added 2 commits July 16, 2026 13:55
Reveal branched on useReducedMotion at render, so the server (which cannot
read the preference) emitted framer's hidden state as inline styles while the
client rendered a bare div. React does not patch up inline-style mismatches,
leaving every Reveal stuck at opacity 0: all four routes rendered blank below
the hero under reduced motion. Reveal now always animates and only swaps its
variant, since the animation is the one thing that clears the SSR start state.

The same rule applied elsewhere: keep the fade, drop the travel.

- reveal/motion: revealItemReduced snaps the rise and de-blur, fades opacity;
  both variants share one hidden state so the markup hydrates identically
- marquee: a paused drift inside overflow-hidden put every entry past the edge
  out of reach, so the row becomes a plain scroller and drops its seam duplicate
- reticle: the outer logo marks fade in place instead of travelling their height
- drawer: vaul animates the panel from its own injected stylesheet, beyond the
  reach of motion-reduce utilities, so the slide keyframes swap for a fade
@vercel

vercel Bot commented Jul 16, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
tkodev-web-v5 Ready Ready Preview, Comment Jul 16, 2026 6:38pm

@tkodev
tkodev merged commit f810589 into main Jul 16, 2026
3 checks passed
@tkodev
tkodev deleted the fix/reduced-motion branch July 16, 2026 18:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant