Skip to Content
Motion and transitions

Motion and transitions

How the site moves, who owns each movement, and the probe that keeps navigation from regressing.

Motion tokens

src/config/motion.ts is the single source of truth. Components and .css.ts files use its tokens and never hard-code a duration, easing, distance or stagger.

TokenValueUsed for
duration.fast200 mshover and state changes, small items
duration.base400 mspanels and the route fade
duration.slow800 mspage entrances
ease.outcubic-bezier(0.16, 1, 0.3, 1)exponential ease-out, the entrance curve
ease.standardcubic-bezier(0, 0, 0.58, 1)small, quick state changes
distance.rise20 px (1.25rem)entrance travel
stagger.step120 msdelay between groups (header, then body)

The values are plain data, so the same constants feed vanilla-extract (through the easeCss and distanceCss strings) and framer-motion (through the numbers and tuples, with seconds() for durations). There is no bounce or overshoot anywhere.

The transition model

Moving between pages layers three things, each with one owner.

  1. The route fade (PageWrapper). src/partials/ClientLayout/PageWrapper.tsx wraps the routed page. When the pathname changes it starts one enter-only Web Animation: opacity 0 to 1 over duration.base on ease.out (src/partials/ClientLayout/routeTransition.ts). There is no travel and no exit animation, so the new page is never delayed and clicks are never blocked. The animation starts in a layout effect, before the first paint of the new route, so there is no flash at full opacity. It does not run on first mount (a hard load), for hash-only moves on the same page, or under reduced motion. shouldFadeRoute is the pure rule and is unit tested.
  2. Each page’s own entrance. Sub-pages (role pages, /about, /resume) rise in once: the rise keyframes (opacity plus distance.rise) over duration.slow on ease.out, the header first and the body one stagger.step later (src/partials/PageShell/riseIn.ts, with the keyframes in PageShell.css.ts). The landing hero does the same family of entrance with CSS keyframes on the same tokens (LandingHero.css.ts), so it plays from the first paint, before any JavaScript. The scroll-driven choreography of the landing sections (framer-motion, LandingAbout, LandingSkills) also reads the tokens. The fade and the rise layer on purpose: the fade is the arrival of the page, the rise is the page’s own moment.
  3. The chrome. The header, floating nav and floating logo sit outside PageWrapper, so they are never faded or remounted by a navigation. Their show and hide rules live in src/containers/Header/chrome.ts (nextChromeHidden, dockProgress, CHROME_EASE, the same curve as ease.out). On the landing page the logo docks into the left gutter as a pure function of scroll. An arrival that lands already scrolled eases from rest into the docked position instead of snapping.

The download dialog

The one overlay, the full-page dialog on /resume (src/partials/ResumeDownload), has its own short moment, in CSS only (ResumeDownload.css.ts), so every way of opening and closing it behaves the same.

  • Enter. The dialog and its ::backdrop fade in over duration.base on ease.out, then the two halves rise distance.rise, the headline first and the form one stagger.step later. Only the dialog fades; the halves move but are never transparent, so the fields and Close are readable and focusable from the first frame.
  • Exit. Opacity only, duration.fast on ease.standard, shorter than the enter. A native <dialog> removes itself and leaves the top layer in a single frame, so the closed style carries transition: opacity, display allow-discrete, overlay allow-discrete: the dialog stays rendered until the fade ends. That covers Close, Escape and close() from Skip and Download with no JavaScript, and the download still starts on the click, not after the fade. The grid template sits on the base style so the layout holds while it fades.
  • Support. Measured in Chromium (enter and exit), WebKit (enter and exit; its ::backdrop does not fade out) and Firefox (enter only: it closes at once, which is the accepted fallback). The enter is a keyframe animation rather than @starting-style, which vanilla-extract cannot emit. A close during the enter fades from the current opacity.
  • Scroll lock. While it is open the page behind does not move. useLenis().stop() runs on open and start() on the native close event (so Escape is covered), html:has(dialog[open]:modal) freezes native wheel, touch and keyboard scrolling (the scrollbar gutter is kept, so nothing reflows), and the dialog has overscroll-behavior: contain plus data-lenis-prevent so it scrolls inside itself on short screens. Focus returns to the trigger with preventScroll, so the position is restored exactly.
  • The custom cursor stays visible. The site hides the system cursor and draws its own dot (src/components/Cursor). A modal dialog is in the browser’s top layer, which paints above any z-index, so the dot would vanish behind it. While a dialog is visible (including its exit fade) the cursor wrapper is shown as a manual popover, which puts it in the top layer after the dialog; it is raised again for each new dialog and hidden once the last one is gone. It is never inside the dialog, so it does not fade with it and keeps tracking the pointer. Without the popover API the system cursor is restored while a dialog is visible, so there is always a pointer. Verified in Chromium; WebKit’s popover state follows the dialog, but headless WebKit and Firefox do not draw the dot at all, so the pixel check is Chromium only.

Rules that keep navigation instant:

  • There is no loading.tsx in src/app. A loading boundary makes React hold the new page back for about 300 ms behind a fallback, even when the data is ready. src/app/no-route-loading.test.ts fails if one is added.
  • Internal links use next/link, so a click is a client navigation and the document is kept. The footer and the floating nav are covered by the probe.
  • The landing page reserves its pinned height in CSS (LandingHeroPinContainer multiplies heroPinVar by 100vh when motion is allowed), so the document is already full height on the first paint and arriving does not shift the layout.
  • Hash moves on the landing page (nav pills, back and forward between anchors) are handled by src/partials/HashScroll/index.tsx: Lenis glides there, or jumps when reduced motion is set.

Reduced motion

prefers-reduced-motion: reduce means content is shown immediately, in its final state:

  • Entrance animations are switched off in CSS (animation: none, also for print), so the base styles are the final state and a page is simply there. Titles are opaque on the first frame and travel 0 px.
  • The route fade does not run.
  • The landing hero does not pin, so there is no pin height to reserve, and hash moves jump instead of gliding.
  • The download dialog has no fade and no rise (animation: none, transition: none): it is opaque on the first frame and gone on the first frame after closing. The scroll lock still applies.
  • Framer-motion pieces use a duration of 0 (useReducedMotion), and MotionConfig reducedMotion="user" is set in ClientLayout. The logo never docks.

The transition probe

scripts/probe-transitions.mjs drives a running production server with headless Chromium (Playwright, 1440 by 900). It samples every animation frame around client navigations and hard loads: URL and scroll, title opacity and position, whether the “Loading…” fallback is on screen, the header logo position, document height and layout shift (CLS). Each scenario runs with motion allowed and with reduced motion.

yarn build && yarn start -p 3000 # one terminal yarn probe:transitions # another; prints a table
VariableMeaning
PROBE_BASEbase URL, default http://localhost:3000
PROBE_BUDGETSJSON of budget overrides, for example '{"clientFullMs":300}', to see a failure on purpose
PROBE_ATTEMPTStries per scenario before it counts as failed, default 2 (a real regression fails every try)
PROBE_ONLYrun only scenarios whose id contains this text, for example dialog (skips the cross-checks)
PROBE_JSON1 prints the raw metrics as JSON instead of the table

The exit code is 0 when every budget holds, 1 on a budget failure (each failure is listed with the measured value, the limit and where to look), and 2 when the probe cannot run (no server, no Chromium). It builds nothing and is not part of yarn test. The unit tests for the evaluation logic (scripts/probe-budgets.test.mjs) do run in yarn test.

Scenarios

Landing to /about, landing to a role page, role to the adjacent role, /about back to the landing page through its back link (/#summary), the footer link from a role page to /resume, the floating-nav pill from /resume to #experience, browser back from /about to / (scroll restoration), and hard loads of /, /about, a role page and /resume. The download dialog has eight more: open, close by the Close button, by Escape, by Skip and download and by Download, scroll-behind on desktop (wheel and keys, 1440 by 900) and on mobile (touch swipe, 390 by 844), a cursor check (a screenshot pixel check that the dot is drawn at the pointer over the left panel, the form and both buttons, mid-enter and mid-exit, and that the popover and body cursor are restored after closing), and one behaviour check (the download starts, focus returns to the trigger, Tab never reaches the page behind). Each samples the dialog’s computed display and opacity, and its ::backdrop opacity, every frame; Escape is a real key press.

Budgets

The numbers live in scripts/probe-budgets.mjs (BUDGETS). Each is the value measured on a production build plus a margin. Measured: nine runs (six alone, three at once) on a healthy build, headless Chromium, localhost.

Budget (key)LimitMeasured (worst of 9 runs)Applies to
loadingFrames00every navigation and hard load
cls0.020every navigation, hard load and browser back
clsLanding0.10arrival on the landing page
clientTitleMs150 ms43 msmotion allowed: title in the DOM after a click
clientHalfMs300 ms148 msmotion allowed: title at least half opaque, client nav
clientFullMs650 ms498 msmotion allowed: title fully opaque, client nav
hardHalfMs300 ms149 msmotion allowed: title at least half opaque, hard load
hardFullMs700 ms532 msmotion allowed: title fully opaque, hard load
clientVsHardMs100 msabout 25 msclient nav no slower than the hard load of the same page
travelPx24 px18 pxmotion allowed: entrance travel (token is 20, sampled late)
logoMovedPx2 px0logo must not move between sub-pages or from the landing top
scrollRestorePx4 px0browser back restores scroll
anchorTopPx2 px0nav pill ends with #experience at the top
reducedFirstOpacity0.991reduced motion: title opaque on its first frame
dialogFirstOpacity0.950 (dialog and backdrop)motion allowed: dialog is not fully opaque on its first frame
dialogEnterMs450 ms250 msmotion allowed: dialog and backdrop opacity at least 0.99 (99% on an ease-out lands before the 400 ms end)
dialogExitMs260 ms229 msmotion allowed: dialog removed after Close, Escape, Skip, Download
dialogExitMinFrames312motion allowed: frames it stays rendered while fading (not a cut)
dialogReducedGoneMs50 ms16 msreduced motion: dialog gone on the first frame after closing
dialogScrollBehindPx0 px0page scrollY while the dialog is open (wheel, keys, touch)
dialogScrollRestorePx0 px0scrollY exactly restored after closing
dialogCursorDiff100213 (weakest spot)the cursor dot differs from its surroundings by this much (0-255) over the dialog
(fixed)0 px0reduced motion: no travel at all
(fixed)monotonicmonotonicdialog opacity only rises on enter and only falls on exit
(fixed)truetruedownload starts, focus returns to the trigger, Tab stays in the dialog
(fixed)no reloaddocument keptfooter link and nav pill are client navigations

Not a budget, by design: the logo legitimately docks and eases in on the way back to the landing page (about 51 px), and that arrival lands at a different scroll position with motion allowed (the pin distance) than with reduced motion. The probe only checks that the hash link scrolled somewhere.

When a budget fails, fix the cause rather than loosening the number. If a change to the motion tokens is intended (for example a longer duration.slow), update the budgets and this page in the same change.

In CI

The Check PR workflow has a step named “Transition probe (non-blocking)” that starts the production build already made for the pipeline and runs yarn probe:transitions. It uses continue-on-error, so a failure shows in the step log without failing the pull request, because shared runners are slower and noisier than the local machine the budgets were measured on. Treat a failure as a prompt to run the probe locally before merging.

Last updated on