When the vault page first existed, clicking the nav link just loaded it. For a page deliberately presented as a cinematic space -- an ancient candlelit library, amber glows, two sleeping cats -- arriving via an instant page swap felt wrong. The entrance needed to earn the destination.

The result is a full-screen cinematic sequence: black curtain, title fades in at screen centre, cross-dissolves to the bottom-left position it occupies on the actual vault page, subtitle appears below it, then the vault reveals underneath. Exit is a black curtain falling before navigating away. This post covers how it works, in its current form.


The state machine

A useState(false) boolean gets unwieldy the moment you need to distinguish between "curtain falling" and "curtain up but title sequence playing" and "navigated but overlay still fading out". The answer is a proper state machine with explicit named phases.

useVaultTransition.jsx is a React context that exposes the phase and the functions that advance it:

idle → entering → revealing → idle   (entry)
idle → exiting  → revealing → idle   (exit)
  • idle -- nothing active
  • entering -- title sequence playing, pre-navigation
  • exiting -- black curtain falling, pre-navigation
  • revealing -- navigation has happened, overlay fading out to show the new page

The single most important design decision in the whole system: navigation always happens while the overlay is fully opaque. The new page renders underneath the curtain and is revealed by a fade-out. This is what prevents both the black flash on entry (vault page snapping in before the overlay clears) and the flashbang on exit (the white site exploding into view after the vault fades).

const completeEnter = useCallback(() => {
  document.body.setAttribute('data-page', 'vault') // set BEFORE navigate
  navigateRef.current?.('/vault')
  setPhase('revealing')
}, [])

const completeExit = useCallback(() => {
  document.body.removeAttribute('data-page')       // remove BEFORE navigate
  navigateRef.current?.(destinationRef.current ?? '/')
  setPhase('revealing')
}, [])

Both directions feed into the same revealing phase. One CSS rule handles the fade-out regardless of which direction you're travelling:

.vault-transition-overlay--revealing {
  animation: vtOverlayFadeOut 0.5s ease 0.05s forwards;
}

The data-page timing here is also deliberate. Vault-specific header and footer styles are applied via body[data-page="vault"]. If that attribute is set in a useEffect inside Vault.jsx -- which fires after paint -- there's a one-frame window where the vault page renders with default header styles before the vault styles kick in. Setting it inside completeEnter while the overlay is still opaque means it's been active for hundreds of milliseconds by the time the overlay lifts. No flash.

Vault.jsx still sets the attribute on mount as a fallback for direct URL navigation and browser back -- but when arriving via the transition it's already set and the useEffect is a no-op.


The overlay component

VaultTransitionOverlay.jsx owns the timers and calls back into the context to advance phases. It renders only when phase !== 'idle'.

The enter sequence timings:

  • 0ms: overlay mounts, background fades in over 700ms (CSS animation)
  • 350ms: title fades in at screen centre (CSS animation with delay)
  • 1300ms: cross-dissolve fires -- stage switches from centre to bottomleft
  • 2400ms: completeEnter() called -- navigation happens, phase becomes revealing
  • ~2900ms: finishReveal() called -- phase resets to idle, overlay unmounts

The exit sequence is simpler -- just the curtain falling and then navigation:

  • 0ms: overlay mounts, curtain fades in over 600ms
  • 700ms: completeExit() called -- navigation happens, phase becomes revealing

Timers are stored in a useRef array and cleared at the top of each phase change:

useEffect(() => {
  timerRefs.current.forEach(clearTimeout)
  timerRefs.current = []

  if (phase === 'entering') {
    const t1 = setTimeout(() => setStage('bottomleft'), 1300)
    const t2 = setTimeout(completeEnter, 2400)
    timerRefs.current.push(t1, t2)
  }
  // ...
}, [phase, completeEnter, completeExit, finishReveal])

Cleaning up in the same effect on each phase change avoids the stale closure problem where a separate cleanup function closes over an outdated phase value.


The cross-dissolve

The title cross-dissolve from centre to bottom-left went through one failed attempt before landing on the right approach. The first try used CSS transition on top, left, and transform to slide the element to its destination position. CSS can't reliably interpolate between top: 50% and a complex calc() expression involving viewport height -- the element ended up approximately right but never exactly aligned with where the vault's intro text lives, and the offset varied with viewport size and font rendering.

The correct approach: ditch the slide entirely. Two separate DOM elements, one per position, with cross-fading opacity. No position interpolation -- just a clean dissolve.

{/* Stage 1: centred */}
<div className={`vault-transition-centre${stage !== 'centre' ? ' vault-transition-centre--out' : ''}`}>
  <h1 className="vault-transition-title">
    Mayu's Architecture Vault
    <span className="vault-transition-alpha">BETA</span>
  </h1>
</div>

{/* Stage 2: bottom-left with subtitle */}
<div className={`vault-transition-bottomleft${stage === 'bottomleft' ? ' vault-transition-bottomleft--in' : ''}`}>
  <h1 className="vault-transition-title">
    Mayu's Architecture Vault
    <span className="vault-transition-alpha">BETA</span>
  </h1>
  <p className="vault-transition-subtitle">
    Architectural decisions, system design, and honest post-mortems.
  </p>
</div>

The --out class sets opacity: 0 and animation: none on the centre element (cancelling the fade-in if it's still running), and the transition handles the dissolve. The --in class on the bottom-left element brings it up.

It's also a better design decision. A cross-dissolve reads as a cinematic cut. The original slide looked like a UI component moving into position. The vault is a space, not a panel.


The hover preview

Separate from the transition, hovering the vault nav link from any other page previews the vault background inside the header itself. The nav links tint to amber, the header fills with the library image, and a gradient fades the bottom edge.

The mechanism is two pseudo-elements on .site-header, both at opacity: 0 by default with transition: opacity 0.35s ease. A React state variable in Header.jsx adds .site-header--vault-preview when hovering the vault link, which sets both pseudo-elements to opacity: 1.

transition is the right tool here rather than animation: forwards. A transition reverses cleanly on mouse-out. An animation: forwards locks the element in its final state even after the hover ends, which breaks the hover-out fade.

The MeekoBubble speech bubble uses CSS custom properties (--border, --bg, --text) so overriding those on its wrapper element propagates automatically into the ::before/::after pseudo-elements that form the bubble tail. One token override, no specificity battles.


The exit intercept

Header.jsx wraps each nav link's onClick to check whether the current location is the vault:

function handleExitClick(destination) {
  return (e) => {
    if (!isVault) return
    e.preventDefault()
    startExit(destination, navigate)
  }
}

Each NavLink gets its own handler with the destination baked in. When on the vault, any nav click is intercepted and React Router's default navigation is suppressed.

An early version tried to intercept at the vault-scene div level. This didn't work because the header sits outside the vault scene in the DOM -- clicks on nav links never bubbled into the scene handler at all.


What the state machine actually buys you

Six distinct timing bugs surfaced during the original build of this system. All five that were caused by code problems (as opposed to the one coordinate issue) came down to the same root cause: something changed before something else was ready. The overlay vanished before the page rendered. The header flashed before the attribute was set. The title slid to approximately the right place rather than exactly the right place.

The state machine made every one of these diagnosable. "The flash happens because the overlay disappears at exiting → idle" is a precise description with an obvious fix. "The flash happens" is not. Explicit phases with explicit names give you vocabulary for the problem space. Worth the overhead every time.