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.
| Token | Value | Used for |
|---|---|---|
duration.fast | 200 ms | hover and state changes, small items |
duration.base | 400 ms | panels and the route fade |
duration.slow | 800 ms | page entrances |
ease.out | cubic-bezier(0.16, 1, 0.3, 1) | exponential ease-out, the entrance curve |
ease.standard | cubic-bezier(0, 0, 0.58, 1) | small, quick state changes |
distance.rise | 20 px (1.25rem) | entrance travel |
stagger.step | 120 ms | delay 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.
- The route fade (
PageWrapper).src/partials/ClientLayout/PageWrapper.tsxwraps the routed page. When the pathname changes it starts one enter-only Web Animation: opacity 0 to 1 overduration.baseonease.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.shouldFadeRouteis the pure rule and is unit tested. - Each page’s own entrance. Sub-pages (role pages,
/about,/resume) rise in once: therisekeyframes (opacity plusdistance.rise) overduration.slowonease.out, the header first and the body onestagger.steplater (src/partials/PageShell/riseIn.ts, with the keyframes inPageShell.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. - 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 insrc/containers/Header/chrome.ts(nextChromeHidden,dockProgress,CHROME_EASE, the same curve asease.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
::backdropfade in overduration.baseonease.out, then the two halves risedistance.rise, the headline first and the form onestagger.steplater. 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.fastonease.standard, shorter than the enter. A native<dialog>removes itself and leaves the top layer in a single frame, so the closed style carriestransition: opacity, display allow-discrete, overlay allow-discrete: the dialog stays rendered until the fade ends. That covers Close, Escape andclose()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
::backdropdoes 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 andstart()on the nativecloseevent (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 hasoverscroll-behavior: containplusdata-lenis-preventso it scrolls inside itself on short screens. Focus returns to the trigger withpreventScroll, 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 anyz-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.tsxinsrc/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.tsfails 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 (
LandingHeroPinContainermultipliesheroPinVarby100vhwhen 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), andMotionConfig reducedMotion="user"is set inClientLayout. 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| Variable | Meaning |
|---|---|
PROBE_BASE | base URL, default http://localhost:3000 |
PROBE_BUDGETS | JSON of budget overrides, for example '{"clientFullMs":300}', to see a failure on purpose |
PROBE_ATTEMPTS | tries per scenario before it counts as failed, default 2 (a real regression fails every try) |
PROBE_ONLY | run only scenarios whose id contains this text, for example dialog (skips the cross-checks) |
PROBE_JSON | 1 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) | Limit | Measured (worst of 9 runs) | Applies to |
|---|---|---|---|
loadingFrames | 0 | 0 | every navigation and hard load |
cls | 0.02 | 0 | every navigation, hard load and browser back |
clsLanding | 0.1 | 0 | arrival on the landing page |
clientTitleMs | 150 ms | 43 ms | motion allowed: title in the DOM after a click |
clientHalfMs | 300 ms | 148 ms | motion allowed: title at least half opaque, client nav |
clientFullMs | 650 ms | 498 ms | motion allowed: title fully opaque, client nav |
hardHalfMs | 300 ms | 149 ms | motion allowed: title at least half opaque, hard load |
hardFullMs | 700 ms | 532 ms | motion allowed: title fully opaque, hard load |
clientVsHardMs | 100 ms | about 25 ms | client nav no slower than the hard load of the same page |
travelPx | 24 px | 18 px | motion allowed: entrance travel (token is 20, sampled late) |
logoMovedPx | 2 px | 0 | logo must not move between sub-pages or from the landing top |
scrollRestorePx | 4 px | 0 | browser back restores scroll |
anchorTopPx | 2 px | 0 | nav pill ends with #experience at the top |
reducedFirstOpacity | 0.99 | 1 | reduced motion: title opaque on its first frame |
dialogFirstOpacity | 0.95 | 0 (dialog and backdrop) | motion allowed: dialog is not fully opaque on its first frame |
dialogEnterMs | 450 ms | 250 ms | motion allowed: dialog and backdrop opacity at least 0.99 (99% on an ease-out lands before the 400 ms end) |
dialogExitMs | 260 ms | 229 ms | motion allowed: dialog removed after Close, Escape, Skip, Download |
dialogExitMinFrames | 3 | 12 | motion allowed: frames it stays rendered while fading (not a cut) |
dialogReducedGoneMs | 50 ms | 16 ms | reduced motion: dialog gone on the first frame after closing |
dialogScrollBehindPx | 0 px | 0 | page scrollY while the dialog is open (wheel, keys, touch) |
dialogScrollRestorePx | 0 px | 0 | scrollY exactly restored after closing |
dialogCursorDiff | 100 | 213 (weakest spot) | the cursor dot differs from its surroundings by this much (0-255) over the dialog |
| (fixed) | 0 px | 0 | reduced motion: no travel at all |
| (fixed) | monotonic | monotonic | dialog opacity only rises on enter and only falls on exit |
| (fixed) | true | true | download starts, focus returns to the trigger, Tab stays in the dialog |
| (fixed) | no reload | document kept | footer 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.