类型:Site模板 · 分类:未分类 · 收录:2026-09-15 23:37:24 · 源码:会员可见
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Undr. Drift — an underground drift night</title>
<meta name="description" content="One closed lot, a few hours, no list, no posts. Undr. is a local underground drift night.">
<meta name="theme-color" content="#0d0e10">
<!-- Set before first paint: reveal styles key off .js, and a deferred module
would run too late, flashing the rested state first. Keep this inline. -->
<script>document.documentElement.classList.add('js')</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500&display=swap">
<!-- engine: GSAP 3.13 + Locomotive Scroll 5 (Lenis inside) — the reference's stack.
Plain deferred scripts execute in order, before the module below. -->
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/ScrollTrigger.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/SplitText.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/Flip.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/CustomEase.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/locomotive-scroll@5.0.0/bundled/locomotive-scroll.min.js"></script>
<style>
/* ─────────────────────────────────────────────────────────────
new-12 · style.css
Design system snapped from https://www.eladiodieste.com/ (2026-09-12).
Layer order, declared once. Later layers win without specificity fights;
anything unlayered wins over everything (keep that rare: @font-face and
the reduced-motion block only).
───────────────────────────────────────────────────────────── */
@layer reset, tokens, base, layout, components, motion, utilities;
/* Inter Tight 400/500 — the web font is linked above. This is the
metric-matched fallback so the page does not reflow when the font lands.
Inter Tight vertical metrics: ascender 2728 / descender −680 / UPM 2816. */
@font-face {
font-family: 'Inter Tight Fallback';
src: local('Arial');
ascent-override: 96.88%;
descent-override: 24.15%;
line-gap-override: 0%;
size-adjust: 100%;
}
@layer reset {
*, ::before, ::after { margin: 0; padding: 0; box-sizing: border-box; border: 0 solid; }
html, body {
width: 100%;
/* house rule: no rubber-band bounce, ever */
overscroll-behavior: none;
}
img, svg, video, canvas, picture { display: block; max-width: 100%; }
img, video { height: auto; }
button, input, select, textarea { font: inherit; letter-spacing: inherit; color: inherit; background: none; border-radius: 0; }
button { cursor: pointer; }
ol, ul, menu { list-style: none; }
a { color: inherit; text-decoration: none; }
h1, h2, h3, h4, h5, h6 { font-size: inherit; font-weight: inherit; }
:focus:not(:focus-visible) { outline: none; }
}
@layer tokens {
:root {
/* ── palette — three colours, from the SEMA-poster reference:
near-black plate, red-orange for the giant headlines and small accent
elements, white for everything you read. ── */
--night: #0d0e10; /* default background */
--red: #e8472a; /* accent: display type + detached details */
--white: #f4f4f2; /* type */
--black: #000; /* pure black: scrims, menu panel */
--bg: var(--night);
--ink: var(--white);
--accent: var(--red);
--display-ink: var(--accent); /* .t-hero / .t-display default colour */
--bg-footer: var(--accent); /* body.is-footer-inview field */
/* tints — derived, never hand-picked */
--tint-image: color-mix(in oklab, var(--ink) 10%, transparent); /* reveal-image placeholder */
--line-faint: color-mix(in oklab, var(--ink) 20%, transparent); /* timeline rail */
--scrim: color-mix(in oklab, var(--black) 50%, transparent); /* video cover */
--caption-ink: color-mix(in oklab, var(--ink) 80%, transparent);
--ink-40: color-mix(in oklab, var(--ink) 40%, transparent); /* dimmed timeline years */
/* ── type scale · rem on a fluid root (15px → 18px → 22px, see @layer base)
resolved px: @390 @768 @1440 @1920 */
--fs-hero: 36vw; /* 140 — — — (md: 20vw → 154 / 288 / 384) */
--fs-display: 3rem; /* 45 54 — — (md: 7rem → 122.9 / 154) */
--fs-statement: 1.875rem; /* 28 34 — — (md: 3rem → 52.7 / 66) */
--fs-sublead: 1.5rem; /* 22.5 27 — — (md: 1.875rem → 32.9 / 41) */
--fs-h3: 1.25rem; /* 19 22.5 — — (md: 1.875rem → 32.9 / 41) */
--fs-year: 1.125rem; /* 17 20 — — (md: 1.875rem → 32.9 / 41) */
--fs-menu: 3.75rem; /* 56 — — — (md: 7rem) */
--fs-body-lg: 1.125rem; /* 17 20 19.7 24.75 */
--fs-body: 1.0625rem; /* 16 19 18.65 23.4 */
--fs-ui: 1rem; /* 15 18 17.55 22 */
--fs-caption: .875rem; /* 13 16 15.4 19.25 */
--fs-meta: .75rem; /* 11 13.5 13.2 16.5 */
/* line-heights */
--lh-hero: 1; /* + text-box trim */
--lh-display: 1;
--lh-display-tight: .85;
--lh-statement: 1;
--lh-sublead: 1;
--lh-h3: 1.05;
--lh-year: 1.2;
--lh-menu: 1;
--lh-body-lg: 1.1;
--lh-body: 1.05;
--lh-body-loose: 1.15;
--lh-ui: 1.5;
--lh-caption: 1.43;
--lh-meta: 1.33;
/* tracking — never positive, tighter as size grows */
--ls-hero: -.075em;
--ls-display: -.05em;
--ls-display-tight: -.035em;
--ls-statement: -.04em;
--ls-quote: -.025em;
--ls-sublead: -.01em;
--ls-h3: -.04em;
--ls-year: -.025em;
--ls-body-lg: -.01em;
--ls-body: -.02em;
--ls-credit: -.025em;
--ls-ui: -.001em;
--ls-note: -.08em;
/* weights — two, and 500 only where it is clickable or a micro-label */
--w-text: 400;
--w-ui: 500;
/* ── easing (all four are the reference's own names) ── */
--ease-magic: cubic-bezier(.33, 0, 0, 1); /* default for reveals, hovers, snap */
--ease-out-expo: cubic-bezier(.19, 1, .22, 1); /* word/char/divider reveals */
--ease-quart: cubic-bezier(.41, .35, .2, 1); /* image clip reveal, fade-in, h3 hover */
--ease-base: cubic-bezier(.46, .03, .12, 1);/* pill nav show/hide */
--ease-out: cubic-bezier(0, 0, .2, 1); /* Tailwind ease-out, misc */
/* durations */
--dur-tap: .3s;
--dur-mid: .6s;
--dur-body: .7s; /* body color swap, fade-in */
--dur-hover: .75s; /* timeline sibling dim */
--dur-image: 1.2s; /* reveal-clip */
--dur-line: 1.4s; /* reveal-lines, divider */
--dur-word: 1.6s; /* reveal-words / chars */
/* reveal stagger steps (multiplied by --line / --word / --char from SplitText propIndex) */
--step-line: 50ms;
--step-word: 75ms;
--step-char: 75ms;
--step-image: .2s; /* × --i */
/* split masks: clip with a bleed so ascenders/descenders survive at rest */
--split-mask-pad: .08em;
--split-mask-bleed-y: .3em;
/* ── spacing (Tailwind base .25rem) ── */
--u: .25rem;
--pad-x: 1rem; /* px-global */
--gap-grid: 1.25rem; /* grid-default gap-5 */
--radius-md: .375rem;
--rule-heavy: clamp(3px, .4vw, 8px); /* the poster's thick underline */
--hero-runway: 55lvh; /* scroll distance the hero stage stays pinned after its first screen */
--hero-overlap: 35lvh; /* the next section rides up over the last part of the runway */
--hero-scrim-top: .3; /* darkening over the media so the type reads: top / middle / bottom alpha */
--hero-scrim-mid: .1;
--hero-scrim-bottom: .4;
--hero-video-sat: 0; /* the clip under the diamond raster is B&W; 1 = colour */
--plate-scrim: .5; /* darkening over the Rules plates so the rule text reads */
--header-py: 1rem; /* md: .75rem */
--section-pt: calc(var(--u) * 6);
--section-pb: calc(var(--u) * 40);/* md: 72 */
--display-mb: calc(var(--u) * 32);/* md: 56 */
/* z-stack */
--z-burger: 60;
--z-pill: 40;
--z-menu: 10;
--z-transition-scrim: 40;
--z-transition-panel: 50;
}
@media (min-width: 48rem) {
:root {
--fs-hero: 20vw;
--fs-display: 7rem;
--fs-statement: 3rem;
--fs-sublead: 1.875rem;
--fs-h3: 1.875rem;
--fs-year: 1.875rem;
--fs-menu: 7rem;
--header-py: .75rem;
--section-pb: calc(var(--u) * 72);
--display-mb: calc(var(--u) * 56);
}
}
}
@layer base {
/* fluid root — three segments, exactly the reference's clamp() chain */
html { font-size: 15px; }
@media (min-width: 640px) { html { font-size: clamp(15px, 12.9574px + .3191vw, 18px); } }
@media (min-width: 1580px) { html { font-size: clamp(18px, 1.1765vw - .5882px, 22px); } }
html {
-webkit-text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent;
tab-size: 4;
}
body {
font-family: 'Inter Tight', 'Inter Tight Fallback', -apple-system, BlinkMacSystemFont, sans-serif;
font-weight: var(--w-text);
line-height: 1.5;
background-color: var(--bg);
color: var(--ink);
transition: background-color var(--dur-body), color var(--dur-body);
}
/* mode: footer in view — the whole page turns accent (one token: --bg-footer) */
body.is-footer-inview { background-color: var(--bg-footer) !important; color: var(--white) !important; }
::selection { background-color: var(--accent); color: var(--white); text-shadow: none; }
html:where(html.is-changing *) { cursor: wait; }
/* Lenis (lenis.css 1.3.17, inlined) */
html.lenis, html.lenis body { height: auto; }
.lenis:not(.lenis-autoToggle).lenis-stopped { overflow: clip; }
.lenis [data-lenis-prevent], .lenis [data-lenis-prevent-wheel], .lenis [data-lenis-prevent-touch] { overscroll-behavior: contain; }
.lenis.lenis-smooth iframe { pointer-events: none; }
.lenis.lenis-autoToggle { transition-property: overflow; transition-duration: 1ms; transition-behavior: allow-discrete; }
}
@layer layout {
.container, .px-global { width: 100%; padding-inline: var(--pad-x); margin-inline: auto; }
.grid-12 { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: var(--gap-grid); }
.grid-16 { display: grid; grid-template-columns: repeat(16, minmax(0, 1fr)); gap: var(--pad-x); }
@media (min-width: 48rem) { .grid-16 { gap: 0; } }
.section { padding-top: var(--section-pt); }
.section--deep { padding-bottom: var(--section-pb); }
.h-lvh { height: 100lvh; }
.h-svh { height: 100svh; }
.h-dvh { height: 100dvh; }
.h-screen { height: 100vh; height: 100svh; }
}
@layer components {
/* ── type roles — one class per row of the type scale ── */
.t-hero { font-size: var(--fs-hero); line-height: var(--lh-hero); letter-spacing: var(--ls-hero); font-weight: var(--w-text); color: var(--display-ink); }
.t-display { font-size: var(--fs-display); line-height: var(--lh-display); letter-spacing: var(--ls-display); font-weight: var(--w-text); color: var(--display-ink); }
.t-display-tight { font-size: var(--fs-display); line-height: var(--lh-display); letter-spacing: var(--ls-display-tight); font-weight: var(--w-text); color: var(--display-ink); }
.is-ink { color: var(--ink); } /* opt a display role back to white */
.is-accent { color: var(--accent); }/* opt any small element into red */
@media (min-width: 48rem) { .t-display-tight { line-height: var(--lh-display-tight); } }
.t-statement { font-size: var(--fs-statement); line-height: var(--lh-statement); letter-spacing: var(--ls-statement); font-weight: var(--w-text); }
.t-quote { font-size: var(--fs-statement); line-height: var(--lh-statement); letter-spacing: var(--ls-quote); font-weight: var(--w-text); }
.t-sublead { font-size: var(--fs-sublead); line-height: var(--lh-sublead); letter-spacing: var(--ls-sublead); font-weight: var(--w-text); }
.t-h3 { font-size: var(--fs-h3); line-height: var(--lh-h3); letter-spacing: var(--ls-h3); font-weight: var(--w-ui); }
.t-year { font-size: var(--fs-year); line-height: var(--lh-year); letter-spacing: var(--ls-year); font-weight: var(--w-ui); }
.t-menu { font-size: var(--fs-menu); line-height: var(--lh-menu); letter-spacing: 0; font-weight: var(--w-text); }
.t-body-lg { font-size: var(--fs-body-lg); line-height: var(--lh-body-lg); letter-spacing: var(--ls-body-lg); font-weight: var(--w-text); }
.t-body { font-size: var(--fs-body); line-height: var(--lh-body); letter-spacing: var(--ls-body); font-weight: var(--w-text); }
.t-body-loose{ font-size: var(--fs-body); line-height: var(--lh-body-loose);letter-spacing: 0; font-weight: var(--w-text); }
.t-credit { font-size: var(--fs-body); line-height: var(--lh-body); letter-spacing: var(--ls-credit); font-weight: var(--w-text); }
.t-ui { font-size: var(--fs-ui); line-height: var(--lh-ui); letter-spacing: var(--ls-ui); font-weight: var(--w-ui); }
.t-ui-lg { font-size: var(--fs-body-lg); line-height: 1.556; letter-spacing: 0; font-weight: var(--w-ui); }
.t-caption { font-size: var(--fs-caption); line-height: var(--lh-caption); letter-spacing: 0; font-weight: var(--w-ui); }
.t-meta { font-size: var(--fs-meta); line-height: var(--lh-meta); letter-spacing: 0; font-weight: var(--w-text); }
.t-note { font-size: .5em; line-height: 1; letter-spacing: var(--ls-note); opacity: .75; display: block; margin-top: .5rem; }
/* trims the half-leading above cap height and below the baseline —
nav, hero words and years sit on the grid instead of in a line box */
.text-trim { text-box: trim-both cap alphabetic; }
.balance { text-wrap: balance; }
/* ── hairline + the heavy rule ── */
.divider { background-color: currentColor; width: 100%; height: 1px; }
.rule-heavy { background-color: var(--accent); width: 100%; height: var(--rule-heavy); }
/* ── burger — the only fixed chrome. Two bars = the poster's "=" glyph; accent
by default, white on the footer field; morphs into a cross while open ── */
.burger {
position: fixed; top: 1rem; right: var(--pad-x); z-index: var(--z-burger);
display: flex; flex-direction: column; justify-content: space-between;
width: 1.75rem; height: 1.125rem; padding: 0;
color: var(--accent); transition: opacity var(--dur-tap), color var(--dur-body);
}
@media (min-width: 48rem) { .burger { top: 2rem; } }
.burger span {
display: block; height: 2px; width: 100%; background: currentColor;
transition: translate var(--dur-tap) var(--ease-magic), rotate var(--dur-tap) var(--ease-magic);
}
.burger[aria-expanded="true"] span:first-child { translate: 0 .5rem; rotate: 45deg; }
.burger[aria-expanded="true"] span:last-child { translate: 0 -.5rem; rotate: -45deg; }
.burger[aria-expanded="true"] { color: var(--ink); }
body.is-footer-inview .burger { color: var(--white); }
@media (hover: hover) { .burger:hover { opacity: .6; } }
/* nav row (kept for inner pages / the pill; no longer on the home hero) */
.nav {
display: flex; align-items: center; justify-content: space-between; gap: 2rem;
padding-block: var(--header-py);
font-weight: var(--w-ui);
}
.nav__link { display: inline-flex; }
/* nav link with the dot: hover → dot grows in, label slides right;
aria-current="true" → dot parked, label parked */
.nav__link {
align-items: center; gap: .5em;
text-box: trim-both cap alphabetic;
}
.nav__link::before {
content: ""; display: block; width: .625em; height: .625em; flex-shrink: 0;
border-radius: 50%; background-color: transparent; transform: scale(0);
transition: background-color var(--dur-tap) ease-in-out, transform var(--dur-tap) var(--ease-magic);
}
.nav__link > span {
display: inline-block; translate: -1.125em 0;
transition: translate var(--dur-tap) var(--ease-magic);
}
.nav__link--static > span { translate: 0; }
@media (hover: hover) {
.nav__link:hover::before { transform: scale(1); background-color: var(--accent); }
.nav__link:hover > span { translate: 0; }
}
.nav__link[aria-current="true"]::before { transform: scale(1); background-color: var(--accent); }
.nav__link[aria-current="true"] > span { translate: 0; }
/* ── plain links ── */
.link-dim { transition: opacity var(--dur-tap); }
@media (hover: hover) { .link-dim:hover { opacity: .6; } }
/* ── image credit (bottom-right of a reveal-image) ── */
.credit {
position: absolute; right: .5rem; bottom: .5rem;
color: var(--caption-ink); font-weight: var(--w-ui);
border-radius: var(--radius-md);
filter: drop-shadow(0 25px 25px rgb(0 0 0 / .15));
opacity: 0; transition: opacity var(--dur-tap);
}
.credit.is-inview { opacity: 1; }
@media (min-width: 48rem) { .credit { right: 1rem; bottom: 1rem; } }
/* ── mobile menu panel — GSAP drives clip-path; CSS holds the rest ── */
.menu {
position: fixed; top: 0; bottom: 1.5rem; left: 0; z-index: var(--z-menu);
height: 100dvh; width: 100%; padding: 1rem; text-align: center;
background: var(--black); color: var(--ink);
visibility: hidden; pointer-events: auto;
}
.menu__inner { display: flex; height: 100%; flex-direction: column; justify-content: flex-end; }
.menu__inner > nav { margin-block: auto; } /* list floats to the vertical centre, footer stays down */
.menu__list { display: flex; flex-direction: column; text-align: left; }
.menu__list li { overflow: hidden; }
.menu__list a { display: block; border-top: 1px solid var(--ink); padding: 1rem 0 1.25rem; }
@media (min-width: 48rem) { .menu { padding: 2rem var(--pad-x); } .menu__list a { padding: 1.25rem 0 1.5rem; } }
.menu__footer { display: flex; flex-direction: column; gap: 1rem; text-align: left; }
/* ── project pill nav ── */
.pill-nav {
pointer-events: none; position: fixed; inset-inline: 0; bottom: 1rem; z-index: var(--z-pill);
display: flex; justify-content: center;
transition: translate .5s var(--ease-base);
}
.pill-nav[data-hidden="true"] { translate: 0 calc(100% + 1.5rem); }
.pill-nav__box {
pointer-events: auto; position: relative; height: 2.5rem; width: 11.75rem;
overflow: clip; background: var(--ink); color: var(--night);
}
.pill-nav__cta {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
transition: opacity var(--dur-tap);
}
@media (hover: hover) { .pill-nav__cta:hover { opacity: .6; } }
/* ── footer wordmark: an HTML block cut to the glyphs by an SVG clipPath; inside — the B&W
still + its raster in a frame twice as tall that parallaxes on scroll. currentColor (white on
the accent field) shows until the image arrives ── */
.wordmark { position: relative; width: 100%; aspect-ratio: 1408 / 285; overflow: hidden; clip-path: url(#wordmark-clip); background: currentColor; }
.wordmark__frame { position: absolute; inset-inline: 0; top: 0; height: 200%; }
.wordmark img { display: block; width: 100%; height: 100%; object-fit: cover; filter: saturate(0); }
/* ── timeline → speedometer ──
The reference's centre rail + progress line became an arc: a sticky
full-viewport layer holds one SVG (viewBox = viewport, set by JS) with the
faint track, the accent progress (pathLength 1, dashoffset 1 → 0) and the
needle (rotate −180° → 0° around the bottom centre). It sticks for the
section's whole height and takes no flow space (negative bottom margin). */
.timeline { position: relative; min-height: 100vh; overflow-x: clip; display: grid; }
.timeline__speedo, .timeline__body { grid-area: 1 / 1; } /* one cell: the layer overlays the body, adds no height */
.timeline__body { position: relative; } /* the section's own padding sits OUTSIDE .timeline, so the sticky dial ends with the plates */
.timeline__speedo {
pointer-events: none; position: sticky; top: 0; z-index: 20; align-self: start;
height: 100lvh; margin-inline: calc(-1 * var(--pad-x)); /* edge to edge, past px-global */
}
.timeline__speedo svg { display: block; width: 100%; height: 100%; overflow: visible; }
.timeline__rail, .timeline__progress { fill: none; stroke-width: 1; vector-effect: non-scaling-stroke; }
.timeline__rail { stroke: var(--line-faint); }
.timeline__progress { stroke: var(--accent); stroke-dasharray: 1; stroke-dashoffset: 1; }
.timeline__needle { fill: var(--accent); } /* a tapered block (points set by JS), not a stroke */
.timeline__dial { position: absolute; bottom: 1.25rem; color: var(--ink-40); } /* .t-year — the size the rail's dates had */
.timeline__dial--0 { left: var(--pad-x); }
.timeline__dial--max { right: var(--pad-x); }
.timeline__grid { position: relative; grid-auto-rows: auto; padding-bottom: 1.5rem; }
/* each item declares its row and 16-col span inline (--row / --col);
below md everything spans the full width */
.timeline__cell { position: relative; grid-row-start: var(--row, auto); grid-column: 1 / -1; }
@media (min-width: 48rem) { .timeline__cell { grid-column: var(--col, 1 / -1); } }
.timeline__num { display: block; margin-bottom: .5rem; color: var(--accent); } /* rule number over the text */
.timeline__item { position: relative; display: block; padding-bottom: 1.5rem; transition: opacity var(--dur-hover) var(--ease-magic); }
@media (min-width: 48rem) {
.timeline__item { padding-bottom: 0; }
.timeline:has(.timeline__item:hover) .timeline__item { opacity: .3; }
.timeline:has(.timeline__item:hover) .timeline__item:hover { opacity: 1; }
}
.timeline__media {
margin-inline: auto; width: 50%; overflow: hidden; background: var(--tint-image);
clip-path: inset(0%); transition: clip-path var(--dur-body) var(--ease-magic);
}
@media (min-width: 48rem) {
.timeline__media { margin-inline: 0; width: 100%; }
.timeline__item:hover .timeline__media { clip-path: inset(5%); }
}
.timeline__reveal { position: relative; height: 100%; width: 100%; background-size: cover; background-position: center; }
/* the frame is what the parallax moves; the still is B&W under its raster, like the hero clip */
.timeline__frame { position: absolute; inset: 0; }
.timeline__reveal img { height: 100%; width: 100%; object-fit: cover; opacity: 0; transition: opacity .5s; filter: saturate(0); }
.timeline__reveal img.is-loaded { opacity: 1; }
/* scrim above image + raster: the white rule text sits partly over the plate */
.timeline__reveal::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: rgb(13 14 16 / var(--plate-scrim)); }
.timeline__title { margin-top: 2rem; text-align: center; }
@media (min-width: 48rem) {
.timeline__title { position: absolute; top: 50%; margin-top: 0; translate: 0 -50%; }
.timeline__title--left { left: -20%; text-align: left; max-width: var(--timeline-title-safe-w, 43ch); }
.timeline__title--right { right: -20%; text-align: right; max-width: var(--timeline-title-safe-w, 45ch); }
.timeline__title--right.is-edge { right: -40%; }
}
/* ── stack ── */
.stack { position: relative; height: var(--stack-height); }
@media (min-width: 48rem) { .stack { height: 100vh; } }
.stack__viewport { position: sticky; top: 0; height: 100dvh; }
@media (min-width: 48rem) { .stack__viewport { height: 100%; } }
.stack__slide { position: absolute; inset: 0; display: flex; flex-direction: column; overflow: hidden; }
.stack__media { position: relative; height: 60%; width: 100%; flex-shrink: 0; overflow: hidden; will-change: transform; background: var(--night); } /* opaque under a lazy image */
.stack__media-inner { position: absolute; inset: 0; will-change: transform; }
.stack__media img { height: 100%; width: 100%; object-fit: cover; }
.stack__caption {
display: flex; height: 40%; flex-shrink: 0; flex-direction: column; overflow: auto; padding: 1rem;
background: var(--bg); color: var(--ink); line-height: 1.2em; font-weight: var(--w-ui); letter-spacing: -.025em;
will-change: transform;
}
.stack__caption > div { margin-block: auto; display: flex; flex-direction: column; gap: 1rem; }
.stack__layer { position: absolute; inset: 0; background: transparent; }
@media (min-width: 48rem) {
.stack__slide { flex-direction: row; align-items: center; justify-content: center; }
.stack__media { position: absolute; inset: 0; height: auto; }
.stack__media img { height: 130%; }
.stack__caption { position: absolute; bottom: 0; left: 0; height: auto; max-width: 36rem; background: transparent; color: var(--white); }
.stack__layer { background: var(--black); }
}
/* ── hero stage ──
250lvh section, 100lvh sticky stage: the media layer (scroll-scrubbed
video) sits in .hero__scene. The chrome is the poster composition:
"Undr." + heavy rule + three-word row top-left, date + time across the
vertical centre with a red "+", note bottom-left, "Drift" bottom-right. */
.hero { position: relative; height: calc(100lvh + var(--hero-runway)); visibility: hidden; color: var(--ink); }
.hero__sticky { position: sticky; top: 0; height: 100lvh; overflow: hidden; }
.hero__scene { position: absolute; inset: 0; overflow: hidden; }
.hero__layers { position: absolute; inset: 0; }
.hero__media { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
/* diamond raster: a WebGL canvas over the clip; the clip itself goes B&W
and shows through at 27%. Both hang on .has-fx — FX off = neither */
.hero.has-fx .hero__media { filter: saturate(var(--hero-video-sat)); }
.raster { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; } /* hero + plates */
/* scrim: the type must read over any frame of the clip; two tokens, zero to disable */
.hero__layers::after {
content: ""; position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(180deg,
rgb(13 14 16 / var(--hero-scrim-top)) 0%, rgb(13 14 16 / var(--hero-scrim-mid)) 30%,
rgb(13 14 16 / var(--hero-scrim-mid)) 70%, rgb(13 14 16 / var(--hero-scrim-bottom)) 100%);
}
/* the section after the hero starts before the runway ends, so its statement
lands on the video while the scene is fading to the night plate */
.hero-overlap { position: relative; z-index: 1; margin-top: calc(-1 * var(--hero-overlap)); }
.hero__chrome { pointer-events: none; position: absolute; inset-inline: 0; top: 0; height: 100svh; overflow: hidden; }
.hero__word { display: inline-block; white-space: nowrap; }
.hero__word--right { text-align: right; }
.hero__top { position: absolute; inset-inline: 0; top: 0; padding-top: 2rem; }
.hero__underline { display: inline-block; } /* the rule takes the word's width */
.hero__underline .rule-heavy { margin-top: clamp(4px, .5vw, 10px); }
.hero__row { display: flex; flex-direction: column; gap: .25rem; margin-top: 1.25rem; }
.hero__row > * { display: inline-block; }
@media (min-width: 48rem) {
.hero__top { padding-top: 2rem; }
.hero__underline { margin-left: -1.5rem; } /* optical: the "U" hugs the gutter */
.hero__underline .rule-heavy { margin-left: 1.5rem; width: calc(100% - 1.5rem); }
.hero__row { flex-direction: row; justify-content: space-between; gap: 2rem; }
}
.hero__mid {
position: absolute; inset-inline: 0; top: 50%; translate: 0 -50%;
display: flex; align-items: center; justify-content: space-between;
}
.hero__mid > span { display: inline-block; }
.hero__plus { color: var(--accent); font-size: calc(var(--fs-statement) * 1.6); line-height: 1; } /* reads as a glyph, like the poster's "=" */
@media (max-width: 47.99rem) {
.hero__mid .t-statement { font-size: var(--fs-sublead); letter-spacing: var(--ls-sublead); }
.hero__mid .hero__plus { font-size: calc(var(--fs-sublead) * 1.6); }
}
.hero__bottom {
position: absolute; inset-inline: 0; bottom: 0; padding-bottom: 2rem;
display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem;
}
.hero__note { padding-bottom: .1em; }
.hero__coords { white-space: nowrap; }
/* redacted digits: blurred in place, still selectable-looking but unreadable */
.is-redacted { display: inline-block; filter: blur(3px); user-select: none; }
/* placeholder plate for images that do not exist yet — same tint the
reveal-image plate uses, so the composition reads without assets */
.ph { background: color-mix(in oklab, var(--ink) 10%, var(--night)); } /* opaque, so stacked plates do not bleed through */
.reveal-image .ph { background: color-mix(in oklab, var(--ink) 18%, var(--night)); }
.ph--accent { background: color-mix(in oklab, var(--accent) 70%, var(--night)); }
/* ── three columns of images ── */
.three-images { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: var(--gap-grid); }
.three-images > * { grid-column: span 12; }
@media (min-width: 48rem) { .three-images > * { grid-column: span 4; } }
/* ── page transition chrome ── */
.transition-scrim { pointer-events: none; position: fixed; inset: 0; z-index: var(--z-transition-scrim); background: var(--black); opacity: 0; }
.transition-panel { position: fixed; inset-inline: 0; top: 0; z-index: var(--z-transition-panel); height: 100lvh; background: var(--black); clip-path: inset(0 0 100% 0); }
.transition-panel img { position: absolute; inset: 0; height: 100%; width: 100%; object-fit: cover; opacity: 0; }
/* ── video cover ── */
.video-cover { position: absolute; inset: 0; transition: opacity var(--dur-body); }
.video-cover video { position: absolute; inset: 0; height: 100%; width: 100%; object-fit: cover; filter: saturate(80%); transition: filter 1s; }
.video-cover__scrim { position: absolute; inset: 0; background: var(--scrim); transition: opacity 1s; }
.video-cover__play { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; padding: 2.5rem; transition: transform var(--dur-tap) var(--ease-magic); }
.video-cover__play svg { width: 2.5rem; }
@media (min-width: 48rem) { .video-cover__play svg { width: 5.25rem; } }
@media (hover: hover) {
.video-cover:hover video { filter: saturate(100%); }
.video-cover:hover .video-cover__scrim { opacity: 0; }
.video-cover__play:hover { transform: scale(1.4); }
}
}
@layer motion {
/* ═══════════════════════════════════════════════════════════════
The reveal contract.
JS toggles ONE class — `.is-inview` (Locomotive Scroll `data-scroll`) —
and adds `.is-loaded` on images. Everything else is CSS: the index each
SplitText unit carries (`--line`, `--word`, `--char`, set by propIndex)
is the stagger.
`:where(html.js *)` — with JS off nothing is ever hidden.
═══════════════════════════════════════════════════════════════ */
/* lines rise 1em and fade in, 50ms apart */
.reveal-lines:where(html.js *) { opacity: 0; }
.reveal-lines.is-inview { opacity: 1; }
.reveal-lines .line {
translate: 0 1em; opacity: 0;
transition-property: translate, transform, opacity;
transition-duration: var(--dur-line);
transition-timing-function: var(--ease-magic);
}
.reveal-lines.is-inview .line {
translate: 0 0; opacity: 1;
transition-delay: calc(var(--line, 0) * var(--step-line));
}
/* words / chars slide up out of a mask, 75ms apart */
.reveal-words:where(html.js *), .reveal-chars:where(html.js *) { opacity: 0; }
.reveal-words.is-inview, .reveal-chars.is-inview { opacity: 1; }
.reveal-words .word {
transition: var(--dur-word) var(--ease-out-expo) transform;
transition-delay: calc(var(--step-word) * var(--word, 0));
transform: translateY(130%);
}
.reveal-chars .char {
transition: var(--dur-word) var(--ease-out-expo) transform;
transition-delay: calc(var(--step-char) * var(--char, 0));
transform: translateY(130%);
}
.reveal-words.is-inview .word, .reveal-chars.is-inview .char { transform: translateY(0%); }
/* hairline draws from the left */
.reveal-divider { transition: transform var(--dur-line) var(--ease-out-expo); transform-origin: 0; transform: scaleX(0); }
.reveal-divider.is-inview { transform: scaleX(1); }
/* image: tinted plate, then clip-up + settle once in view AND loaded */
.reveal-image { background-color: var(--tint-image); overflow: hidden; }
.reveal-image img, .reveal-image video, .reveal-image .ph { opacity: 0; }
.reveal-image.is-inview.is-loaded img, .reveal-image.is-inview.is-loaded video, .reveal-image.is-inview.is-loaded .ph {
opacity: 1;
animation: reveal-clip var(--dur-image) var(--ease-quart) both;
animation-delay: calc(var(--i, 0) * var(--step-image));
}
@keyframes reveal-clip {
0% { clip-path: inset(0 0 100%); transform: scale(1.1) translateY(-5%); }
to { clip-path: inset(0 0 0%); transform: scale(1); }
}
@keyframes fade-in { 0% { opacity: 0; } to { opacity: 1; } }
.animate-fade-in { animation: fade-in var(--dur-body) var(--ease-quart) forwards; }
/* SplitText masks — GSAP wraps each unit in `.<unit>-mask`; we swap its
overflow:clip for a clip-path with vertical bleed so glyph extenders
are not shaved at rest, and pad the right edge so italics/overhangs fit. */
.line-mask { display: block; }
.char-mask, .word-mask, .line, .word, .char { display: inline-block; }
.word-mask, .char-mask { padding-right: var(--split-mask-pad); margin-right: calc(var(--split-mask-pad) * -1); }
.line-mask, .word-mask, .char-mask {
clip-path: inset(calc(var(--split-mask-bleed-y) * -1) 0 calc(var(--split-mask-bleed-y) * -1) 0);
overflow: visible !important;
}
/* generic inview fade (credits, small labels) */
.inview-fade { opacity: 0; transition: opacity var(--dur-tap); }
.inview-fade.is-inview { opacity: 1; }
}
@layer utilities {
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border-width: 0; }
.invisible { visibility: hidden; }
.hidden { display: none; }
.relative { position: relative; }
.overflow-clip { overflow: clip; }
.pointer-none { pointer-events: none; }
.pointer-auto { pointer-events: auto; }
.md-only { display: none; }
@media (min-width: 48rem) { .md-only { display: block; } .mobile-only { display: none; } }
.w-full { width: 100%; }
.object-cover { object-fit: cover; width: 100%; height: 100%; }
.aspect-3-2 { aspect-ratio: 3 / 2; }
.aspect-4-3 { aspect-ratio: 4 / 3; }
.aspect-16-9 { aspect-ratio: 16 / 9; }
.mt-2 { margin-top: calc(var(--u) * 2); }
.mt-5 { margin-top: calc(var(--u) * 5); }
.mt-6 { margin-top: calc(var(--u) * 6); }
.mt-8 { margin-top: calc(var(--u) * 8); }
.mt-24 { margin-top: calc(var(--u) * 24); }
.mb-5 { margin-bottom: calc(var(--u) * 5); }
.mb-6 { margin-bottom: calc(var(--u) * 6); }
.mb-display { margin-bottom: var(--display-mb); }
.pt-6 { padding-top: calc(var(--u) * 6); }
.pb-6 { padding-bottom: calc(var(--u) * 6); }
.pt-24 { padding-top: calc(var(--u) * 24); }
.pb-43 { padding-bottom: calc(var(--u) * 43); }
.py-8 { padding-block: calc(var(--u) * 8); }
.gap-5 { gap: calc(var(--u) * 5); }
.col-span-4 { grid-column: span 4 / span 4; }
.col-span-7 { grid-column: span 7 / span 7; }
.col-span-12 { grid-column: span 12 / span 12; }
@media (min-width: 48rem) {
.md:col-span-7 { grid-column: span 7 / span 7; }
.md:col-start-6 { grid-column-start: 6; }
.md:col-span-5 { grid-column: span 5 / span 5; }
.md:col-span-6 { grid-column: span 6 / span 6; }
.md:col-start-7 { grid-column-start: 7; }
.md:max-w-66 { max-width: 66%; }
}
.text-right { text-align: right; }
.flex { display: flex; }
.items-end { align-items: flex-end; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.flex-col { flex-direction: column; }
.gap-2 { gap: calc(var(--u) * 2); }
.gap-4 { gap: calc(var(--u) * 4); }
}
/* ── reduced motion: every reveal rests in its final state, scrub-driven
scenes are gated in JS with gsap.matchMedia ── */
@media (prefers-reduced-motion: reduce) {
.reveal-lines .line, .reveal-words .word, .reveal-chars .char { transition: none; translate: 0 0; transform: none; opacity: 1; }
.reveal-lines:where(html.js *), .reveal-words:where(html.js *), .reveal-chars:where(html.js *) { opacity: 1; }
.reveal-divider { transition: none; transform: scaleX(1); }
.reveal-image img, .reveal-image video, .reveal-image .ph { opacity: 1; animation: none; }
.burger, .burger span, .pill-nav, .nav__link::before, .nav__link > span, .timeline__item, .timeline__media { transition: none; }
}
</style>
</head>
<body>
<!-- ═══ burger: the only fixed chrome. Two bars (the poster's "=" motif), accent,
morphs into a cross while the full-screen menu is open ═══ -->
<button class="burger" data-menu-toggle aria-label="Menu" aria-expanded="false" aria-controls="menu">
<span></span><span></span>
</button>
<!-- ═══ mobile menu panel ═══ -->
<div class="menu" id="menu" data-menu>
<div class="menu__inner">
<nav aria-label="Menu">
<ul class="menu__list t-menu">
<li><a class="text-trim" href="#rules">Rules</a></li>
<li><a class="text-trim" href="#nights">Nights</a></li>
<li><a class="text-trim" href="#pull-up">Pull up</a></li>
</ul>
</nav>
<div class="menu__footer t-body-lg">
<p>Undr. Drift © <span data-year-now>2026</span></p>
<div class="flex items-center justify-between gap-2">
<a href="#rules" class="link-dim">Rules</a>
<a href="#" class="link-dim">Drop</a>
</div>
</div>
</div>
</div>
<main id="top">
<!-- ═══ HERO · 250lvh runway, 100lvh sticky stage.
Media (scroll-scrubbed video) mounts into [data-scene]; the hero module
plays the intro over it and feeds 0→1.5 to the media hook.
Composition (poster): "Undr." top-left with a heavy rule under it, a row of
three words, date + time across the vertical centre with a red "+",
entry/location note bottom-left, "Drift" bottom-right. ═══ -->
<h1 class="sr-only">Undr. Drift — underground drift session</h1>
<section class="hero" data-hero>
<div class="hero__sticky">
<div class="hero__scene" data-scene-wrapper>
<div class="hero__layers" data-scene aria-hidden="true">
<!-- all-intra re-encode (ffmpeg -g 1) so every frame seeks instantly, hosted on CloudFront
(CORS + byte ranges); crossorigin so the raster can read its frames -->
<video class="hero__media" data-hero-video muted playsinline preload="auto" crossorigin="anonymous"
poster="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/c34fa15e-fad4-4c37-a7a0-1a325b39e8bf.jpg" src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/3abfc4bb-a49d-4a22-93e6-3ebb0f7fa9ac.mp4"></video>
</div>
</div>
</div>
<div class="hero__chrome" aria-hidden="true">
<div class="px-global hero__top">
<div class="hero__underline">
<div class="t-hero text-trim"><span class="hero__word" data-line="1">Undr.</span></div>
<div class="rule-heavy reveal-divider" data-hero-rule></div>
</div>
<div class="hero__row t-ui" data-hero-row>
<span>Late night</span>
<span>Closed lot</span>
<span>Sideways</span>
</div>
</div>
<div class="px-global hero__mid" data-hero-mid>
<span class="t-statement text-trim" data-hero-date>23.04</span>
<span class="t-statement text-trim hero__plus" data-hero-plus>+</span>
<span class="t-statement text-trim" data-hero-time>00:30</span>
</div>
<div class="px-global hero__bottom">
<p class="t-meta text-trim hero__note" data-hero-note>Secret location <span class="hero__coords">55.7<span class="is-redacted">558</span>, 37.6<span class="is-redacted">173</span></span></p>
<div class="t-hero text-trim hero__word--right"><span class="hero__word" data-line="2">Drift</span></div>
</div>
</div>
</section>
<!-- ═══ STATEMENT · one paragraph at 3rem, lines rising 50ms apart; rides up over the end of the hero runway ═══ -->
<section class="px-global pt-6 hero-overlap">
<h2 class="t-statement reveal-lines md:max-w-66" data-split="lines" data-scroll data-scroll-offset="20%">
One closed lot, no lights but the city’s. Cars that have no business being this
sideways, and a crowd that knows not to post. Undr. is a drift night that exists
for a few hours and then doesn’t.
</h2>
</section>
<!-- ═══ BODY · 12-column grid, one paragraph in the right five columns ═══ -->
<section class="px-global pt-24">
<div class="grid-12 gap-5">
<div class="col-span-12 md:col-span-5 md:col-start-7">
<p class="t-body-lg reveal-lines" data-split="lines" data-scroll data-scroll-offset="20%">
No list, no tickets. You hear about it, you pull up, you keep it off your feed.
The lot drops two hours before.
</p>
</div>
</div>
<!-- the three plates that stood here were cut; the gap they left is kept on purpose —
one empty 3:2 cell of the same grid, so the distance to Rules stays exactly as it was -->
<div class="three-images mt-8" aria-hidden="true"><div class="aspect-3-2"></div></div>
</section>
<!-- ═══ RULES · the reference's timeline (16-column grid) with the year rail
replaced by a speedometer: a sticky full-screen arc from the bottom-left corner over the top
to the bottom-right, an accent needle from the bottom centre that sweeps 0 → 200 as the
section scrolls, the arc filling behind it. Six rules on the plates. ═══ -->
<section id="rules" class="px-global section--deep">
<div class="timeline" data-timeline-root>
<!-- sticky for the section's whole height, takes no flow space;
viewBox / arc / needle geometry set by JS to the viewport -->
<div class="timeline__speedo" aria-hidden="true">
<svg data-speedo preserveAspectRatio="none">
<path class="timeline__rail" data-speedo-rail pathLength="1"/>
<path class="timeline__progress" data-speedo-progress pathLength="1"/>
<polygon class="timeline__needle" data-speedo-needle/>
</svg>
<span class="timeline__dial timeline__dial--0 t-year">0</span>
<span class="timeline__dial timeline__dial--max t-year">200</span>
</div>
<!-- body shares the grid cell with the speedo layer, so the sticky layer adds no height
and stops at the section's end like any sticky child -->
<div class="timeline__body">
<div class="pt-6 pb-43">
<h2 class="t-display reveal-words" data-split="words" data-split-mask="words" data-scroll data-scroll-offset="30%">Rules</h2>
</div>
<div class="timeline__grid grid-16">
<!-- row 1 · left, span 3 from col 2 -->
<div class="timeline__cell" style="--row:1; --col: 2 / span 3">
<div class="timeline__item" data-timeline-item>
<div class="flex w-full items-center justify-between">
<div class="timeline__media" data-timeline-item-child style="aspect-ratio: 4 / 3">
<div class="timeline__reveal" data-timeline-item-reveal><div class="timeline__frame" data-timeline-item-frame><img crossorigin="anonymous" src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/52e76319-4056-49ce-84db-05836bd735b5.jpg" width="1200" height="896" alt="A palm thrust at the lens, blown out by the flash, a crowd and a car behind" loading="lazy" decoding="async" data-plate-fx></div></div>
</div>
</div>
<div class="timeline__title timeline__title--right"><h3 class="t-h3 balance" data-scroll data-scroll-speed="-.2"><div><span class="timeline__num t-ui">01</span>No posting. Not the lot, not the plates, not the people.</div></h3></div>
</div>
</div>
<!-- row 2 · right, span 6 from col 10 -->
<div class="timeline__cell" style="--row:2; --col: 10 / span 6">
<div class="timeline__item" data-timeline-item>
<div class="flex w-full items-center justify-between">
<div class="timeline__media" data-timeline-item-child style="aspect-ratio: 3 / 2">
<div class="timeline__reveal" data-timeline-item-reveal><div class="timeline__frame" data-timeline-item-frame><img crossorigin="anonymous" src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/f157067a-0206-4e81-910f-81fe28677dbf.jpg" width="1200" height="797" alt="An open trunk with a tow strap, a jerry can and a fire extinguisher, hands pulling the strap out" loading="lazy" decoding="async" data-plate-fx></div></div>
</div>
</div>
<div class="timeline__title timeline__title--left"><h3 class="t-h3 balance" data-scroll data-scroll-speed="-.2"><div><span class="timeline__num t-ui">02</span>Your car, your problem. Tow strap, fluids, a fire bottle — bring them.</div></h3></div>
</div>
</div>
<!-- row 3 · left, span 4 from col 4 -->
<div class="timeline__cell" style="--row:3; --col: 4 / span 4">
<div class="timeline__item" data-timeline-item>
<div class="flex w-full items-center justify-between">
<div class="timeline__media" data-timeline-item-child style="aspect-ratio: 1 / 1">
<div class="timeline__reveal" data-timeline-item-reveal><div class="timeline__frame" data-timeline-item-frame><img crossorigin="anonymous" src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/237faead-d5c2-4e2e-af1c-52ee809a44cf.jpg" width="1200" height="1200" alt="A rear wheel spinning in tyre smoke lit by a street lamp" loading="lazy" decoding="async" data-plate-fx></div></div>
</div>
</div>
<div class="timeline__title timeline__title--right"><h3 class="t-h3 balance" data-scroll data-scroll-speed="-.2"><div><span class="timeline__num t-ui">03</span>One warm-up lap, then you’re live. Nobody waits.</div></h3></div>
</div>
</div>
<!-- row 4 · right edge, span 4 from col 12 (edge → title pushed −40%) -->
<div class="timeline__cell" style="--row:4; --col: 12 / span 4">
<div class="timeline__item" data-timeline-item>
<div class="flex w-full items-center justify-between">
<div class="timeline__media" data-timeline-item-child style="aspect-ratio: 4 / 5">
<div class="timeline__reveal" data-timeline-item-reveal><div class="timeline__frame" data-timeline-item-frame><img crossorigin="anonymous" src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/c78d5b93-1f6e-4663-bb0d-f348012ea11a.jpg" width="960" height="1200" alt="An empty lot with tyre circles and a knocked-over cone, tail lights leaving through the gate" loading="lazy" decoding="async" data-plate-fx></div></div>
</div>
</div>
<div class="timeline__title timeline__title--left"><h3 class="t-h3 balance" data-scroll data-scroll-speed="-.2"><div><span class="timeline__num t-ui">04</span>The lot is ours until it isn’t. When the word comes, everyone is gone in five.</div></h3></div>
</div>
</div>
<!-- row 5 · left, span 5 from col 1 -->
<div class="timeline__cell" style="--row:5; --col: 1 / span 5">
<div class="timeline__item" data-timeline-item>
<div class="flex w-full items-center justify-between">
<div class="timeline__media" data-timeline-item-child style="aspect-ratio: 16 / 9">
<div class="timeline__reveal" data-timeline-item-reveal><div class="timeline__frame" data-timeline-item-frame><img crossorigin="anonymous" src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/8e275529-5405-4f1e-abf3-fd3cbcd17bf7.jpg" width="1200" height="675" alt="A row of traffic cones in the foreground, a car sliding past behind them, a crowd beyond" loading="lazy" decoding="async" data-plate-fx></div></div>
</div>
</div>
<div class="timeline__title timeline__title--right"><h3 class="t-h3 balance" data-scroll data-scroll-speed="-.2"><div><span class="timeline__num t-ui">05</span>Nobody inside the line. Behind the cones or in a car.</div></h3></div>
</div>
</div>
<!-- row 6 · right, span 3 from col 10 -->
<div class="timeline__cell" style="--row:6; --col: 10 / span 3">
<div class="timeline__item" data-timeline-item>
<div class="flex w-full items-center justify-between">
<div class="timeline__media" data-timeline-item-child style="aspect-ratio: 3 / 4">
<div class="timeline__reveal" data-timeline-item-reveal><div class="timeline__frame" data-timeline-item-frame><img crossorigin="anonymous" src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/68cb177d-6ffc-4e19-a5d6-e54138188c86.jpg" width="900" height="1200" alt="Five guys in hoodies at a fence with their backs to the camera, one looking back" loading="lazy" decoding="async" data-plate-fx></div></div>
</div>
</div>
<div class="timeline__title timeline__title--left"><h3 class="t-h3 balance" data-scroll data-scroll-speed="-.2"><div><span class="timeline__num t-ui">06</span>Show up once, you’re a face. Show up twice, you’re on the list. There is no list.</div></h3></div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ═══ NIGHTS · a two-line display statement, then the stacked
slides, pinned + snapped on desktop; one photo per past night ═══ -->
<section id="nights">
<div class="px-global pt-6">
<h2 class="t-display-tight reveal-words mb-display" data-split="words" data-split-mask="words" data-scroll data-scroll-offset="30%">
Fourteen nights. What stayed off the feed.
</h2>
</div>
<div class="stack" data-stack>
<div class="stack__viewport" data-stack-viewport>
<!-- reversed in the DOM: last slide first, so no z-index is needed; the dark layer sits on every slide that arrives over another (all but the first shown, [07]).
Three nights, three different pictures on purpose: tail lights + city (cold), house light (warm), flash (hard) -->
<div class="stack__slide">
<div class="stack__media" data-stack-media><div class="stack__media-inner" data-stack-media-inner><img src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/6a65b344-e2dc-43a1-abf7-135a7df0eb77.jpg" width="2048" height="1360" alt="A black Silvia nose-down in the ditch under a guardrail, flash-lit; one guy with a foot on the rail, another on his phone" loading="lazy" decoding="async" style="object-position: 42% 50%"></div></div>
<div class="stack__caption t-body-lg" data-stack-caption><div><p>[14] The hairpin</p><p>S15, hairpin nine, one foot on the guardrail. He walked back up the hill, got a lift, and was there again the week after.</p></div></div>
<div class="stack__layer" data-stack-layer></div>
</div>
<div class="stack__slide">
<div class="stack__media" data-stack-media><div class="stack__media-inner" data-stack-media-inner><img src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/522cfa99-d1b8-48ef-b37d-4bfa2644dad3.jpg" width="2048" height="1360" alt="A white R34 driven through the porch of a small house at night, a crowd on the lawn with phones up, one guy on the roof pointing" loading="lazy" decoding="async" style="object-position: 55% 50%"></div></div>
<div class="stack__caption t-body-lg" data-stack-caption><div><p>[11] The house</p><p>The white R34 ended the night in a living room on a street we won’t name. Nobody remembers agreeing to it. Everyone has a photo, nobody posted one.</p></div></div>
<div class="stack__layer" data-stack-layer></div>
</div>
<div class="stack__slide">
<div class="stack__media" data-stack-media><div class="stack__media-inner" data-stack-media-inner><img src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/f4392b13-002f-4236-9308-d95eb8ec831f.jpg" width="2016" height="1344" alt="A girl sitting on the roof of a black R35 at a mountain-pass turnout, tail lights on, the city below" loading="lazy" decoding="async" style="object-position: 48% 50%"></div></div>
<div class="stack__caption t-body-lg" data-stack-caption><div><p>[07] The pass</p><p>Someone’s sister showed up in a stock R35, sat on the roof through the whole second heat and drove home before anyone got her name.</p></div></div>
</div>
</div>
</div>
</section>
</main>
<!-- ═══ FOOTER · "Pull up" lands here. Follows the stack after one 6rem gap;
the page recolours to the accent while this straddles the centre;
the wordmark is filled with a picture that parallaxes inside the glyphs ═══ -->
<footer id="pull-up" class="h-screen px-global flex flex-col justify-between pt-6 pb-6 mt-24" data-footer-spy>
<p class="t-quote reveal-lines md:max-w-66" data-split="lines" data-scroll data-scroll-offset="20%">
No list, no address, no posts. Someone who was there tells you where. Pull up.
</p>
<div>
<div class="flex items-end justify-between t-credit mb-5">
<p>Undr. — a drift night that exists for a few hours. Keep it off your feed ©<span data-year-now>2026</span></p>
<p class="md-only"><a class="link-dim" href="#rules">Rules</a> <a class="link-dim" href="#">Ask for the drop</a></p>
</div>
<!-- wordmark: an HTML block clipped to the glyphs (clip-path → the SVG <text> below) holding the B&W
still + its diamond raster (plate-fx); the frame is twice the block's height and parallaxes
−50% → 0 on scroll. "Undr. Drift" at its natural width: font-size 362, tracking
−14, ink 1405 of 1408, x −23 = the U's left bearing on the gutter; objectBoundingBox scale = 1/1408, 1/285 -->
<div class="wordmark" data-wordmark aria-hidden="true">
<div class="wordmark__frame" data-wordmark-frame>
<img crossorigin="anonymous" src="https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/f4392b13-002f-4236-9308-d95eb8ec831f.jpg" width="2016" height="1344" alt="" loading="lazy" decoding="async" data-plate-fx>
</div>
<svg width="0" height="0" aria-hidden="true" focusable="false">
<clipPath id="wordmark-clip" clipPathUnits="objectBoundingBox">
<text transform="scale(0.000710227 0.00350877)" x="-23" y="279" font-family="Inter Tight, Inter Tight Fallback, sans-serif" font-weight="400" font-size="362" letter-spacing="-14">Undr. Drift</text>
</clipPath>
</svg>
</div>
</div>
</footer>
<!-- ═══ CTA pill — "Pull up": slides in with the first pixels of
scroll, hides while the footer is on screen; glides to #pull-up via Lenis ═══ -->
<div class="pill-nav" data-pill-nav data-hidden="true">
<div class="pill-nav__box">
<a class="pill-nav__cta t-ui text-trim" href="#pull-up">Pull up ↗</a>
</div>
</div>
<!-- ═══ page transition chrome ═══ -->
<div class="transition-scrim" data-transition-scrim></div>
<div class="transition-panel" data-transition-panel><img alt="" width="2880" height="1800"></div>
<script type="module">
/* ═══════════════════════════════════════════════════════════════════════════
Undr. Drift — every module of the original lib/ folder plus main.js,
concatenated in dependency order and stripped of import/export:
lib/gsap.js · lib/scroll.js · lib/split.js · lib/reveal.js ·
lib/raster.js · lib/hero-video.js · lib/hero-fx.js · lib/plate-fx.js ·
lib/hero.js · lib/menu.js · lib/timeline.js · lib/stack.js ·
lib/title-mask.js · lib/transition.js · lib/pill-nav.js · main.js
═══════════════════════════════════════════════════════════════════════════ */
/* ─── lib/gsap.js ──────────────────────────────────────────────────────────
GSAP + plugins from the CDN globals (loaded with <script defer> above),
registered once. The four named eases are the CSS eases so the JS and CSS
halves of one motion share a curve. */
const { gsap, ScrollTrigger, SplitText, Flip, CustomEase } = window;
if (!gsap) throw new Error('gsap missing — check the CDN <script> order in the head');
gsap.registerPlugin(ScrollTrigger, SplitText, Flip, CustomEase);
CustomEase.create('magic', '0.33, 0, 0, 1'); // --ease-magic
CustomEase.create('quart', '0.41, 0.35, 0.2, 1'); // --ease-quart
CustomEase.create('outExpo', '0.19, 1, 0.22, 1'); // --ease-out-expo
CustomEase.create('base', '0.46, 0.03, 0.12, 1');// --ease-base
const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
const isMobile = () => matchMedia('(max-width: 767px)').matches;
/* ─── lib/scroll.js ────────────────────────────────────────────────────────
Smooth scroll + the class contract.
Locomotive Scroll 5 (bundled, Lenis 1.3.17 inside) owns the scroll:
- Lenis defaults (lerp .1, smoothWheel);
- the ticker is gsap's, so Lenis, ScrollTrigger and every tween step in
one frame; ScrollTrigger.update rides the lenis scroll event;
- [data-scroll] elements get `.is-inview` from Locomotive's IntersectionObserver
honouring data-scroll-offset / -repeat / -speed / -css-progress. */
let instance = null;
const listeners = new Set();
function initScroll() {
if (instance) return instance;
instance = new window.LocomotiveScroll({
lenisOptions: {},
initCustomTicker: (render) => gsap.ticker.add(render),
destroyCustomTicker: (render) => gsap.ticker.remove(render),
scrollCallback: (e) => listeners.forEach((fn) => fn(e)),
});
if (history.scrollRestoration) {
history.scrollRestoration = 'manual';
window.scrollTo(0, 0);
}
instance.lenisInstance?.on('scroll', ScrollTrigger.update);
gsap.ticker.lagSmoothing(0);
bindAnchors();
return instance;
}
const getScroll = () => instance;
const onScroll = (fn) => { listeners.add(fn); return () => listeners.delete(fn); };
/* in-page anchors glide for 2s through Lenis instead of jumping */
function bindAnchors() {
const isSamePage = (href) => {
if (href.startsWith('#')) return true;
const u = new URL(href, location.origin);
return u.pathname === location.pathname && u.hash.length > 0;
};
document.addEventListener('click', (e) => {
const a = e.target.closest('a');
if (!a) return;
const href = a.getAttribute('href');
if (!href || !href.includes('#')) return;
const hash = href.slice(href.indexOf('#'));
if (hash && hash !== '#' && isSamePage(href) && document.querySelector(hash)) {
e.preventDefault();
instance?.scrollTo(hash, { duration: 2 });
}
});
if (location.hash) {
const hash = location.hash;
requestAnimationFrame(() => instance?.scrollTo(hash, { duration: 2 }));
}
}
/* ─── lib/split.js ─────────────────────────────────────────────────────────
[data-split] → SplitText, after the font is in (a split measured against
the fallback face re-flows). Every unit carries its index as a custom
property (propIndex → --line / --word / --char, 1-based) — that index IS the
stagger, computed in CSS (`transition-delay: calc(var(--word) * 75ms)`).
`mask` wraps each unit in `.<unit>-mask`; the stylesheet swaps its
overflow:clip for a clip-path with vertical bleed.
Attributes: data-split="lines|words|chars" (default lines),
data-split-mask="lines|words|chars" (default lines). */
let refreshQueued = false;
const queueRefresh = () => {
if (refreshQueued) return;
refreshQueued = true;
requestAnimationFrame(() => { refreshQueued = false; ScrollTrigger.refresh(); });
};
function initSplit(root = document) {
const splits = [];
let cancelled = false;
document.fonts.ready.then(() => requestAnimationFrame(() => {
if (cancelled) return;
gsap.utils.toArray('[data-split]', root).forEach((el) => {
const { split, splitMask } = el.dataset;
splits.push(new SplitText(el, {
tag: 'span',
type: split?.toLowerCase() || 'lines',
mask: splitMask?.toLowerCase() || 'lines',
charsClass: 'char',
wordsClass: 'word',
linesClass: 'line',
propIndex: true,
autoSplit: true,
onSplit: queueRefresh,
}));
});
}));
return () => { cancelled = true; splits.forEach((s) => s.revert()); };
}
/* ─── lib/reveal.js ────────────────────────────────────────────────────────
The non-split halves of the reveal contract + the footer spy.
- .reveal-image gets `.is-loaded` once its <img> has pixels; CSS then plays
reveal-clip only when BOTH .is-inview and .is-loaded are present;
- footer spy: body.is-footer-inview while the footer straddles the
viewport centre → the page recolours through the token --bg-footer. */
function initImageLoad(root = document) {
root.querySelectorAll('.reveal-image:not([data-load-init])').forEach((box) => {
box.setAttribute('data-load-init', '');
const img = box.querySelector('img');
const loaded = () => box.classList.add('is-loaded');
if (!img) return loaded();
if (img.complete && img.naturalWidth > 0) loaded();
else {
img.addEventListener('load', loaded, { once: true });
img.addEventListener('error', loaded, { once: true });
}
});
}
function initFooterSpy(footer = document.querySelector('[data-footer-spy]')) {
if (!footer) return;
ScrollTrigger.create({
trigger: footer,
start: 'top center',
end: 'bottom top',
onEnter: () => document.body.classList.add('is-footer-inview'),
onLeaveBack: () => document.body.classList.remove('is-footer-inview'),
});
}
/* dev: replay a reveal — strip the state classes, force a reflow, put them back */
function replay(el) {
const had = ['is-inview', 'is-loaded'].filter((c) => el.classList.contains(c));
el.classList.remove('is-inview');
el.querySelectorAll('.is-inview').forEach((n) => n.classList.remove('is-inview'));
void el.offsetWidth;
gsap.delayedCall(0.05, () => {
had.forEach((c) => el.classList.add(c));
el.querySelectorAll('[data-scroll]').forEach((n) => n.classList.add('is-inview'));
});
}
/* ─── lib/raster.js ────────────────────────────────────────────────────────
Diamond raster — the "Custom ASCII art" preset (renderMode "diamond")
re-implemented as one WebGL fragment shader, as an engine any element can
wear: the hero clip (with a trailing source and inertia) and the Rules
plates (a still each).
Passes (all GLSL ES 1.00, works on webgl1 and webgl2 contexts):
1. down source → cell texture (cols × rows): cover-mapped like the
element under it, 3×3 taps averaged per cell, input desaturated
2. blurH/V bright cells (luma > .35) blurred at cell resolution → bloom
3. main per pixel: the cell's luma (+ edge emphasis, + flicker) sets the
diamond radius |x|+|y| ≤ r inside the cell; three channels sample
the field at ±chromatic offset; solid bg at bgOpacity, diamonds
added on top ("lighter"); bloom; brightness/contrast/saturation/
grayscale, tint; scan lines, vignette, film grain; glitch bands.
createRaster({ host, params, className }) appends a <canvas class="raster">
to `host` (absolute, inset 0) and returns { canvas, gl, params, setSource(el),
upload(), markDirty(), render(time), resize(), set(partial), frameOk, lost,
destroy() } — or null when WebGL is unavailable. The caller owns the loop,
the gating and the timing; one WebGL context per raster. */
/* the preset, keys as in the source JSON (0–100 where the tool uses 0–100) */
const PRESET = {
renderMode: 'diamond',
bgMode: 'solid', bgColor: '#0d0e10', bgOpacity: 73,
cellSize: 5, coverage: 100, invert: false, styleBlend: 'lighter',
brightness: 100, contrast: 100, saturation: 100, grayscale: 0,
edgeEmphasis: 43, density: 0,
tint: '#d8b46a', tintOpacity: 0, overlayBlend: 'multiply',
pfx: { vignette: 38, scanLines: 28, chromatic: 40, bloom: 60, filmGrain: 40, glitch: 20 },
animated: true, animStyle: 'flicker', animSpeed: 180, animIntensity: 28,
/* ours, not in the preset: the source video is greyscale under the effect */
inputSaturation: 0,
maxDpr: 1.5,
};
const VERT = `attribute vec2 a; varying vec2 v; void main(){ v = a * .5 + .5; gl_Position = vec4(a, 0., 1.); }`;
const FRAG_DOWN = `precision mediump float;
uniform sampler2D u_video; uniform vec2 u_grid, u_cellUV, u_cover; uniform float u_inSat;
varying vec2 v;
float luma(vec3 c){ return dot(c, vec3(.2126, .7152, .0722)); }
void main(){
vec3 acc = vec3(0.);
for (int i = -1; i <= 1; i++) for (int j = -1; j <= 1; j++) {
vec2 uv = (v * u_grid + vec2(float(i), float(j)) / 3.) * u_cellUV; /* cell centre ± a third of a cell, in canvas uv */
acc += texture2D(u_video, (uv - .5) * u_cover + .5).rgb; /* object-fit: cover, like the <video> */
}
acc /= 9.;
gl_FragColor = vec4(mix(vec3(luma(acc)), acc, u_inSat), 1.);
}`;
const FRAG_BLUR = `precision mediump float;
uniform sampler2D u_tex; uniform vec2 u_dir; uniform float u_thresh;
varying vec2 v;
float luma(vec3 c){ return dot(c, vec3(.2126, .7152, .0722)); }
vec3 tap(vec2 uv){ vec3 c = texture2D(u_tex, uv).rgb; return u_thresh > 0. ? c * smoothstep(u_thresh, 1., luma(c)) : c; }
void main(){
vec3 c = tap(v) * .227;
c += (tap(v + u_dir) + tap(v - u_dir)) * .195;
c += (tap(v + u_dir * 2.) + tap(v - u_dir * 2.)) * .122;
c += (tap(v + u_dir * 3.) + tap(v - u_dir * 3.)) * .054;
c += (tap(v + u_dir * 4.) + tap(v - u_dir * 4.)) * .016;
gl_FragColor = vec4(c, 1.);
}`;
const FRAG_MAIN = `precision highp float;
uniform sampler2D u_cells, u_bloom;
uniform vec2 u_res, u_grid;
uniform float u_scale, u_cell, u_time;
uniform vec3 u_bg, u_tint;
uniform float u_bgA, u_edge, u_flickSpeed, u_flickAmp, u_coverage, u_invert;
uniform float u_bright, u_contrast, u_sat, u_gray, u_tintA;
uniform float u_vig, u_scan, u_chroma, u_bloomI, u_grain, u_glitch;
varying vec2 v;
float hash(vec2 p){ p = fract(p * vec2(123.34, 456.21)); p += dot(p, p + 45.32); return fract(p.x * p.y); }
float luma(vec3 c){ return dot(c, vec3(.2126, .7152, .0722)); }
/* p in CSS px → (diamond colour, coverage) of the cell under p */
vec4 field(vec2 p){
vec2 cell = floor(p / u_cell);
vec2 cuv = (cell + .5) / u_grid;
vec3 c = texture2D(u_cells, cuv).rgb;
float l = luma(c);
float lx = luma(texture2D(u_cells, cuv - vec2(1. / u_grid.x, 0.)).rgb);
float ly = luma(texture2D(u_cells, cuv - vec2(0., 1. / u_grid.y)).rgb);
l = clamp(l + (abs(l - lx) + abs(l - ly)) * u_edge * 2., 0., 1.); /* edgeEmphasis */
float f = hash(cell + floor(u_time * u_flickSpeed) * .37); /* flicker: per cell, re-rolled flickSpeed×/s */
l = clamp(l * (1. + (f - .5) * 2. * u_flickAmp), 0., 1.);
if (u_invert > .5) l = 1. - l;
float keep = step(hash(cell * 1.7 + 3.1), u_coverage); /* coverage */
vec2 local = fract(p / u_cell) - .5;
float d = abs(local.x) + abs(local.y); /* diamond SDF, cell units */
float aa = .7 / (u_cell * u_scale);
float cov = (1. - smoothstep(l * .5 - aa, l * .5 + aa, d)) * keep;
vec3 col = c * (l / max(luma(c), 1e-4)); /* the cell colour at the emphasised luma */
return vec4(col, cov);
}
void main(){
vec2 p = gl_FragCoord.xy / u_scale;
vec2 cssRes = u_res / u_scale;
vec2 uv = gl_FragCoord.xy / u_res;
vec2 dir = uv - .5;
float dist = length(dir * vec2(cssRes.x / cssRes.y, 1.));
/* glitch: a few horizontal bands slip sideways for a moment */
float seed = floor(u_time * 9.);
float bandH = 12. + 40. * hash(vec2(seed, 1.7));
float band = floor(p.y / bandH);
float on = step(1. - u_glitch * .45, hash(vec2(seed, band)));
p.x += on * (hash(vec2(band, seed)) - .5) * 60. * u_glitch;
/* chromatic aberration grows with the distance from the centre */
vec2 off = dir * dist * u_chroma * 22. * (1. + on * 3.); /* ≈1.5 cells at the edge for chromatic 40 */
vec4 fr = field(p + off), fg = field(p), fb = field(p - off);
float cov = (fr.a + fg.a + fb.a) / 3.;
vec3 dia = vec3(fr.r * fr.a, fg.g * fg.a, fb.b * fb.a);
/* bgMode solid at bgOpacity, then the diamonds added ("lighter"), premultiplied */
vec3 rgb = u_bg * u_bgA + dia;
float alpha = u_bgA + (1. - u_bgA) * cov;
vec3 bl = texture2D(u_bloom, p / (u_cell * u_grid)).rgb;
rgb += bl * u_bloomI * .8;
alpha = clamp(alpha + luma(bl) * u_bloomI * .5, 0., 1.);
/* colour adjustments in the recipe's order, on straight colour */
vec3 c = rgb / max(alpha, 1e-4);
c *= u_bright;
c = (c - .5) * u_contrast + .5;
c = mix(vec3(luma(c)), c, u_sat);
c = mix(c, vec3(luma(c)), u_gray);
c = mix(c, c * u_tint, u_tintA);
/* post: scan lines (2 css px), vignette, film grain */
c *= 1. - u_scan * .8 * (.5 + .5 * sin(p.y * 3.14159265));
c *= 1. - u_vig * smoothstep(.5, 1.1, dist);
c += (hash(gl_FragCoord.xy * .37 + vec2(u_time * 13., u_time * 7.)) - .5) * u_grain * .35;
c = clamp(c, 0., 1.);
gl_FragColor = vec4(c * alpha, alpha);
}`;
const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
function createRaster({ host, params = {}, className = 'raster' } = {}) {
if (!host || reduced) return null;
const P = { ...PRESET, ...params, pfx: { ...PRESET.pfx, ...(params.pfx || {}) } };
const canvas = document.createElement('canvas');
canvas.className = className;
canvas.setAttribute('aria-hidden', 'true');
host.appendChild(canvas);
const gl = canvas.getContext('webgl', { alpha: true, premultipliedAlpha: true, antialias: false, depth: false, stencil: false, powerPreference: 'high-performance' });
if (!gl) { canvas.remove(); return null; }
/* ── programs ── */
const compile = (type, src) => {
const sh = gl.createShader(type);
gl.shaderSource(sh, src); gl.compileShader(sh);
if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) throw new Error('raster shader: ' + gl.getShaderInfoLog(sh));
return sh;
};
const program = (frag) => {
const pr = gl.createProgram();
gl.attachShader(pr, compile(gl.VERTEX_SHADER, VERT));
gl.attachShader(pr, compile(gl.FRAGMENT_SHADER, frag));
gl.linkProgram(pr);
if (!gl.getProgramParameter(pr, gl.LINK_STATUS)) throw new Error('raster link: ' + gl.getProgramInfoLog(pr));
const u = {};
const n = gl.getProgramParameter(pr, gl.ACTIVE_UNIFORMS);
for (let i = 0; i < n; i++) { const info = gl.getActiveUniform(pr, i); u[info.name] = gl.getUniformLocation(pr, info.name); }
return { pr, u };
};
let prDown, prBlur, prMain;
try { prDown = program(FRAG_DOWN); prBlur = program(FRAG_BLUR); prMain = program(FRAG_MAIN); }
catch (e) { console.warn(e.message); canvas.remove(); return null; }
/* one big triangle */
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
for (const { pr } of [prDown, prBlur, prMain]) {
const a = gl.getAttribLocation(pr, 'a');
gl.enableVertexAttribArray(a);
gl.vertexAttribPointer(a, 2, gl.FLOAT, false, 0, 0);
}
/* ── textures / framebuffers ── */
const texture = (filter) => {
const t = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
return t;
};
const target = (filter) => {
const t = texture(filter), fb = gl.createFramebuffer();
return { t, fb, w: 0, h: 0, size(w, h) {
if (this.w === w && this.h === h) return;
this.w = w; this.h = h;
gl.bindTexture(gl.TEXTURE_2D, t);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, w, h, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0);
} };
};
const srcTex = texture(gl.LINEAR);
const cells = target(gl.NEAREST), tmp = target(gl.LINEAR), bloom = target(gl.LINEAR);
/* ── size ── */
let dirty = true, frameOk = false, lost = false; /* dirty: the source has a new frame waiting to be uploaded */
const size = { w: 0, h: 0, cssW: 0, cssH: 0, scale: 1, cols: 1, rows: 1 };
const resize = () => {
const r = host.getBoundingClientRect();
/* layout size, not the transformed one — hosts get scaled by intros */
const cssW = host.clientWidth || r.width, cssH = host.clientHeight || r.height;
if (!cssW || !cssH) return;
const scale = Math.min(devicePixelRatio || 1, P.maxDpr);
Object.assign(size, { cssW, cssH, scale, w: Math.round(cssW * scale), h: Math.round(cssH * scale), cols: Math.ceil(cssW / P.cellSize), rows: Math.ceil(cssH / P.cellSize) });
canvas.width = size.w; canvas.height = size.h;
cells.size(size.cols, size.rows); tmp.size(size.cols, size.rows); bloom.size(size.cols, size.rows);
dirty = true;
};
const ro = new ResizeObserver(resize);
ro.observe(host);
resize();
/* ── source ── */
let source = null;
const hasPixels = (el) => el && (
el instanceof HTMLVideoElement ? (frameOk || el.readyState >= 2) /* after the first frame the presented frame is always valid, seeking or not */
: el instanceof HTMLImageElement ? (el.complete && el.naturalWidth > 0)
: true);
const srcSize = (el) => el instanceof HTMLVideoElement ? [el.videoWidth || 16, el.videoHeight || 9]
: el instanceof HTMLImageElement ? [el.naturalWidth || 3, el.naturalHeight || 2]
: [el.width || 1, el.height || 1];
const upload = () => {
if (lost || !hasPixels(source)) return false;
gl.bindTexture(gl.TEXTURE_2D, srcTex);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
let ok = false;
try { gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, source); frameOk = true; ok = true; }
catch { /* not decodable yet — keep the last frame */ }
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
dirty = false;
return ok;
};
/* ── render ── */
const draw = (pr, fb, w, h) => {
gl.useProgram(pr.pr);
gl.bindFramebuffer(gl.FRAMEBUFFER, fb);
gl.viewport(0, 0, w, h);
gl.drawArrays(gl.TRIANGLES, 0, 3);
};
const bind = (unit, t) => { gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, t); };
const render = (time = 0) => {
if (lost || !size.w || !source) return false;
if (dirty) upload();
if (!frameOk) return false;
const [sw, sh] = srcSize(source);
const ca = size.cssW / size.cssH, sa = sw / sh;
const cover = sa > ca ? [ca / sa, 1] : [1, sa / ca];
/* 1 · cells */
gl.useProgram(prDown.pr);
bind(0, srcTex);
gl.uniform1i(prDown.u.u_video, 0);
gl.uniform2f(prDown.u.u_grid, size.cols, size.rows);
gl.uniform2f(prDown.u.u_cellUV, P.cellSize / size.cssW, P.cellSize / size.cssH);
gl.uniform2f(prDown.u.u_cover, cover[0], cover[1]);
gl.uniform1f(prDown.u.u_inSat, P.inputSaturation);
draw(prDown, cells.fb, cells.w, cells.h);
/* 2 · bloom: bright cells, blurred H then V at cell resolution */
gl.useProgram(prBlur.pr);
bind(0, cells.t);
gl.uniform1i(prBlur.u.u_tex, 0);
gl.uniform2f(prBlur.u.u_dir, 1 / size.cols, 0);
gl.uniform1f(prBlur.u.u_thresh, 0.35);
draw(prBlur, tmp.fb, tmp.w, tmp.h);
bind(0, tmp.t);
gl.uniform2f(prBlur.u.u_dir, 0, 1 / size.rows);
gl.uniform1f(prBlur.u.u_thresh, 0);
draw(prBlur, bloom.fb, bloom.w, bloom.h);
/* 3 · main */
const u = prMain.u, pf = P.pfx;
gl.useProgram(prMain.pr);
bind(0, cells.t); bind(1, bloom.t);
gl.uniform1i(u.u_cells, 0); gl.uniform1i(u.u_bloom, 1);
gl.uniform2f(u.u_res, size.w, size.h);
gl.uniform2f(u.u_grid, size.cols, size.rows);
gl.uniform1f(u.u_scale, size.scale);
gl.uniform1f(u.u_cell, P.cellSize);
gl.uniform1f(u.u_time, P.animated ? time : 0);
gl.uniform3fv(u.u_bg, hex(P.bgColor));
gl.uniform1f(u.u_bgA, P.bgMode === 'none' ? 0 : P.bgOpacity / 100);
gl.uniform1f(u.u_edge, P.edgeEmphasis / 100);
gl.uniform1f(u.u_flickSpeed, P.animated && P.animStyle === 'flicker' ? P.animSpeed / 100 * 6 : 0);
gl.uniform1f(u.u_flickAmp, P.animated ? P.animIntensity / 100 : 0);
gl.uniform1f(u.u_coverage, P.coverage / 100);
gl.uniform1f(u.u_invert, P.invert ? 1 : 0);
gl.uniform1f(u.u_bright, P.brightness / 100);
gl.uniform1f(u.u_contrast, P.contrast / 100);
gl.uniform1f(u.u_sat, P.saturation / 100);
gl.uniform1f(u.u_gray, P.grayscale / 100);
gl.uniform3fv(u.u_tint, hex(P.tint));
gl.uniform1f(u.u_tintA, P.tintOpacity / 100);
gl.uniform1f(u.u_vig, pf.vignette / 100);
gl.uniform1f(u.u_scan, pf.scanLines / 100);
gl.uniform1f(u.u_chroma, pf.chromatic / 100);
gl.uniform1f(u.u_bloomI, pf.bloom / 100);
gl.uniform1f(u.u_grain, pf.filmGrain / 100);
gl.uniform1f(u.u_glitch, pf.glitch / 100);
draw(prMain, null, size.w, size.h);
return true;
};
canvas.addEventListener('webglcontextlost', (e) => { e.preventDefault(); lost = true; canvas.hidden = true; });
return {
canvas, gl,
get params() { return P; },
get frameOk() { return frameOk; },
get lost() { return lost; },
get source() { return source; },
setSource(el) { source = el; frameOk = false; dirty = true; },
markDirty() { dirty = true; },
upload, render, resize,
set(partial = {}) { Object.assign(P, partial, { pfx: { ...P.pfx, ...(partial.pfx || {}) } }); resize(); },
destroy() { ro.disconnect(); canvas.remove(); gl.getExtension('WEBGL_lose_context')?.loseContext(); },
};
}
/* ─── lib/hero-video.js ────────────────────────────────────────────────────
Scroll-scrubbed hero video ("seek-coalescing scrubber"). The clip is
all-intra (`ffmpeg -g 1`), so a plain `currentTime` seek lands on any frame
in ~10ms and no frame bank is needed.
- at most ONE seek in flight, always chasing the latest target (`seeked` → next);
- 30ms dead zone (~¾ of a 24fps frame) so a settled scroll stops seeking;
- poster-pin at rest: `loadedmetadata` → pause + currentTime .001;
- warm the decoder once on `canplay` (seek to ~3% and back);
- `ready` resolves when metadata + first frame are in (or on error / 4s
timeout, so a missing clip never blocks the hero intro);
- `onFrame(fn)`: fn runs inside `seeked`, BEFORE the next seek goes out.
While a scrub is running the element sits in `seeking` with readyState 1
the whole time, so anyone who needs the frame's pixels (the WebGL raster)
must take them here.
Reduced motion: no scrubbing, the poster/first frame stays. */
function initHeroVideo(video = document.querySelector('[data-hero-video]')) {
if (!video) return { scrub() {}, ready: Promise.resolve(), video: null };
let pending = null;
let seeking = false;
const frameListeners = new Set();
const applyNext = () => {
/* a frame has landed: consumers take it now, then the next seek goes out */
frameListeners.forEach((fn) => fn(video));
if (pending === null) { seeking = false; return; }
const t = pending; pending = null;
if (Math.abs(video.currentTime - t) < 0.01) { seeking = false; return; }
seeking = true;
try { video.currentTime = t; } catch { seeking = false; }
};
video.addEventListener('seeked', applyNext);
/* p01 ∈ [0,1] → clip time */
const scrub = (p01) => {
if (reduced) return;
const d = video.duration;
if (!d || video.readyState < 1) return;
if (!video.paused) video.pause();
const target = Math.min(d - 0.001, Math.max(0, p01 * d));
if (!seeking && Math.abs(video.currentTime - target) < 0.03) return;
pending = target;
if (!seeking) applyNext();
};
const ready = new Promise((resolve) => {
let done = false;
const finish = () => { if (!done) { done = true; resolve(); } };
const onMeta = () => { video.pause(); try { video.currentTime = 0.001; } catch {} };
const onCanPlay = () => {
/* prime the decoder: one seek forward and back, then we are ready */
const warm = Math.min(0.12, (video.duration || 4) * 0.03);
const back = () => { video.removeEventListener('seeked', back); try { video.currentTime = 0.001; } catch {} finish(); };
video.addEventListener('seeked', back, { once: true });
try { video.currentTime = warm; } catch { finish(); }
};
if (video.readyState >= 1) onMeta(); else video.addEventListener('loadedmetadata', onMeta, { once: true });
if (video.readyState >= 3) onCanPlay(); else video.addEventListener('canplay', onCanPlay, { once: true });
video.addEventListener('error', finish, { once: true });
setTimeout(finish, 4000);
});
const onFrame = (fn) => { frameListeners.add(fn); return () => frameListeners.delete(fn); };
return { scrub, ready, video, onFrame, get seeking() { return seeking; } };
}
/* ─── lib/hero-fx.js ───────────────────────────────────────────────────────
Hero diamond raster — the raster engine worn by the hero clip.
Frames: the texture is refilled inside a scrubber's `onFrame` hook — i.e.
in `seeked`, before the next seek is issued. During a scrub the element is
`seeking` (readyState 1) continuously, so a ticker-timed upload would wait
for the scroll to stop and the raster would lag the clip by that much.
Inertia: the raster has its OWN source — a 360p all-intra copy of the clip
(`lagSrc`) scrubbed with the same seek-coalescing scrubber to a time that
eases toward the clip's time every tick,
`lagP += (targetP − lagP)·(1 − e^(−dt/lagTau))`. The clip moves first, the
raster trails and decelerates into place. `lagTau 0` or no second source
(mobile by default) → the raster reads the main <video> frame by frame.
The hero root gets `.has-fx` (CSS desaturates the <video> that shows
through at 27%). Renders on gsap.ticker while the stage is on screen and
the scene is not faded (p < 1.5).
Roll back: FX = false below (or `?fx=off` in the URL). */
const HERO_PARAMS = {
/* inertia: the raster's own low-res source trails the clip, time constant in seconds (0 = frame-locked) */
lagTau: 0.3,
lagSrc: 'https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/5699b256-92c4-46d5-8778-bd48dc6de453.mp4', /* CloudFront, CORS + ranges */
lagOnMobile: false,
};
function initHeroFx({ video, host = document.querySelector('[data-scene]'), root = host?.closest('[data-hero]'), ready = Promise.resolve(), onFrame, params = {} } = {}) {
if (!video || !host) return null;
const H = { ...HERO_PARAMS, ...params };
root?.classList.add('has-fx'); /* the <video> under the canvas goes B&W (CSS) */
const stub = { canvas: null, ready: Promise.resolve(), set() {}, pause() {}, resume() {}, progress() {}, get params() { return H; } };
if (reduced) return stub;
/* the raster's frame source: its own trailing copy of the clip, or the main <video> */
const trailing = H.lagTau > 0 && !!H.lagSrc && (H.lagOnMobile || !isMobile());
let source = video, sourceScrub = null, sourceState = null, frameHook = onFrame, fxReady = ready;
if (trailing) {
source = document.createElement('video');
source.muted = true; source.playsInline = true; source.preload = 'auto';
source.crossOrigin = 'anonymous'; /* remote clip → the texture upload needs CORS */
source.setAttribute('aria-hidden', 'true');
/* kept renderable (not display:none) so every browser keeps presenting frames to texImage2D */
source.style.cssText = 'position:absolute;width:1px;height:1px;opacity:0;pointer-events:none';
source.src = H.lagSrc;
host.appendChild(source);
const s = initHeroVideo(source);
sourceScrub = s.scrub; frameHook = s.onFrame; fxReady = s.ready; sourceState = s;
}
const raster = createRaster({ host, params, className: 'raster hero__fx' });
if (!raster) { if (trailing) source.remove(); root?.classList.remove('has-fx'); return null; }
raster.setSource(source);
/* the scrub: take each landed frame inside `seeked`, before the scrubber's next seek */
let lastFrame = -1, paused = false;
const take = () => { if (!paused && raster.upload()) lastFrame = source.currentTime; };
if (frameHook) frameHook(take);
/* everything else (rest, warm-up, a frame presented for any other reason) just marks dirty for the ticker */
const mark = () => raster.markDirty();
source.addEventListener('seeked', mark);
source.addEventListener('loadeddata', mark);
if ('requestVideoFrameCallback' in source) {
const tick = () => { raster.markDirty(); source.requestVideoFrameCallback(tick); };
source.requestVideoFrameCallback(tick);
}
/* ── inertia: the trailing source eases toward the clip's time ── */
let targetP = 0, lagP = 0, prog = 0, active = true;
const trail = (dtMs) => {
if (!sourceScrub || raster.lost) return;
if (prog >= 1.5 || H.lagTau <= 0) { lagP = targetP; return; } /* scene faded: no seeks, just stay in sync */
const k = 1 - Math.exp(-(dtMs / 1000) / H.lagTau);
lagP += (targetP - lagP) * k;
if (Math.abs(targetP - lagP) < 0.0005) {
lagP = targetP;
/* arrived: land on the clip's exact frame — the scrubber's 30ms dead zone would leave the last frame behind */
const d = source.duration, t = d ? Math.min(d - 0.001, targetP * d) : 0;
if (d && !sourceState.seeking && Math.abs(source.currentTime - t) > 0.01) { try { source.currentTime = t; } catch {} }
return;
}
sourceScrub(lagP); /* the scrubber's dead zone swallows the no-ops */
};
/* ── loop ── */
const t0 = performance.now();
const render = (_, dtMs) => {
trail(dtMs || 16.7);
if (paused || !active || prog >= 1.5) return; /* a hidden tab stops rAF, so the ticker, on its own */
if (raster.render((performance.now() - t0) / 1000) && lastFrame < 0) lastFrame = source.currentTime;
};
raster.canvas.addEventListener('webglcontextlost', () => root?.classList.remove('has-fx'));
fxReady.then(() => { raster.markDirty(); gsap.ticker.add(render); });
/* only while the stage is on screen */
if (root) ScrollTrigger.create({ trigger: root, start: 'top bottom', end: 'bottom top', onToggle: (self) => { active = self.isActive; } });
return {
raster, canvas: raster.canvas, gl: raster.gl, source, ready: fxReady,
get params() { return { ...raster.params, ...H }; },
get lastFrame() { return lastFrame; },
get lagP() { return lagP; },
get targetP() { return targetP; },
set(partial = {}) { for (const k of Object.keys(HERO_PARAMS)) if (k in partial) H[k] = partial[k]; raster.set(partial); },
pause() { paused = true; },
resume() { paused = false; },
progress(p) { prog = p; targetP = Math.min(1, Math.max(0, p / 1.5)); },
render: () => raster.render((performance.now() - t0) / 1000),
};
}
/* ─── lib/plate-fx.js ──────────────────────────────────────────────────────
Diamond raster on stills — the raster engine worn by the Rules plates.
Markup: .timeline__frame[data-timeline-item-frame] > img[data-plate-fx]
The canvas is appended beside the <img> inside the frame (the frame is the
positioned box both fill; the reveal clip and the hover inset stay on the
parents, the parallax moves the frame — image and raster move together).
The image is uploaded once, after load + decode; the raster renders on
gsap.ticker only while the plate is on screen, so six plates cost about one
hero. The <img> shows through at 27% and is B&W in CSS. */
function initPlateFx(imgs = document.querySelectorAll('img[data-plate-fx]')) {
const plates = [];
imgs.forEach((img) => {
const host = img.parentElement;
const loaded = () => img.classList.add('is-loaded');
if (img.complete && img.naturalWidth > 0) loaded();
else { img.addEventListener('load', loaded, { once: true }); img.addEventListener('error', loaded, { once: true }); }
if (reduced) return;
const raster = createRaster({ host });
if (!raster) return;
raster.setSource(img);
const plate = { raster, img, active: false };
const take = () => (img.decode ? img.decode().catch(() => {}) : Promise.resolve()).then(() => raster.upload());
if (img.complete && img.naturalWidth > 0) take();
else img.addEventListener('load', take, { once: true });
const t0 = performance.now() - Math.random() * 10000; /* unsynchronised flicker across plates */
const render = () => { if (plate.active) raster.render((performance.now() - t0) / 1000); };
gsap.ticker.add(render);
ScrollTrigger.create({ trigger: host, start: 'top bottom', end: 'bottom top', onToggle: (self) => { plate.active = self.isActive; } });
plates.push(plate);
});
return plates;
}
/* ─── lib/hero.js ──────────────────────────────────────────────────────────
Hero stage choreography (intro + scrub-out), media-agnostic.
Intro (page load, scroll locked until it ends):
t=0 layers scale 1.4→1.2 (1s power4.out) scene clip-path: a plus grows from nothing to icon
size at the centre (L 4.5vh, stroke .14·L) (1s power4.out)
t=1 layers scale 1.2→1 (2s power4.inOut) the plus keeps growing until its bars swallow the
frame (L 2.2·max(cx,cy), w 1.05·max) (2s power4.inOut), then clip none
both words: chars from x:1em (2s power4.inOut, stagger .01, masked)
<50% rule .is-inview (scaleX 0→1, 1.4s out-expo in CSS)
< row words from xPercent 50 / autoAlpha 0 (.6s magic, stagger .15)
<20% date from x:−1em, time from x:+1em (.8s magic) · "+" from rotate 90 / scale 0 (.8s magic)
<50% note autoAlpha 0→1 (1s power4.inOut)
-=.8 scroll unlocked
Scrub (top top → +=55% — the --hero-runway token, progress still 0→1.5 for the
media), the chrome leaves inside the first .6 (= 40% of the runway), the scene
fades to the night plate over the last .45 (from p 1.05). */
let introDone = false;
/* 12-vertex "+" polygon around (cx, cy): L = half arm length, w = half stroke.
The scene opens through the same plus that sits between the date and the time. */
const plusPath = (cx, cy, L, w) => `polygon(${[
[cx - w, cy - L], [cx + w, cy - L], [cx + w, cy - w], [cx + L, cy - w],
[cx + L, cy + w], [cx + w, cy + w], [cx + w, cy + L], [cx - w, cy + L],
[cx - w, cy + w], [cx - L, cy + w], [cx - L, cy - w], [cx - w, cy - w],
].map(([x, y]) => `${x.toFixed(1)}px ${y.toFixed(1)}px`).join(', ')})`;
function initHero({ root = document.querySelector('[data-hero]'), onProgress, ready = Promise.resolve() } = {}) {
if (!root) return null;
const scroll = getScroll();
const scene = root.querySelector('[data-scene-wrapper]');
const layers = root.querySelector('[data-scene]');
const rule = root.querySelector('[data-hero-rule]');
/* lock scroll until the intro releases it; failsafe so a stalled media
promise never leaves the page dead */
requestAnimationFrame(() => { if (!introDone) scroll?.stop(); });
const failsafe = setTimeout(release, 6000);
function release() {
if (introDone) return;
introDone = true;
clearTimeout(failsafe);
scroll?.start();
}
let split, scrub;
ready.then(() => {
gsap.set(root, { visibility: 'visible' });
split = SplitText.create('[data-line]', { tag: 'span', type: 'chars', mask: 'chars', charsClass: 'char', propIndex: true, autoSplit: true });
if (reduced) {
gsap.set([scene, layers], { clearProps: 'all' });
rule?.classList.add('is-inview');
release();
} else {
const cx = scene.clientWidth / 2, cy = scene.clientHeight / 2;
const iconL = Math.max(28, innerHeight * 0.045), cover = Math.max(cx, cy);
const plus = { L: 0, w: 0 };
const applyPlus = () => gsap.set(scene, { clipPath: plusPath(cx, cy, plus.L, plus.w) });
applyPlus();
gsap.timeline()
.fromTo(layers, { scale: 1.4 }, { scale: 1.2, duration: 1, ease: 'power4.out' }, 0)
.to(layers, { scale: 1, duration: 2, ease: 'power4.inOut' }, 1)
.to(plus, { L: iconL, w: iconL * 0.14, duration: 1, ease: 'power4.out', onUpdate: applyPlus }, 0)
.from(split.chars, { duration: 2, x: '1em', ease: 'power4.inOut', stagger: { each: 0.01, from: 'start' } }, 1)
.to(plus, { L: cover * 2.2, w: cover * 1.05, duration: 2, ease: 'power4.inOut', onUpdate: applyPlus }, '<')
.call(() => rule?.classList.add('is-inview'), undefined, '<50%')
.from('[data-hero-row] > *', { xPercent: 50, autoAlpha: 0, duration: 0.6, ease: 'magic', stagger: 0.15 }, '<')
.from('[data-hero-date]', { x: '-1em', autoAlpha: 0, duration: 0.8, ease: 'magic' }, '<20%')
.from('[data-hero-time]', { x: '1em', autoAlpha: 0, duration: 0.8, ease: 'magic' }, '<')
.from('[data-hero-plus]', { rotate: 90, scale: 0, autoAlpha: 0, duration: 0.8, ease: 'magic' }, '<')
.fromTo('[data-hero-note]', { autoAlpha: 0 }, { autoAlpha: 1, duration: 1, ease: 'power4.inOut' }, '<50%')
.call(release, undefined, '-=0.8')
.set(scene, { clipPath: 'none' }, 3); /* the plus has swallowed the frame by t=3 — drop the clip */
}
/* scrub — the media hook gets the raw 0→1.5; the chrome is gone by OUT */
const OUT = 0.6;
const state = { progress: 0 };
gsap.matchMedia().add('(prefers-reduced-motion: no-preference)', () => {
scrub = gsap.timeline({
defaults: { ease: 'none' },
scrollTrigger: { trigger: root, start: 'top top', end: '+=55%', scrub: true },
});
scrub.to(state, { progress: 1.5, duration: 1.5, onUpdate: () => onProgress?.(state.progress) }, 0)
.to('[data-line="1"]', { xPercent: -110, duration: OUT }, 0)
.to('[data-line="2"]', { xPercent: 110, duration: OUT }, 0)
.to('[data-hero-rule]', { scaleX: 0, transformOrigin: '0 50%', duration: OUT }, 0)
.to('[data-hero-date]', { xPercent: -110, duration: OUT }, 0)
.to('[data-hero-time]', { xPercent: 110, duration: OUT }, 0)
.to('[data-hero-plus]', { rotate: 90, autoAlpha: 0, duration: OUT }, 0)
.fromTo('[data-hero-row], [data-hero-note]', { autoAlpha: 1 }, { autoAlpha: 0, duration: OUT, immediateRender: false }, 0)
/* the media fades into the night plate over the last 20% of the runway,
so the next section arrives over black, not over a frozen frame */
.to('[data-scene]', { autoAlpha: 0, duration: 0.45, ease: 'none' }, 1.05);
});
});
return {
revert() { scrub?.scrollTrigger?.kill(); scrub?.kill(); split?.revert(); },
};
}
/* ─── lib/menu.js ──────────────────────────────────────────────────────────
Full-screen menu. One fixed black panel; GSAP owns clip-path, CSS owns
layout. One toggle — the fixed burger — opens and closes it (aria-expanded
drives the bar → cross morph in CSS). Scroll stops while open; Escape closes.
open : panel clip polygon(0 0,100% 0,100% 0,0 0) → full 1s power3.inOut
nav li > * y 80 → 0, autoAlpha 0 → 1 1s power3.out, stagger .15, at "<40%"
.menu__footer y 40 → 0, autoAlpha .6s power3.inOut, at "<40%"
close: nav li > * y 0 → −80, autoAlpha → 0 .2s power2.inOut, stagger .05
.menu__footer y → 80, autoAlpha → 0 .2s power2.inOut, "<"
panel clip → top line .4s power2.out, at "<80%"; then autoAlpha 0 */
const OPEN = 'polygon(0% 100%, 100% 100%, 100% 0%, 0% 0%)';
const CLOSED = 'polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)';
function initMenu({ panel = document.querySelector('[data-menu]'), toggle = document.querySelector('[data-menu-toggle]') } = {}) {
if (!panel) return null;
let tl = null;
let isOpen = false;
const items = () => panel.querySelectorAll('nav li > *');
const footer = panel.querySelector('.menu__footer');
const setExpanded = (v) => toggle?.setAttribute('aria-expanded', v ? 'true' : 'false');
function open() {
if (isOpen) return;
isOpen = true;
setExpanded(true);
getScroll()?.stop();
panel.removeAttribute('inert');
tl?.kill();
tl = gsap.timeline({ overwrite: true })
.set(panel, { autoAlpha: 1 })
.fromTo(panel, { clipPath: CLOSED }, { clipPath: OPEN, duration: 1, ease: 'power3.inOut' })
.fromTo(items(), { y: 80, autoAlpha: 0 }, { y: 0, autoAlpha: 1, duration: 1, stagger: 0.15, ease: 'power3.out' }, '<40%')
.fromTo(footer, { autoAlpha: 0, y: 40 }, { autoAlpha: 1, y: 0, duration: 0.6, ease: 'power3.inOut' }, '<40%');
}
function close() {
if (!isOpen) return;
isOpen = false;
setExpanded(false);
getScroll()?.start();
panel.setAttribute('inert', '');
tl?.kill();
tl = gsap.timeline({ overwrite: true })
.fromTo(items(), { y: 0, autoAlpha: 1 }, { y: -80, autoAlpha: 0, duration: 0.2, stagger: { each: 0.05 }, ease: 'power2.inOut' })
.fromTo(footer, { autoAlpha: 1, y: 0 }, { autoAlpha: 0, y: 80, duration: 0.2, ease: 'power2.inOut' }, '<')
.fromTo(panel, { clipPath: OPEN }, { clipPath: CLOSED, duration: 0.4, ease: 'power2.out' }, '<80%')
.set(panel, { autoAlpha: 0 });
}
panel.setAttribute('inert', '');
toggle?.addEventListener('click', () => (isOpen ? close() : open()));
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
panel.querySelectorAll('nav a').forEach((a) => a.addEventListener('click', close));
return { open, close, get isOpen() { return isOpen; } };
}
/* ─── lib/timeline.js ──────────────────────────────────────────────────────
Rules — the reference's timeline with the year rail turned into a
speedometer. Items scatter on a 16-column grid and wipe up as they approach
the centre; the centre rail + progress line + spinning tip became one
sticky SVG: a faint arc from the bottom-left corner over the top to the
bottom-right, an accent needle from the bottom centre, the arc filling
behind the needle as the section scrolls.
Geometry (init + resize): W×H = viewport, centre (W/2, H), R = min(W/2, H − 4rem)
— on 1440×900 R = 720, the arc leaves from the corners; viewBox = "0 0 W H";
arc `M cx−R H A R R 0 0 1 cx+R H` (sweep 1 → over the top), pathLength 1;
needle = tapered polygon from the centre to the right (NEEDLE_BASE wide at the
pivot, 1px at the tip), stopping NEEDLE_GAP rem short of the arc.
Scrub: one ScrollTrigger over the sticky range (root top top → bottom bottom):
progress dashoffset 1 → 0 · needle rotate −180° ("0") → 0° ("200").
Items (desktop): clip inset(0 0 100%) → 0 + img yPercent −30 → 0, sine.inOut,
scrub top bottom → center center; hover (CSS): siblings .3, image clip inset(5%).
Mobile (<768): media scale 1 → 1.3 + title fade while the item straddles the centre. */
const NEEDLE_BASE = 3; /* half-width at the pivot, px → 6px block tapering to 1px */
const NEEDLE_GAP = 3; /* rem between the tip and the arc — the needle never touches the dial */
function initTimeline(root = document.querySelector('[data-timeline-root]')) {
if (!root) return null;
const ctx = gsap.context(() => {
/* speedometer */
const svg = root.querySelector('[data-speedo]');
const rail = root.querySelector('[data-speedo-rail]');
const progress = root.querySelector('[data-speedo-progress]');
const needle = root.querySelector('[data-speedo-needle]');
const geo = { cx: 0, cy: 0, R: 0 };
const layout = () => {
if (!svg) return;
const W = innerWidth, H = innerHeight;
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
const cx = W / 2, cy = H, R = Math.min(W / 2, H - 4 * rem);
Object.assign(geo, { cx, cy, R });
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
const d = `M ${cx - R} ${cy} A ${R} ${R} 0 0 1 ${cx + R} ${cy}`;
rail?.setAttribute('d', d);
progress?.setAttribute('d', d);
if (needle) {
const L = Math.max(R * 0.5, R - NEEDLE_GAP * rem), b = NEEDLE_BASE;
needle.setAttribute('points', `${cx},${cy - b} ${cx + L},${cy - 0.5} ${cx + L},${cy + 0.5} ${cx},${cy + b}`);
}
};
let p = 0;
const paint = () => {
if (progress) progress.style.strokeDashoffset = String(1 - p); /* straight to the style: GSAP rounds this one to whole px */
if (needle) gsap.set(needle, { rotate: -180 + 180 * p, svgOrigin: `${geo.cx} ${geo.cy}` });
};
layout(); paint();
if (svg) {
ScrollTrigger.create({
trigger: root, start: 'top top', end: 'bottom bottom', scrub: true,
onUpdate: (self) => { p = self.progress; paint(); },
onRefresh: (self) => { layout(); p = self.progress; paint(); },
});
}
if (isMobile()) {
const dim = (reveal, title, media) => {
gsap.to(title, { opacity: 0 }); gsap.to(media, { scale: 1 });
gsap.to(reveal, { opacity: 0.4, clipPath: 'inset(0% 0% 0% 0%)' });
};
const lift = (reveal, title, media) => {
gsap.to(title, { opacity: 1 }); gsap.to(media, { scale: 1.3 });
gsap.to(reveal, { opacity: 1, clipPath: 'inset(0% 0% 0% 0%)' });
};
gsap.utils.toArray('[data-timeline-item]', root).forEach((item) => {
const media = item.querySelector('[data-timeline-item-child]');
const reveal = item.querySelector('[data-timeline-item-reveal]');
const title = item.querySelector('h3');
if (!(media && reveal && title)) return;
gsap.set(reveal, { clipPath: 'inset(0% 0% 100% 0%)' });
gsap.timeline({
scrollTrigger: {
trigger: item, start: 'top center', end: 'bottom center', toggleActions: 'play none none none',
onEnter: () => lift(reveal, title, media),
onLeave: () => dim(reveal, title, media),
onEnterBack: () => lift(reveal, title, media),
onLeaveBack: () => dim(reveal, title, media),
},
})
.fromTo(media, { scale: 1 }, { scale: 1.3, duration: 0.6 }, 0)
.fromTo(title, { opacity: 0 }, { opacity: 1, duration: 0.6 }, 0);
});
return;
}
/* images wipe up as they approach the centre */
gsap.utils.toArray('[data-timeline-item]', root).forEach((item) => {
const reveal = item.querySelector('[data-timeline-item-reveal]');
const img = item.querySelector('[data-timeline-item-frame], [data-timeline-item-reveal] img, [data-timeline-item-reveal] .ph'); /* the frame carries img + raster */
if (!(reveal && img)) return;
gsap.timeline({ scrollTrigger: { trigger: item, start: 'top bottom', end: 'center center', scrub: true } })
.fromTo(reveal, { clipPath: 'inset(0% 0% 100% 0%)' }, { clipPath: 'inset(0% 0% 0% 0%)', ease: 'sine.inOut' }, 0)
.fromTo(img, { yPercent: -30 }, { yPercent: 0, ease: 'sine.inOut' }, 0);
});
const refresh = () => ScrollTrigger.refresh();
const ro = new ResizeObserver(refresh);
ro.observe(document.body); ro.observe(root);
return () => ro.disconnect();
}, root);
return ctx;
}
/* ─── lib/stack.js ─────────────────────────────────────────────────────────
Stacked slides. n absolutely-stacked slides in a sticky viewport; the
section is n×100svh tall on mobile, 100vh + pin on desktop.
Desktop: pinned (pinType transform), scrub, snap to each slide (.8s, magic).
per transition i → i+1: slide i clip inset(0 0 100% 0) (wipes up)
slide i img yPercent −15 (parallax out)
slide i+1 from yPercent 15 (parallax in)
slide i+1 dark layer autoAlpha .9 → 0
slide i+1 caption from yPercent −100
Mobile: scrub .3, no pin; media slides up (−100%) while its inner slides down (+100%),
images ±15%, caption +100%.
Order: markup is reversed in the DOM (last slide first) so z-order needs no z-index. */
function initStack(root = document.querySelector('[data-stack]')) {
if (!root) return null;
const nodes = root.querySelectorAll('[data-stack-viewport] > div');
const slides = gsap.utils.toArray(nodes).reverse();
if (slides.length < 2) return null;
root.style.setProperty('--stack-height', `${100 * slides.length}svh`);
const make = (opts) => gsap.timeline({
ease: 'none',
scrollTrigger: {
trigger: root, start: 'top top', scrub: true, invalidateOnRefresh: true,
snap: { snapTo: 1 / (slides.length - 1), duration: 0.8, ease: 'magic' },
...opts,
},
});
const mm = gsap.matchMedia();
mm.add('(min-width: 768px)', () => {
const tl = make({ end: `+=${(slides.length - 1) * 100}%`, pin: true, pinType: 'transform' });
slides.slice(0, -1).forEach((cur, i) => {
const next = slides[i + 1];
const layer = next.querySelector('[data-stack-layer]');
const img = cur.querySelector(':scope img, :scope .ph');
const caption = next.querySelector('[data-stack-caption]');
tl.to(cur, { clipPath: 'inset(0 0 100% 0)' })
.to(img, { yPercent: -15 }, '<')
.from(next, { yPercent: 15 }, '<')
.fromTo(layer, { autoAlpha: 0.9 }, { autoAlpha: 0 }, '<')
.from(caption, { yPercent: -100 }, '<');
});
});
mm.add('(max-width: 767px)', () => {
const tl = make({ end: 'bottom bottom', scrub: 0.3 });
slides.slice(0, -1).forEach((cur, i) => {
const next = slides[i + 1];
const media = cur.querySelector('[data-stack-media]');
const inner = cur.querySelector('[data-stack-media-inner]');
const img = cur.querySelector('[data-stack-media] img, [data-stack-media] .ph');
const nextImg = next.querySelector('[data-stack-media] img, [data-stack-media] .ph');
const caption = cur.querySelector('[data-stack-caption]');
tl.to(media, { yPercent: -100 })
.to(inner, { yPercent: 100 }, '<')
.to(img, { yPercent: -15 }, '<')
.from(nextImg, { yPercent: 15 }, '<')
.to(caption, { yPercent: 100 }, '<');
});
});
return mm;
}
/* ─── lib/title-mask.js ────────────────────────────────────────────────────
Wordmark filled with a picture (the footer). The reference filled an SVG
<text> with a <pattern> holding an <image> twice the pattern's height and
slid it down on scroll. Ours is turned inside out so the diamond raster can
live inside the letters: an HTML block clipped to the glyphs
(clip-path: url(#wordmark-clip)) holds a frame twice its height with the
B&W still and its raster canvas; the frame parallaxes yPercent −50 → 0
(scrub 1.25, top bottom → bottom bottom). */
function initTitleMask(marks = document.querySelectorAll('[data-wordmark]')) {
const tweens = [];
marks.forEach((mark) => {
const frame = mark.querySelector('[data-wordmark-frame]');
if (!frame) return;
tweens.push(gsap.fromTo(frame, { yPercent: -50 }, {
yPercent: 0, ease: 'none',
scrollTrigger: { trigger: mark, start: 'top bottom', end: 'bottom bottom', scrub: 1.25 },
}));
});
const ro = new ResizeObserver(() => ScrollTrigger.refresh());
ro.observe(document.body);
return () => { tweens.forEach((t) => t.scrollTrigger?.kill()); ro.disconnect(); };
}
/* ─── lib/transition.js ────────────────────────────────────────────────────
Page transition, static-site edition. The leave choreography plays, then the
browser navigates, and the next page fades its cover out on load.
leave: scrim autoAlpha 0 → .7 (.6s linear) · main y → −100 (.8s power3.inOut)
panel clip inset(100% 0 0 0) → inset(0) (.8s power3.inOut), panel shows the
destination's hero picture if the link carries data-hero-src
enter (next page, if sessionStorage says we came through a leave):
panel starts fully shown, autoAlpha → 0 (.6s linear), then ScrollTrigger.refresh() */
const KEY = 'new-12:transition';
function initTransition({ main = document.querySelector('main'), scrim = document.querySelector('[data-transition-scrim]'), panel = document.querySelector('[data-transition-panel]') } = {}) {
if (!(main && scrim && panel)) return;
const img = panel.querySelector('img');
/* enter */
if (sessionStorage.getItem(KEY) === '1') {
sessionStorage.removeItem(KEY);
gsap.set(panel, { clipPath: 'inset(0% 0% 0% 0%)', autoAlpha: 1 });
gsap.timeline({ onComplete: () => requestAnimationFrame(() => ScrollTrigger.refresh()) })
.set(main, { clearProps: 'transform' })
.set(scrim, { autoAlpha: 0 })
.to(panel, { autoAlpha: 0, duration: 0.6, ease: 'none' })
.set(panel, { clipPath: 'inset(0% 0% 100% 0%)' });
}
/* leave */
document.addEventListener('click', (e) => {
const a = e.target.closest('a[href]');
if (!a || a.target === '_blank' || a.hasAttribute('download')) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
const url = new URL(a.href, location.href);
if (url.origin !== location.origin) return;
if (url.pathname === location.pathname && url.hash) return; // in-page anchor → the scroll module
e.preventDefault();
document.documentElement.classList.add('is-changing');
if (img) {
const src = a.dataset.heroSrc;
if (src) { img.setAttribute('src', src); gsap.set(img, { autoAlpha: 1 }); }
else gsap.set(img, { autoAlpha: 0 });
}
gsap.set([scrim, panel, main], { clearProps: 'all' });
gsap.timeline({ onComplete: () => { sessionStorage.setItem(KEY, '1'); location.href = url.href; } })
.set(main, { y: 0 })
.set(panel, { autoAlpha: 1 })
.set(scrim, { autoAlpha: 0 })
.to(scrim, { autoAlpha: 0.7, duration: 0.6, ease: 'none' })
.to(main, { y: -100, duration: 0.8, ease: 'power3.inOut' }, '<')
.fromTo(panel, { clipPath: 'inset(100% 0% 0% 0%)' }, { clipPath: 'inset(0% 0% 0% 0%)', duration: 0.8, ease: 'power3.inOut' }, '<');
});
}
/* ─── lib/pill-nav.js ──────────────────────────────────────────────────────
Floating pill: a white pill fixed at the bottom centre. Home uses it as the
CTA ("Pull up" → #pull-up): hidden at the top of the page, slides in with
the first pixels of scroll (hero top passes −4%), hides while the footer is
on screen. With a [data-pill-toggle] + nav inside it Flip-expands into a
list (the reference's project switcher) — kept, unused. */
function initPillNav(root = document.querySelector('[data-pill-nav]'), { showAfter = document.querySelector('[data-hero]') } = {}) {
if (!root) return null;
const box = root.querySelector('[data-flip-id]');
const list = root.querySelector('nav');
const toggle = root.querySelector('[data-pill-toggle]');
let open = false;
/* visible from the first scroll (hero top passes 4% above the viewport) to the footer */
const footer = document.querySelector('[data-footer-spy]');
let pastHero = !showAfter, atFooter = false;
const sync = () => root.setAttribute('data-hidden', pastHero && !atFooter ? 'false' : 'true');
if (showAfter) {
ScrollTrigger.create({
trigger: showAfter, start: 'top -4%',
onEnter: () => { pastHero = true; sync(); },
onLeaveBack: () => { pastHero = false; sync(); },
});
}
if (footer) {
ScrollTrigger.create({
trigger: footer, start: 'top bottom',
onEnter: () => { atFooter = true; sync(); },
onLeaveBack: () => { atFooter = false; sync(); },
});
}
if (!(toggle && list && box)) return { get isOpen() { return false; } };
toggle.addEventListener('click', () => {
open = !open;
const state = Flip.getState(box);
root.toggleAttribute('data-open', open);
if (open) list.removeAttribute('inert'); else list.setAttribute('inert', '');
Flip.from(state, { duration: 0.6, ease: 'magic', absolute: false });
gsap.fromTo(list.querySelectorAll('li'), { autoAlpha: open ? 0 : 1, x: open ? -16 : 0 }, { autoAlpha: open ? 1 : 0, x: 0, duration: 0.4, stagger: 0.04, ease: 'magic' });
});
return { get isOpen() { return open; } };
}
/* ─── main.js ──────────────────────────────────────────────────────────────
Entry. Order matters: eases/plugins → scroll (owner of Lenis) → split
(needs fonts) → spies → sections. Every module is a no-op when its markup
is absent. */
const scroll = initScroll();
initSplit();
initImageLoad();
initFooterSpy();
const heroVideo = initHeroVideo();
/* diamond raster over the clip and the Rules plates.
Roll back: set FX = false — or open the page with ?fx=off to compare — and the hero is the
plain colour clip again, the plates static B&W stills */
const FX = new URLSearchParams(location.search).get('fx') !== 'off';
const heroFx = FX ? initHeroFx({ video: heroVideo.video, ready: heroVideo.ready, onFrame: heroVideo.onFrame }) : null;
const hero = initHero({
/* the scroll-scrubbed hero video: 0→1.5 from the stage becomes 0→1 of the clip;
the intro waits for the raster's own trailing source too (each has a 4s timeout) */
ready: Promise.all([heroVideo.ready, heroFx?.ready ?? Promise.resolve()]),
onProgress: (p) => {
document.documentElement.style.setProperty('--hero-p', p.toFixed(3));
heroVideo.scrub(Math.min(1, Math.max(0, p / 1.5)));
heroFx?.progress(p);
},
});
const menu = initMenu();
initTimeline();
const plates = FX ? initPlateFx() : [];
initStack();
initTitleMask();
initTransition();
initPillNav();
/* dev hook (house convention) */
window.__site = {
scroll, gsap, ScrollTrigger, menu, hero, heroVideo, fx: heroFx, plates, replay,
refresh: () => ScrollTrigger.refresh(),
drive: (y) => getScroll()?.scrollTo(y, { immediate: true }),
state: () => ({
y: scroll?.lenisInstance?.scroll,
stopped: scroll?.lenisInstance?.isStopped,
inview: document.querySelectorAll('.is-inview').length,
triggers: ScrollTrigger.getAll().length,
video: heroVideo.video && { t: +heroVideo.video.currentTime.toFixed(3), d: heroVideo.video.duration, seeking: heroVideo.video.seeking, ready: heroVideo.video.readyState },
}),
};
</script>
</body>
</html>[/hidecontent]