<YC />
Back to blog
4 min read

Why This Site's Intro Replays After You Close the Tab

A home-only overlay, sessionStorage (not localStorage), useSyncExternalStore, and Motion's AnimatePresence — with the hero painted underneath so LCP is not gated on the intro.

The overlay on / is not a real loader. The page is already there. Home plays a short branded sequence, then gets out of the way.

One-sentence answer: Remember the intro for this tab with sessionStorage. Close the tab and the session dies, so it plays again. useSyncExternalStore reads that flag without a hydration flicker; Motion's AnimatePresence is what lets the overlay leave instead of popping off.

What you actually see

IntroLoader is composed only on the home page. /blog and /about never mount it.

You doIntro?
First visit to / in this tabYes
Refresh, or /about → / in the same tabNo
Close the tab, open / againYes (session ended)
OS “reduce motion”No

That is a choreography, not a progress bar tied to fonts or data.

sessionStorage, not localStorage

The browser stores intro-seen = 1 until that tab's session ends. Refresh keeps it. A new tab or a closed tab does not.

localStorage would remember forever. Fine for a cookie banner. Wrong for a calling-card intro you still want on the next visit.

When the bar fills and a short hold finishes, completeIntro does two things:

  1. Write intro-seen
  2. Fire a small event so React knows to hide the overlay

Logic lives in lib/intro.ts. The component does not decide skip/replay itself.

Why useSyncExternalStore

React cannot read sessionStorage during SSR. A naive useState + useEffect would render “show” on the server, paint “show” on the client, then flip to “skip” in an effect — a flash, and the set-state-in-effect lint this repo already hit.

useSyncExternalStore is React's hook for a value that lives outside React.

const showIntro = useSyncExternalStore(
  subscribeIntroVisibility,
  getIntroClientSnapshot,
  getIntroServerSnapshot,
);
ArgumentJob
SubscribeRe-render when the seen flag is written, or reduced-motion changes
Client snapshotIn the browser: show only if unseen and motion is allowed
Server snapshotAlways true — there is no window yet

Return visits still should not flash. A tiny inline script in the root layout runs before React and sets data-intro="seen" on <html> when the session flag is already there. CSS then hides [data-intro-loader] on first paint. React can catch up after.

Why Motion and AnimatePresence

CSS can fade a node that stays in the DOM. When showIntro becomes false, React would delete the overlay on the next render — no exit.

AnimatePresence keeps that node mounted until exit finishes (opacity: 0, a slight y: -12). Inner motion nodes stagger mark → name → role → bar.

This site already depends on motion. The import is motion/react — not a second Framer package.

Why the hero stays visible under the overlay

The overlay is position: fixed and covers the viewport. The heading underneath can still paint at opacity: 1. Chrome can then record LCP on that heading at first paint, instead of waiting until the overlay exits.

Hiding [data-intro-hero] until data-intro-ready looked cleaner, but it delayed Largest Contentful Paint by the full intro (~2s) plus a 700ms fade. The hero stays a Server Component. No "use client" just to hold a boolean.

Is this the clean split?

For a portfolio, yes:

  • Skip/duration helpers in lib/intro.ts, with Vitest on the branches
  • Overlay UI in components/intro/
  • Home page only composes it
  • Reduced motion is a hard skip
  • Exit animation goes through AnimatePresence, not display: none mid-fade

Do not set data-intro="seen" while the overlay is still exiting. That CSS hide would kill the fade. Session storage can flip immediately; the HTML data-intro attribute waits for the next load.

Two honest limits: first paint can be an empty overlay for a couple of frames while stagger starts at opacity 0, and none of this is “the site is loading.”