diff --git a/app/globals.css b/app/globals.css
index 40baff4e..69341b60 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -289,6 +289,96 @@ input,
.brutalist-root[data-contrast="high"] :focus-visible { outline: 3px solid #fff; outline-offset: 3px; box-shadow: 0 0 0 6px #000; }
.field input:focus-visible, .field textarea:focus-visible { outline-offset: 6px; border-bottom-width: 2px; }
+/* ---- Scroll motion ---------------------------------------- *
+ * Two techniques. Scrubbed (CSS scroll timelines) follows scroll position and
+ * is progressive enhancement: without support the element just shows its final
+ * state. One-shot (`.is-in`, added by useInView) plays once on entry; the hidden
+ * start state applies only when scripting is on, so no-JS never sees blank
+ * content. Both are fenced to no-preference — scroll timelines ignore the
+ * duration-shrinking reduced-motion rule below, so they must be opted out. */
+@media (prefers-reduced-motion: no-preference) {
+ @supports (animation-timeline: scroll()) {
+ .scroll-progress {
+ position: fixed;
+ inset: 0 0 auto 0;
+ height: 3px;
+ background: var(--accent);
+ transform: scaleX(0);
+ transform-origin: left;
+ z-index: 55; /* above .site-nav (50) */
+ pointer-events: none;
+ animation: scroll-progress linear both;
+ animation-timeline: scroll(root);
+ }
+ }
+ @supports (animation-timeline: view()) {
+ .binary-rule {
+ animation: rule-wipe steps(14, end) both;
+ animation-timeline: view();
+ animation-range: entry 10% entry 100%;
+ }
+ .timeline::before {
+ content: "";
+ position: absolute;
+ left: 4px;
+ top: 0;
+ bottom: 0;
+ width: 2px;
+ background: var(--accent);
+ transform-origin: top;
+ animation: timeline-spine linear both;
+ animation-timeline: view();
+ animation-range: entry 30% cover 55%;
+ }
+ }
+ @media (scripting: enabled) {
+ .projects-grid:not(.is-in) .work-card { clip-path: inset(0 0 100% 0); }
+ .continent-list:not(.is-in) .continent-fill { transform: scaleX(0); }
+ .continent-list:not(.is-in) .continent-count { opacity: 0; }
+ }
+ .projects-grid.is-in .work-card {
+ /* `backwards` only: a forwards fill would pin transform and kill the hover lift. */
+ animation: card-stamp var(--dur-slow) var(--ease-out) backwards;
+ animation-delay: calc(var(--i, 0) * 70ms);
+ }
+ .continent-list.is-in .continent-fill {
+ animation: bar-fill var(--dur-slow) var(--ease-out) backwards;
+ animation-delay: calc(var(--i, 0) * 60ms);
+ }
+
+ /* Flight globe intro. Timings mirror useGlobeIntro.ts (DRAW_MS = 900). Routes
+ carry pathLength=1, so dash units are fractions of each route's length.
+ The classes drop away after the intro, so routes that rotate into view
+ later render solid instead of redrawing. */
+ .flight-line.is-pending { stroke-dasharray: 1; stroke-dashoffset: 1; }
+ .flight-line.is-drawing {
+ animation: route-draw 900ms var(--ease-out) backwards;
+ animation-delay: var(--delay, 0ms);
+ }
+ .airport-dot { transform-box: fill-box; transform-origin: center; }
+ .airport-dot.is-pending { transform: scale(0); }
+ .airport-dot.is-drawing {
+ animation: airport-pop var(--dur-base) var(--ease-out) backwards;
+ animation-delay: 900ms;
+ }
+}
+@keyframes route-draw {
+ from { stroke-dasharray: 1; stroke-dashoffset: 1; }
+ to { stroke-dasharray: 1; stroke-dashoffset: 0; }
+}
+@keyframes airport-pop { from { transform: scale(0); } to { transform: scale(1); } }
+@keyframes scroll-progress { to { transform: scaleX(1); } }
+@keyframes rule-wipe {
+ from { clip-path: inset(0 100% 0 0); color: var(--accent-text); }
+ to { clip-path: inset(0 0 0 0); }
+}
+@keyframes timeline-spine { from { transform: scaleY(0); } to { transform: scaleY(1); } }
+@keyframes card-stamp {
+ from { clip-path: inset(0 0 100% 0); transform: translateY(14px); }
+ to { clip-path: inset(0 0 0 0); transform: none; }
+}
+@keyframes bar-fill { from { transform: scaleX(0); } }
+
/* ---- Reduced motion --------------------------------------- */
@media (prefers-reduced-motion: reduce) {
.brutalist-root *, .brutalist-root *::before, .brutalist-root *::after {
@@ -603,7 +693,14 @@ input,
position: relative;
overflow: hidden;
}
-.continent-fill { display: block; height: 100%; background: var(--accent); }
+.continent-fill {
+ display: block;
+ height: 100%;
+ background: var(--accent);
+ transform: scaleX(var(--p));
+ transform-origin: left;
+ transition: transform var(--dur-slow) var(--ease-out);
+}
.continent-count {
font-family: var(--font-mono);
font-size: 13px;
@@ -996,7 +1093,7 @@ input,
gap: var(--s-8);
align-items: start;
}
-.timeline { list-style: none; padding: 0; margin: 0; }
+.timeline { list-style: none; padding: 0 0 0 var(--s-5); margin: 0; position: relative; }
.timeline-row {
display: grid;
grid-template-columns: 140px 1fr 1.4fr;
diff --git a/components/landing/BrutalistLanding.tsx b/components/landing/BrutalistLanding.tsx
index 4f0b4b8c..e0f76ed3 100644
--- a/components/landing/BrutalistLanding.tsx
+++ b/components/landing/BrutalistLanding.tsx
@@ -68,6 +68,7 @@ export function BrutalistLanding({ travel }: { travel: LandingTravelData }) {
return (
+
diff --git a/components/landing/useInView.ts b/components/landing/useInView.ts
new file mode 100644
index 00000000..cdb5edba
--- /dev/null
+++ b/components/landing/useInView.ts
@@ -0,0 +1,28 @@
+'use client'
+
+// useInView — flips `inView` to true once, the first time the element nears the
+// viewport. One-shot by design: scroll-back never replays the entrance.
+import { useEffect, useRef, useState } from 'react'
+
+export function useInView() {
+ const ref = useRef(null)
+ const [inView, setInView] = useState(false)
+
+ useEffect(() => {
+ const el = ref.current
+ if (!el) return
+ const io = new IntersectionObserver(
+ ([entry]) => {
+ if (!entry.isIntersecting) return
+ setInView(true)
+ io.disconnect()
+ },
+ // Inset the bottom edge so tall elements trigger before they're half read.
+ { rootMargin: '0px 0px -10% 0px' }
+ )
+ io.observe(el)
+ return () => io.disconnect()
+ }, [])
+
+ return { ref, inView }
+}
diff --git a/components/travel/TravelMap.tsx b/components/travel/TravelMap.tsx
index 9780b307..261f312f 100644
--- a/components/travel/TravelMap.tsx
+++ b/components/travel/TravelMap.tsx
@@ -11,6 +11,13 @@ import {
} from "react-simple-maps";
import { geoOrthographic } from "d3-geo";
import { Country, FlightRoute } from "@/lib/content/travel";
+import {
+ INTRO_START_LON,
+ MAX_STAGGER_MS,
+ prefersReducedMotion,
+ useGlobeSpin,
+ useIntroDone,
+} from "./useGlobeIntro";
// Served locally so a CDN hiccup can't blank the globe — the site otherwise
// has no runtime third-party dependency.
@@ -81,6 +88,8 @@ interface TravelMapProps {
flightRoutes?: FlightRoute[];
selectedYear?: number;
showFlights?: boolean;
+ /** Flips true once the globe scrolls into view; starts the route draw-in. */
+ drawIn?: boolean;
}
interface TooltipData {
@@ -104,12 +113,30 @@ export function TravelMap({
flightRoutes = [],
selectedYear,
showFlights = false,
+ drawIn = false,
}: TravelMapProps) {
const [tooltip, setTooltip] = useState(null);
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
+ // Starts off-axis so the intro spin has somewhere to settle from; the map is
+ // client-only (dynamic, ssr:false), so reading matchMedia here is safe.
const [rotation, setRotation] = useState<[number, number, number]>([
- 0, -20, 0,
+ prefersReducedMotion() ? 0 : INTRO_START_LON,
+ -20,
+ 0,
]);
+ const userRotated = useRef(false);
+ const introDone = useIntroDone(drawIn);
+ useGlobeSpin(drawIn, setRotation, userRotated);
+ const routeClass = !drawIn
+ ? "flight-line is-pending"
+ : introDone
+ ? "flight-line"
+ : "flight-line is-drawing";
+ const airportClass = !drawIn
+ ? "airport-dot is-pending"
+ : introDone
+ ? "airport-dot"
+ : "airport-dot is-drawing";
const [isDragging, setIsDragging] = useState(false);
const dragStart = useRef<{
x: number;
@@ -183,6 +210,7 @@ export function TravelMap({
const handleMouseDown = useCallback(
(e: React.PointerEvent) => {
+ userRotated.current = true;
setIsDragging(true);
dragStart.current = {
x: e.clientX,
@@ -259,6 +287,7 @@ export function TravelMap({
aria-label={`Globe showing ${displayedCountries.length} visited countries. Drag or use the arrow keys to rotate.`}
onKeyDown={(e) => {
const step = 10;
+ userRotated.current = true;
const [lon, lat] = rotation;
// Match drag: dragging up increases rotation[1] (dy < 0, so
// rotation[1] - dy*sensitivity grows), so ArrowUp must also increase it.
@@ -354,7 +383,15 @@ export function TravelMap({
strokeOpacity={0.6}
strokeWidth={Math.min(0.8 + route.count * 0.15, 3)}
strokeLinecap="round"
- style={{ cursor: "pointer" }}
+ // Normalises dash lengths so the draw-in works for any route length.
+ pathLength={1}
+ className={routeClass}
+ style={
+ {
+ cursor: "pointer",
+ "--delay": `${Math.min(idx * 25, MAX_STAGGER_MS)}ms`,
+ } as React.CSSProperties
+ }
onMouseEnter={() =>
showFlightTooltip(route.from, route.to, route.count)
}
@@ -382,6 +419,7 @@ export function TravelMap({
r={Math.min(2.5 + airport.count * 0.05, 5)}
stroke="var(--map-stroke)"
strokeWidth={0.5}
+ className={airportClass}
style={{ fill: "var(--accent)", cursor: "pointer" }}
/>
diff --git a/components/travel/useGlobeIntro.ts b/components/travel/useGlobeIntro.ts
new file mode 100644
index 00000000..824b59fd
--- /dev/null
+++ b/components/travel/useGlobeIntro.ts
@@ -0,0 +1,50 @@
+'use client'
+
+// Intro choreography for the flight globe: the globe drifts into place while the
+// routes draw in (CSS, see "Flight globe intro" in globals.css). Timings here
+// must stay in step with the --draw-ms keyframe duration there.
+import { useEffect, useState, type Dispatch, type RefObject, type SetStateAction } from 'react'
+
+type Rotation = [number, number, number]
+
+export const INTRO_START_LON = -50
+export const DRAW_MS = 900
+export const MAX_STAGGER_MS = 1200
+const SPIN_MS = 1400
+const INTRO_TOTAL_MS = MAX_STAGGER_MS + DRAW_MS + 300
+
+export const prefersReducedMotion = () =>
+ typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches
+
+/** True once the intro window after `active` has elapsed. Stays true afterwards. */
+export function useIntroDone(active: boolean) {
+ const [done, setDone] = useState(false)
+ useEffect(() => {
+ if (!active) return
+ const id = setTimeout(() => setDone(true), INTRO_TOTAL_MS)
+ return () => clearTimeout(id)
+ }, [active])
+ return done
+}
+
+/** Eases longitude from INTRO_START_LON to 0 once `active`; yields to any user input. */
+export function useGlobeSpin(
+ active: boolean,
+ setRotation: Dispatch>,
+ interrupted: RefObject
+) {
+ useEffect(() => {
+ if (!active || prefersReducedMotion()) return
+ const start = performance.now()
+ let raf = 0
+ const step = (now: number) => {
+ if (interrupted.current) return
+ const t = Math.min(1, (now - start) / SPIN_MS)
+ const eased = 1 - Math.pow(1 - t, 3)
+ setRotation(([, lat]) => [INTRO_START_LON * (1 - eased), lat, 0])
+ if (t < 1) raf = requestAnimationFrame(step)
+ }
+ raf = requestAnimationFrame(step)
+ return () => cancelAnimationFrame(raf)
+ }, [active, setRotation, interrupted])
+}