3 min readNext.jsFramer MotionDebugging
AnimatePresence and the pages that went blank
An exit animation in the App Router left 30 of 40 pages at opacity: 0 — header and footer on screen, content invisible, and nothing a text search could find.
Switch between sections of this site quickly, and sometimes the page came up empty: header and footer in place, the page the right height, and nothing in between. Reload, and it was fine. The kind of bug you can shrug off as a one-off — until you count it.
The code that did it
The page transition was the textbook Framer Motion pattern: wrap the page in AnimatePresence, key
it on the pathname, fade the old page out before the new one comes in.
const PageTransition = ({children}) => {
const pathname = usePathname();
return (
<AnimatePresence mode="wait">
<motion.div
key={pathname}
initial={{opacity: 0, y: 12}}
animate={{opacity: 1, y: 0}}
exit={{opacity: 0, y: -8}}
transition={{duration: 0.35, ease: [0.16, 1, 0.3, 1]}}
>
{children}
</motion.div>
</AnimatePresence>
);
};
mode="wait" means the new page mounts only after the old one has finished fading out. That is the
whole point of it — and the whole problem. If another navigation arrives while that fade is still
running, the wrapper can be left at opacity: 0 with nothing scheduled to bring it back. In the App
Router, where navigations are instant and the layout around the page never unmounts, that is easy
to hit: click one link, change your mind, click another.
Counting it
A bug that shows up "sometimes" needs a number before it gets a fix. I scripted 40 quick switches between sections against the production site and checked, after each one, whether the page was visible. 30 of the 40 ended on a blank page.
How you check matters. The obvious test — look for the page's text with innerText or a text
locator — passes on every one of those blank pages, because the text is there. It is just
transparent. What catches it is the computed opacity of the wrapper:
// On a blank page this is "0", with all of the page's text still in the DOM.
getComputedStyle(document.querySelector("#main > div")).opacity;
The fix: only ever animate towards visible
There is no exit animation any more. The page animates in, keyed on the pathname, and that is all:
const PageTransition = ({children}: {children: ReactNode}) => {
const pathname = usePathname();
return (
<motion.div
key={pathname}
initial={{opacity: 0, y: 6}}
animate={{opacity: 1, y: 0}}
transition={{duration: 0.22, ease: [0.16, 1, 0.3, 1]}}
>
{children}
</motion.div>
);
};
A new key remounts the wrapper and restarts the entrance. An interrupted entrance is simply replaced
by the next one, and every animation that can run is heading towards opacity: 1 — there is no
state in which the page is waiting on something to finish before it may appear.
Two related changes went in with it:
- The scroll reveals used
whileInView. They now useuseInViewplus an explicitanimatetarget, so whether a section is visible is ordinary React state rather than something an interrupted animation can leave half-done. - Three pages had their own fade-in wrappers on top of the page transition. They duplicated it and were a second place for the same failure, so they went.
The same 40-switch script afterwards: 0 blank pages.
The trade-off
The old page now disappears at once instead of fading out, and the new one takes 220 ms to settle in. That is a small loss next to the alternative: a blank page is the first thing a visitor notices, and on a portfolio it reads as "this person ships broken sites".
The source is in
components/PageTransition.tsx
and components/Reveal.tsx.
Keep reading
More notes
5 min read
Two windows, one client id
A shared whiteboard where one window's edits reached nobody. The protocol was right and its tests were green — the browser had copied something I had assumed belonged to a single tab.
- Realtime
- Browser storage
- Debugging
5 min read
The refusal that named nothing
A change the room threw away stayed queued for ever: its author kept seeing an edit no one else had. Fixing it was less about the limits than about what a refusal has to carry.
- Realtime
- Protocol design
- Testing
4 min read
The lag my test browser could not see
Headless Chrome draws at 60 fps whatever the monitor. On a 165 Hz screen that hid a 146 ms frame — and the CSS property behind it was one I had decided was free.
- Performance
- CSS
- Measuring
