类型:Site模板 · 收录:2026-09-27 11:09:42 · 源码:会员可见
Build this premium car-rental landing page as a Vite project (vanilla JavaScript, no framework) with a proper, production-ready setup. Recreate it EXACTLY as specified below: same markup, copy, CSS, scroll choreography, animations, fonts and mobile layout. All images and the video load from the CloudFront URLs given; the project must contain NO local image or video files (no /public media, no /src/assets media). Follow every number literally.
═══════════════════════════════════════
0. CONCEPT
═══════════════════════════════════════
A white page from the first pixel; the hero's black is the video itself, full-bleed. One sticky "scene" sits under every section and holds the media. On scroll, the hero video is SCRUBBED (never played): it shrinks from full-screen cover into an empty grid slot on screen 2, then glides to the bottom-centre, its last frame dissolves (blur) into a transparent PNG cut-out of the same car, four more car cut-outs materialise beside it as a row, the row rises over a statement that reveals word by word from blur, the centre car zooms to full width, and five light-grey widget cards rise from the bottom (arrows, name/description, location pills, price). The arrows (and ← → keys) switch cars in a carousel. The fixed header is the only thing that flips colour (white over the video → black over the white page). One font (Inter Tight 400/500), no shadows, no borders, no uppercase, letter-spacing never positive. Everything appears through blur and settles with expo-out; everything scroll-driven tracks the finger 1:1 (ease none).
═══════════════════════════════════════
1. PROJECT SETUP (Vite)
═══════════════════════════════════════
Scaffold: npm create vite@latest avelon-drive-landing -- --template vanilla
Remove the template's demo files (counter.js, javascript.svg, public/vite.svg, the default style.css content).
Install (pin these exact versions):
npm i gsap@3.13.0 locomotive-scroll@5.0.0 bootstrap-icons@1.13.1
package.json scripts: "dev": "vite", "build": "vite build", "preview": "vite preview"; "type": "module".
File tree:
index.html
vite.config.js
package.json
.gitignore (node_modules, dist, .DS_Store)
src/
main.js entry: imports CSS + every module, boots in the order in §8
style.css all CSS (§4–§6)
lib/
gsap.js plugin registration + custom eases + reduced/isMobile
beats.js beat table, N = 5, applyBeats()
vars.js setVar / setClass / clamp01
scroll.js Locomotive Scroll (Lenis) + anchor glide
split.js SplitText for [data-split]
pol.js header polarity scalar
header.js header entrance
hero-video.js seek-coalescing video scrubber
hero.js hero intro + scrubbed exit
about.js screen 2 in/out
marquee.js brand marquee
media.js the scene timeline (plate, row, zoom, setCur)
words.js word-by-word statement
catalog.js widgets fill/swap/entrance + words triggers
slider.js arrows / keys carousel
vite.config.js:
import { defineConfig } from 'vite';
export default defineConfig({ build: { target: 'es2022', cssTarget: 'chrome111' } });
(cssTarget keeps color-mix(), cos() in calc(), @layer, svh/lvh and CSS nesting intact.)
Imports (npm, not CDN):
src/lib/gsap.js:
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText } from 'gsap/SplitText';
import { Flip } from 'gsap/Flip';
import { CustomEase } from 'gsap/CustomEase';
src/lib/scroll.js: import LocomotiveScroll from 'locomotive-scroll';
src/main.js (first lines): import 'bootstrap-icons/font/bootstrap-icons.min.css'; import './style.css';
index.html <head> (exact):
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>brand — premium car rental, New York</title>
<meta name="description" content="The car you want, at your door. Premium rental in New York — no counters, no paperwork.">
<meta name="theme-color" content="#ffffff">
<script>document.documentElement.classList.add('js')</script> ← inline, BEFORE any CSS (the hidden pre-intro states key off html.js)
<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">
Before </body>: <script type="module" src="/src/main.js"></script>
═══════════════════════════════════════
2. MEDIA (exact CloudFront URLs — use as-is in the markup)
═══════════════════════════════════════
- Hero video (all-intra H.264, 1920×1080, 24 fps, 8.04 s; every frame is a keyframe so seeks are instant; CloudFront serves CORS + byte ranges):
https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/58a84706-cc4c-494c-a1e6-13c8469f128d.mp4
- Video poster (frame 0):
https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/d95584a7-788a-44d7-9599-b02bd19aa092.jpg
- Car cut-outs (1920×1080 transparent PNG, same framing as the video's last frame: car ≈69% of width, centred .50/.52):
car 0 Mercedes S-Class (lands on the video's last frame):
https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/ff5d729f-94d6-4a74-808b-a27bd264a768.png
car 1 Porsche 911 Carrera:
https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/f999be6e-5e04-4f8e-80c4-9f36260a00de.png
car 2 Range Rover Autobiography:
https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/48d9cc8e-60cb-430a-9e36-ccd22949fa04.png
car 3 Bentley Continental GT:
https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/9ce67fcf-e0d4-437e-ab0e-e07216fc9ffe.png
car 4 BMW 7 Series:
https://d2ol7oe51mr4n9.cloudfront.net/user_3GJaYKPxdnQG0Q9O26lu6DPmcHu/06b0cd99-f8fb-4a66-a254-eb8daee3e4ed.png
The <video> has: muted playsinline preload="auto" crossorigin="anonymous" poster="…" src="…" — no autoplay, no loop, no controls.
═══════════════════════════════════════
3. BODY MARKUP (index.html — exact structure, classes, data-attributes, copy)
═══════════════════════════════════════
<header class="header pol" data-header>
<div class="header__bar">
<a class="header__logo t-ui" href="#top" data-header-item aria-label="brand — home">
<svg class="header__mark" viewBox="0 0 24 32" aria-hidden="true"><rect x="9" y="3" width="12" height="26" rx="5.6" transform="skewX(-14)"/></svg>
<span>brand</span>
</a>
<nav class="header__nav t-ui" aria-label="Primary">
<span class="header__ghost" data-header-item aria-hidden="true">Overview</span>
<a class="link-dim" href="#catalog" data-header-item>Fleet</a>
<a class="link-dim" href="#about" data-header-item>Experience</a>
<a class="link-dim" href="#top" data-header-item>Contact</a>
</nav>
<a class="btn btn--ink header__cta" href="#catalog" data-header-item>Reserve</a>
<button class="btn btn--ink btn--icon header__menu" type="button" data-header-item aria-label="Menu"><i class="bi bi-list" aria-hidden="true"></i></button>
</div>
</header>
<main id="top">
<div class="stage" data-stage>
<div class="stage__scene" data-scene>
<p class="catalog__words t-statement" data-words>A private collection, not a car park: S-Class to Continental GT, each car detailed, insured and at your door within the hour, anywhere in New York.</p>
<div class="row" data-row aria-hidden="true">
<div class="car media" data-car="0" data-media>
<video class="media__video" data-hero-video muted playsinline preload="auto" crossorigin="anonymous" poster="[POSTER URL]" src="[VIDEO URL]"></video>
<img class="media__cut" data-cut src="[CAR 0 URL]" alt="" decoding="async">
</div>
<div class="car" data-car="1"><img class="car__cut" src="[CAR 1 URL]" alt="" loading="lazy" decoding="async"></div>
<div class="car" data-car="2"><img class="car__cut" src="[CAR 2 URL]" alt="" loading="lazy" decoding="async"></div>
<div class="car" data-car="3"><img class="car__cut" src="[CAR 3 URL]" alt="" loading="lazy" decoding="async"></div>
<div class="car" data-car="4"><img class="car__cut" src="[CAR 4 URL]" alt="" loading="lazy" decoding="async"></div>
</div>
</div>
<section class="hero" data-hero aria-label="Intro">
<div class="hero__side">
<span class="pill hero__pill t-caption" data-hero-ui><i class="bi bi-geo-alt-fill" aria-hidden="true"></i>New York</span>
<p class="hero__desc t-body" data-hero-ui>The car you want, at your door. No counters, no paperwork — just the keys.</p>
</div>
<p class="hero__hint t-ui" data-hero-ui>Scroll <i class="bi bi-arrow-down" aria-hidden="true"></i></p>
<h1 class="hero__title t-display" data-hero-title>
<span class="hero__ghost">Premium</span>
<span class="hero__main">Car<br>Rental</span>
</h1>
</section>
<section class="about" id="about" data-about aria-label="About">
<div class="about__pin">
<div class="about__slot" data-media-slot aria-hidden="true"></div>
<div class="about__copy">
<h2 class="about__statement t-statement" data-about-in data-split="lines">It's not about getting there. It's about how you arrive. Your car meets you at the door — fuelled, detailed, silent.</h2>
<a class="btn btn--soft" href="#catalog" data-about-in style="--in-y: 14svh">Reserve</a>
</div>
<div class="marquee about__marquee" data-marquee data-about-in data-scroll aria-label="Brands we work with">
<div class="marquee__track">
8 × <span class="marquee__item t-caption">…</span> in this order: Mercedes-Benz, Porsche, Range Rover, BMW, Bentley, Aston Martin, Maserati, Lexus
</div>
</div>
</div>
</section>
<section class="catalog" id="catalog" data-catalog aria-label="Cars">
<div class="catalog__screen">
<div class="widgets" data-widgets aria-label="Selected car">
<button class="widget widget--arrow" type="button" data-prev aria-label="Previous car"><i class="bi bi-chevron-left" aria-hidden="true"></i></button>
<div class="widget widget--main">
<h3 class="widget__name t-statement" data-w-name>Mercedes S-Class</h3>
<div class="widget__row">
<p class="widget__desc t-body-sm is-dim" data-w-desc>Long-wheelbase calm. The city goes quiet behind its doors.</p>
<a class="btn btn--card" href="#catalog">Specifications</a>
</div>
</div>
<div class="widget widget--loc">
<span class="pill pill--card t-caption"><i class="bi bi-geo-alt-fill" aria-hidden="true"></i><span data-w-loc>New York</span></span>
<span class="pill pill--card t-caption" data-w-extra>+3</span>
</div>
<div class="widget widget--price">
<span class="t-body-sm is-dim">Per day</span>
<strong class="widget__price t-price" data-w-price>$300</strong>
<a class="btn btn--ink" href="#catalog">Reserve</a>
</div>
<button class="widget widget--arrow" type="button" data-next aria-label="Next car"><i class="bi bi-chevron-right" aria-hidden="true"></i></button>
</div>
</div>
</section>
</div>
</main>
<script id="cars" type="application/json">
[
{"name":"Mercedes S-Class","desc":"Long-wheelbase calm. The city goes quiet behind its doors.","price":"$300","location":"New York","extra":3},
{"name":"Porsche 911 Carrera","desc":"The rear-engine icon. Still analogue where it matters.","price":"$420","location":"New York","extra":2},
{"name":"Range Rover Autobiography","desc":"Air-sprung serenity. The traffic passes somewhere below.","price":"$360","location":"Hamptons","extra":4},
{"name":"Bentley Continental GT","desc":"Twelve cylinders, hand-stitched leather. Grand touring, unhurried.","price":"$690","location":"Brooklyn","extra":1},
{"name":"BMW 7 Series","desc":"A quiet cabin, a theatre in the back. Long legs for the highway.","price":"$280","location":"New York","extra":3}
]
</script>
═══════════════════════════════════════
4. src/style.css — TOKENS, BASE, COMPONENTS
═══════════════════════════════════════
First line: @layer reset, tokens, base, layout, components, motion, utilities;
Unlayered metric fallback: @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: *, html,body {width:100%; overscroll-behavior:none} · img,svg,video,canvas,iframe {display:block; max-width:100%} · img,video {height:auto} · button,input,select,textarea {font:inherit; letter-spacing:inherit; color:inherit; background:none} · button {cursor:pointer} · ul,ol {list-style:none} · a {color:inherit; text-decoration:none} · h1–h6 {font-size:inherit; font-weight:inherit} · :focus:not(:focus-visible){outline:none} · :focus-visible {outline:2px solid var(--ink); outline-offset:3px}
@layer tokens — :root:
--k:#000; --w:#fff; --soft:#f1f1f1;
--w-text:400; --w-ui:500;
--fs-display:2.75rem; --fs-statement:1.75rem; --fs-price:1.5rem; --fs-body:1.125rem; --fs-body-sm:.9375rem; --fs-ui:1rem; --fs-caption:.875rem;
@media (min-width:48rem) { :root { --fs-display:4.5rem; --fs-statement:2.5rem } }
--lh-display:.95; --lh-statement:1.15; --lh-price:1; --lh-body:1.3; --lh-body-sm:1.25; --lh-ui:1; --lh-caption:1.2;
--ls-display:-.04em; --ls-statement:-.03em; --ls-price:-.02em; --ls-body:0; --ls-ui:-.01em; --ls-caption:0;
--radius:12px; --radius-card:1.5rem; --btn-h:2.75rem; --glass-blur:12px;
--ease-magic:cubic-bezier(.33,0,0,1); --ease-out-expo:cubic-bezier(.19,1,.22,1); --ease-expo:cubic-bezier(.16,1,.3,1); --ease-quart:cubic-bezier(.41,.35,.2,1); --ease-out:cubic-bezier(0,0,.2,1);
--dur-tap:.2s; --split-mask-pad:.08em; --split-mask-bleed-y:.3em;
--pad-x:clamp(1rem, 2.18vw, 2.5rem); --gap-grid:1.25rem;
--plate-w:100vw; --plate-h:56.25vw; --card-w:33.5vw; --s2:.335; --pitch-row:28.57vw; --pitch-full:72vw; --row-dy:44svh; --par-dy:46svh; --full-dy:44svh;
nested inside :root → @media (max-width:47.99rem) { & { --card-w:58vw; --s2:.58; --pitch-row:50vw; --pitch-full:100vw; --row-dy:42svh; --par-dy:40svh; --full-dy:22svh } }
--hold:60svh; --about-exit:50svh; --cat-run:220svh; --z-header:60; (JS overwrites the three section tokens)
--ink:var(--k); --plate:var(--w);
--ink-55 / --ink-40 / --ink-20 / --ink-10 / --ink-06: color-mix(in srgb, var(--ink) 55|40|20|10|6%, transparent);
--glass: color-mix(in srgb, var(--ink) 8%, transparent); --glass-hover: color-mix(in srgb, var(--ink) 14%, transparent);
Polarity (declared on .pol, NEVER on :root):
.pol { --pol:0; --ink:color-mix(in srgb, var(--w), var(--k) calc(var(--pol) * 100%)); --plate:color-mix(in srgb, var(--k), var(--w) calc(var(--pol) * 100%)); + re-declare --ink-55/40/20/10/06, --glass, --glass-hover from this --ink exactly as above }
@layer base:
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; background:var(--w)}
body {font-family:'Inter Tight','Inter Tight Fallback',-apple-system,BlinkMacSystemFont,sans-serif; font-weight:400; line-height:1.5; background-color:#fff; color:#000}
::selection {background-color:var(--ink); color:var(--plate)}
Lenis CSS: html.lenis, html.lenis body {height:auto} · .lenis:not(.lenis-autoToggle).lenis-stopped {overflow:clip} · .lenis [data-lenis-prevent] {overscroll-behavior:contain} · .lenis.lenis-smooth iframe {pointer-events:none}
@layer components:
Type roles — .t-display / .t-statement / .t-price / .t-body / .t-body-sm / .t-ui / .t-caption each set font-size, line-height, letter-spacing from their tokens; weight 500 for display, statement, price, ui, caption; 400 for body, body-sm; .t-price adds font-variant-numeric:tabular-nums.
.is-dim {color:var(--ink-55)} · .link-dim {transition:opacity .2s} · @media (hover:hover) and (pointer:fine) {.link-dim:hover {opacity:.6}}
.btn {position:relative; display:inline-flex; align-items:center; justify-content:center; gap:.5rem; height:var(--btn-h); padding-inline:1.25rem; border-radius:var(--radius); font-size:var(--fs-ui); font-weight:500; letter-spacing:-.01em; line-height:1; white-space:nowrap; color:var(--ink); transition:background-color .2s var(--ease-magic), color .2s var(--ease-magic), transform .15s var(--ease-out)} · .btn:active {transform:scale(.97)}
.btn--ink {background:var(--ink); color:var(--plate)} · .btn--soft {background:#f1f1f1; color:#000} · .btn--glass {background:var(--glass); backdrop-filter:blur(12px)} · .btn--icon {width:var(--btn-h); padding:0} · .btn .bi {font-size:1.2em; line-height:1; display:inline-flex}
hover (hover:hover and pointer:fine): .btn--ink → background:color-mix(in srgb, var(--ink) 86%, var(--plate)); .btn--soft → #e8e8e8; .btn--glass → var(--glass-hover)
.pill {display:inline-flex; align-items:center; gap:.4em; height:2rem; padding-inline:.8em; border-radius:999px; background:var(--glass); backdrop-filter:blur(12px); white-space:nowrap} · .pill .bi {font-size:.95em}
@supports (corner-shape: squircle) { .btn, .widget { corner-shape:squircle } }
.marquee {overflow:hidden; padding-block:1.5rem; mask-image:linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent)} (+ -webkit-mask-image)
.marquee__track {display:flex; width:max-content; gap:5.5rem; padding-right:5.5rem; animation:marquee var(--marquee-dur, 40s) linear infinite; animation-play-state:paused; will-change:transform} · .marquee.is-inview .marquee__track {animation-play-state:running}
.marquee__item {color:var(--ink-40); white-space:nowrap; display:inline-flex; align-items:center; gap:.4em; letter-spacing:-.01em}
@keyframes marquee {from {transform:translate3d(0,0,0)} to {transform:translate3d(-50%,0,0)}}
.header {position:fixed; inset-inline:0; top:0; z-index:60; pointer-events:none; color:var(--ink)}
.header__bar {display:flex; align-items:center; gap:2rem; padding:1.6rem var(--pad-x); pointer-events:auto}
.header__logo {display:inline-flex; align-items:center; gap:.7rem; font-size:1.375rem; letter-spacing:-.02em} · .header__mark {width:.75em; height:1em; fill:currentColor}
.header__nav {display:flex; align-items:center; gap:2rem; margin-left:auto; margin-right:clamp(3rem, 8vw, 8.5rem)} · .header__ghost {color:var(--ink-40); margin-right:clamp(2rem, 5vw, 5rem)} · .header__menu {display:none}
@media (max-width:47.99rem) { .header__nav {display:none} .header__bar {gap:.5rem} .header__cta {margin-left:auto} .header__menu {display:inline-flex} }
html.js .header, html.js .hero {visibility:hidden}
@layer utilities: .sr-only {position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); white-space:nowrap}
═══════════════════════════════════════
5. src/style.css — @layer layout
═══════════════════════════════════════
.stage {position:relative}
.stage__scene {position:sticky; top:0; height:100lvh; margin-bottom:-100lvh; overflow:hidden; z-index:0; background:#fff}
.stage > section {position:relative; z-index:1}
.row {position:absolute; inset:0; --rin:0; --par:0; --zoom:0}
THE PLATE — every car is the same 16:9, 100vw-wide box centred on the viewport; all motion is one chained transform (copy exactly):
.car {
position:absolute; left:50%; top:50%;
width:var(--plate-w); height:var(--plate-h);
margin:calc(var(--plate-h) / -2) 0 0 calc(var(--plate-w) / -2);
transform-origin:50% 50%;
--g1:0; --g2:0; --d:0; --vis:1;
--x0:0px; --y0:0px; --s0:1; --x1:0px; --y1:0px; --s1:1; --cx1:0%; --cy1:0%;
--y2:var(--row-dy); --y3:var(--full-dy); --dir:0; --rise:1;
--pitch:calc(var(--pitch-row) + (var(--pitch-full) - var(--pitch-row)) * var(--zoom));
--hump:calc((1 - cos(clamp(-1, var(--d), 1) * 3.14159rad)) * .5);
--sx:calc((var(--s0) + (var(--s1) - var(--s0)) * var(--g1) + (var(--s2) - var(--s1)) * var(--g2) + (1 - var(--s2)) * var(--zoom)) * (.92 + .08 * var(--vis)) * (1 - var(--zoom) * var(--hump) * .03));
--tx:calc(var(--x0) + (var(--x1) - var(--x0)) * var(--g1) + (0px - var(--x1)) * var(--g2) - var(--d) * var(--pitch) - var(--dir) * (1 - var(--rin)) * 12vw - (.5 - var(--par)) * var(--d) * 3vw);
--ty:calc(var(--y0) + (var(--y1) - var(--y0)) * var(--g1) + (var(--y2) - var(--y1)) * var(--g2) + (var(--y3) - var(--y2)) * var(--zoom) + var(--rise) * (1 - var(--rin)) * 6svh - var(--par) * var(--par-dy) + var(--zoom) * var(--hump) * 1svh);
--gc:calc(var(--g1) * (1 - var(--g2)));
transform:translate(var(--tx), var(--ty)) scale(var(--sx));
clip-path:inset(calc(var(--gc) * var(--cy1)) calc(var(--gc) * var(--cx1)) round calc(var(--g1) * var(--radius-card) / var(--s1)));
will-change:transform;
}
.media {--dis:0; --rise:0; --x0:0px; --y0:0px}
.car:not(.media) {--x0:0px; --y0:var(--row-dy); --s0:var(--s2); --x1:0px; --y1:var(--row-dy); --s1:var(--s2); --g1:1; --g2:1}
.media__video, .media__cut, .car__cut {position:absolute; inset:0; width:100%; height:100%; object-fit:cover}
.media__video {opacity:calc(1 - var(--dis)); background:#000}
.media__cut {opacity:var(--dis)}
.car:not(.media) {opacity:calc(var(--rin) * var(--vis))}
.media {opacity:var(--vis)}
.car.is-hidden {visibility:hidden}
HERO:
.hero {position:relative; height:100lvh; pointer-events:none; color:#fff; --ink:#fff; --ink-40:rgba(255,255,255,.4); --glass:rgba(255,255,255,.12)} · .hero > * {pointer-events:auto}
.hero__side {position:absolute; left:var(--pad-x); top:50%; display:flex; flex-direction:column; align-items:flex-start; gap:2.5rem; max-width:19em; translate:0 -50%}
.hero__desc {text-wrap:pretty}
.hero__hint {position:absolute; left:var(--pad-x); bottom:2.25rem; display:inline-flex; align-items:center; gap:.5rem} · .hero__hint .bi {font-size:1.05em}
.hero__title {position:absolute; left:var(--title-left, 54vw); right:var(--pad-x); bottom:2.25rem; display:flex; align-items:flex-end; justify-content:space-between; gap:1.2em; white-space:nowrap}
(JS sets --title-left to the left edge of the header's "Overview", so "Premium" starts under it and "Car / Rental" sits on the right margin.)
.hero__ghost {color:var(--ink-40)} · .hero__main {display:block}
@media (max-width:47.99rem) { .hero__title {left:var(--pad-x)} .hero__hint {display:none} .hero__side {top:44%; max-width:17em} }
ABOUT:
.about {height:calc(100svh + var(--hold) + var(--about-exit))}
.about__pin {position:sticky; top:0; height:100svh; display:grid; grid-template-columns:repeat(12, minmax(0,1fr)); gap:var(--gap-grid); padding-inline:var(--pad-x); pointer-events:none} · .about__pin > * {pointer-events:auto}
.about__slot {grid-column:1 / 7; margin-top:20.4svh; height:47.9svh; pointer-events:none} (never painted; JS measures it)
.about__copy {grid-column:7 / 13; margin-top:20.4svh; height:47.9svh; display:flex; flex-direction:column; justify-content:space-between; align-items:flex-start}
.about__statement {text-wrap:pretty; max-width:15.5em}
.about__marquee {position:absolute; inset-inline:0; bottom:3.2svh}
@media (max-width:47.99rem) { .about__pin {align-content:start; row-gap:0} .about__slot {grid-column:1 / -1; margin-top:12svh; height:52vw} .about__copy {grid-column:1 / -1; margin-top:2.5svh; height:auto; justify-content:flex-start; gap:1.25rem} .about__statement {max-width:none} }
CATALOG:
.catalog {height:calc(100svh + var(--cat-run))}
.catalog__screen {position:sticky; top:0; height:100svh; pointer-events:none} · .catalog__screen > * {pointer-events:auto}
.catalog__words {position:absolute; left:50%; top:47svh; width:41.5vw; max-width:44rem; text-align:center; text-wrap:balance; pointer-events:none; z-index:0; color:#000}
@media (max-width:47.99rem) { .catalog__words {width:88vw; top:40svh} }
.widgets {position:absolute; inset-inline:var(--pad-x); bottom:3.4svh; display:flex; gap:10px; height:min(180px, 18.8svh)}
.widget {background:#f1f1f1; color:#000; border-radius:var(--radius-card); display:flex; flex-direction:column; justify-content:center; align-items:center; gap:.6rem}
.widget--arrow {flex:0 0 90px; font-size:1.4rem; color:#000; transition:background-color .2s var(--ease-magic); cursor:pointer} · hover (fine pointer) {background:#e8e8e8} · .widget--arrow:active {transform:scale(.98)}
.widget--main {flex:1 1 auto; align-items:stretch; justify-content:space-between; padding:1.7rem 1.8rem 1.5rem; gap:1rem}
.widget__row {display:flex; align-items:flex-end; justify-content:space-between; gap:2rem}
.widget__desc {max-width:20em; text-wrap:balance}
.widget--loc {flex:0 0 156px} · .widget--price {flex:0 0 160px; gap:.35rem} · .widget__price {margin-bottom:.45rem}
.btn--card {background:#e2e2e2; color:#000} · hover {background:#d8d8d8}
.pill--card {background:#e2e2e2; backdrop-filter:none; color:#000}
@media (max-width:47.99rem) {
.widgets {flex-wrap:wrap; height:auto; bottom:2svh; gap:8px}
.widget--main {order:1; flex-basis:100%; padding:1.2rem 1.25rem 1.1rem; gap:.8rem}
.widget--main .widget__row {align-items:flex-end; gap:1rem}
.widget--loc, .widget--price {order:2; flex:1 1 calc(50% - 4px); height:auto; padding:1rem .75rem; gap:.3rem}
.widget--loc {flex-direction:row; flex-wrap:wrap; gap:.4rem}
.widget__price {margin-bottom:.2rem}
.widget--arrow {order:3; flex:1 1 calc(50% - 4px); height:3.25rem; font-size:1.25rem}
}
═══════════════════════════════════════
6. src/style.css — @layer motion (custom-property contract)
═══════════════════════════════════════
JS writes 0→1 custom properties ON the elements (rounded to 3 decimals, skipped if unchanged); CSS derives opacity / translate / blur. Blur applies ONLY while a state class is on (.is-anim / .is-dissolving / .is-words), toggled when the value is strictly between 0 and 1.
[data-hero-ui], [data-hero-title] {--in:1; --out:0} · html.js [data-hero-ui], html.js [data-hero-title] {--in:0}
[data-hero-ui] {opacity:calc(var(--in) * (1 - var(--out))); translate:0 calc((1 - var(--in)) * 1.25rem - var(--out) * 1em)} · [data-hero-ui].is-anim {filter:blur(calc((1 - var(--in)) * 8px))}
[data-hero-title] {opacity:calc(var(--in) * (1 - var(--out))); translate:0 calc((1 - var(--in)) * .2em - var(--out) * 4svh)} · [data-hero-title].is-anim {filter:blur(calc((1 - var(--in)) * 14px + var(--out) * 16px))}
[data-about-in] {--in:0; --out:0; --in-y:20svh} · html:not(.js) [data-about-in] {--in:1}
[data-about-in]:not(.about__statement) {opacity:calc(clamp(0, var(--in) / .6, 1) * (1 - var(--out))); translate:0 calc((1 - var(--in)) * var(--in-y) - var(--out) * 1.5rem)}
.about__statement {opacity:calc(1 - var(--out)); translate:0 calc(0px - var(--out) * 2rem)} · .about__statement.is-anim {filter:blur(calc(var(--out) * 10px))}
.about__statement .line {--li:clamp(0, calc((var(--in) * 1.5 - (var(--line, 1) - 1) * .14) / 1), 1); translate:0 calc((1 - var(--li)) * 1.15em); opacity:var(--li)}
.about__marquee {--in-y:14svh}
.media.is-dissolving .media__video {filter:blur(calc(var(--dis) * 24px))}
.media.is-dissolving .media__cut {filter:blur(calc((1 - var(--dis)) * 12px))}
.row.is-anim .car:not(.media) {filter:blur(calc((1 - var(--rin)) * 12px))}
[data-words] {--p:0; --out:0; --n:20; opacity:calc(1 - var(--out)); translate:-50% calc(-50% - var(--out) * 16px)} · [data-words].is-anim {filter:blur(calc(var(--out) * 10px))}
[data-words] .word {--w:clamp(0, calc((var(--p) * (var(--n) + 1) - (var(--word, 1) - 1)) / 2), 1); opacity:calc(.12 + .88 * var(--w)); translate:0 calc((1 - var(--w)) * .35em)}
[data-words].is-words .word {filter:blur(calc((1 - var(--w)) * 8px))} · [data-words].is-hidden {visibility:hidden}
.car.is-anim {filter:blur(calc((1 - var(--vis)) * 6px))}
.about__marquee {translate:0 calc((1 - var(--in)) * var(--in-y) + var(--out) * 1.5rem)}
SplitText masks: .line-mask {display:block} · .char-mask, .word-mask, .line, .word, .char {display:inline-block} · .word-mask, .char-mask {padding-right:.08em; margin-right:-.08em} · .line-mask, .word-mask, .char-mask {clip-path:inset(-.3em 0); overflow:visible !important}
Unlayered, at the end — @media (prefers-reduced-motion: reduce): [data-hero-ui],[data-hero-title],[data-about-in] {--in:1 !important; --out:0 !important; filter:none !important; translate:none !important} · [data-words] {--p:1 !important; --out:0 !important; filter:none !important} · [data-words] .word, .about__statement .line {filter:none !important; translate:none !important; opacity:1 !important} · .media__video, .media__cut, .car {filter:none !important} · .widget {translate:none !important; opacity:1 !important} · .marquee__track {animation:none !important} · .btn, .link-dim {transition:none} · html.js .header, html.js .hero {visibility:visible}
═══════════════════════════════════════
7. src/lib — MODULES (ES modules, named exports)
═══════════════════════════════════════
gsap.js
gsap.registerPlugin(ScrollTrigger, SplitText, Flip, CustomEase);
CustomEase.create('magic','0.33, 0, 0, 1'); ('quart','0.41, 0.35, 0.2, 1'); ('outExpo','0.19, 1, 0.22, 1'); ('base','0.46, 0.03, 0.12, 1'); ('expoOut','0.16, 1, 0.3, 1');
export const reduced = matchMedia('(prefers-reduced-motion: reduce)').matches;
export const isMobile = () => matchMedia('(max-width: 767px)').matches;
export { gsap, ScrollTrigger, SplitText, Flip, CustomEase };
vars.js
clamp01(v); setVar(el, name, v, unit='') → s = (+v).toFixed(3)+unit, memoised on el.__vars, el.style.setProperty only when changed; setClass(el, cls, on) → memoised on el.__cls, classList.toggle.
beats.js (H = window.innerHeight)
export const N = 5;
DESKTOP = { hold:60, aboutExit:50, catRun:220, tail:0, videoEnd:1.6, heroOutTitle:.25, heroOutUi:.18, g1:.7, pol:[.12,.22], aboutIn:[.35,1] }
MOBILE = { ...DESKTOP, hold:40, aboutExit:40, videoEnd:1.4 }
export beats() → isMobile() ? MOBILE : DESKTOP
export applyBeats() → on documentElement.style: --hold = hold+'svh', --about-exit = aboutExit+'svh', --cat-run = `calc(${catRun}svh + 0svh + ${tail}svh)`.
scroll.js
instance = new LocomotiveScroll({ lenisOptions:{ lerp:0.07, wheelMultiplier:0.85, touchMultiplier:1.3, syncTouch:false }, initCustomTicker:(r) => gsap.ticker.add(r), destroyCustomTicker:(r) => gsap.ticker.remove(r) });
history.scrollRestoration = 'manual'; window.scrollTo(0,0);
instance.lenisInstance.on('scroll', ScrollTrigger.update); gsap.ticker.lagSmoothing(0);
Anchor glide: document click on a[href*="#"] pointing to an existing same-page id → preventDefault, instance.scrollTo(hash, { duration:2 }); if location.hash on load → rAF → same.
Locomotive's IntersectionObserver adds .is-inview to [data-scroll] (that starts the marquee).
export initScroll(), getScroll().
split.js
After document.fonts.ready → rAF: for each [data-split] → new SplitText(el, { tag:'span', type:'lines', mask:'lines', linesClass:'line', wordsClass:'word', charsClass:'char', propIndex:true, autoSplit:true, onSplit: queue ONE ScrollTrigger.refresh() in rAF }). (propIndex gives each line --line: 1, 2, 3…)
pol.js
setPol(v) → setVar on every .pol element, '--pol', v.
header.js
playIntro(): gsap.fromTo([data-header-item], { y:'1em', autoAlpha:0 }, { y:0, autoAlpha:1, duration:.6, ease:'magic', stagger:.08, clearProps:'transform,opacity,visibility' }). Reduced → skip.
hero-video.js — seek-coalescing scrubber
let pending=null, seeking=false.
applyNext() (also bound to 'seeked'): if pending===null → seeking=false, return; t=pending; pending=null; if |currentTime−t| < .01 → seeking=false, return; seeking=true; currentTime=t.
scrub(p01): if reduced or !duration or readyState<1 return; pause if playing; target = min(d−.001, max(0, p01·d)); if !seeking && |currentTime−target| < .03 return; pending=target; if !seeking applyNext().
ready (Promise): on loadedmetadata → pause, currentTime=.001; on canplay → warm the decoder: seek to min(.12, duration·.03), on that seeked → currentTime=.001 and resolve; also resolve on 'error' and after 4000 ms. Handle readyState already ≥1 / ≥3.
export initHeroVideo() → { scrub, ready, video }.
hero.js
--title-left: r = header .header__ghost getBoundingClientRect(); if r.width>0 hero.style '--title-left' = Math.round(r.left)+'px' else remove it. Run now, on fonts.ready, and on ScrollTrigger 'refreshInit'.
Intro — after Promise.all([document.fonts.ready, heroVideo.ready]) → rAF → start():
reduced → set every --in 1, show, return.
set --in 0 and .is-anim on the title + the 3 [data-hero-ui]; header.playIntro();
timeline: title --in 0→1 at 0.15 s, 1.4 s 'outExpo'; UI item i --in 0→1 at 0.45 + i·0.09 s, 0.9 s 'expoOut'.
then gsap.set([header, hero], { visibility:'visible' }); build the exit triggers.
onComplete: introRunning=false; .is-anim stays on the title only while 0 < title --out < 1; off on UI items.
Failsafe: 4000 ms (re-arm while document.hidden) → set --in 1 on all and show.
Exit (scrubbed): ScrollTrigger { trigger:hero, start:'top top', end:()=>'+='+beats().heroOutTitle·H, onUpdate: title --out = progress } and one with heroOutUi·H → each UI --out = progress.
about.js (yA = about.offsetTop; yAe = yA + about.offsetHeight − H; function-based start/end)
In: ScrollTrigger { start: yA − (1 − .35)·H, end: yA, onUpdate → --in = progress on every [data-about-in] }.
Out: { start: yAe − .5H, end: yAe − .1H, onUpdate → --out = progress on all; .is-anim on the statement while 0<p<1 }.
Reduced → --in 1.
marquee.js
Append a clone of every track child once (×2). --marquee-dur on .marquee = (track.scrollWidth / 2 / 40).toFixed(2)+'s' (40 px/s). Measure now, on fonts.ready, and on resize (150 ms debounce).
media.js — THE SCENE (one scrubbed timeline; rebuild on every ScrollTrigger 'refreshInit')
facts(): H; yA = about.offsetTop; yAe = yA + about.offsetHeight − H; yC = catalog.offsetTop; yCe = yC + catalog.offsetHeight − H.
measure(): W, H; s0 = max(1, H / (.5625·W)); slot rect r and its sticky parent rect p → R1 = { x:r.left, y:r.top − p.top, w:r.width, h:r.height }; s1 = max(R1.w/W, R1.h/(.5625·W)); pw = W·s1; ph = .5625·W·s1; cx1 = (pw − R1.w)/2/pw·100; cy1 = (ph − R1.h)/2/ph·100; x1 = R1.x + R1.w/2 − W/2; y1 = R1.y + R1.h/2 − H/2. Write on .media: --s0, --x0 '0px', --y0 '0px', --s1, --x1 px, --y1 px, --cx1 %, --cy1 %.
easeIO = gsap.parseEase('power2.inOut').
build(): kill the old timeline + trigger; measure(); f = facts(); b = beats(); reduced → stop.
tl = gsap.timeline({ defaults:{ ease:'none' }, scrollTrigger:{ trigger:stage, start:'top top', end:() => facts().yCe, scrub:true, invalidateOnRefresh:true } })
The timeline's time unit IS scroll px. Each leg tweens a proxy {v:0}→1 at [position], [duration] with onUpdate:
vid pos 0 dur videoEnd·H → heroVideo.scrub(v)
g1 pos 0 dur .7·H → .media --g1 = easeIO(v)
pol pos .12·H dur .10·H → setPol(v)
g2 pos yAe − .1·H dur .5·H → .media --g2 = easeIO(v)
dis pos yAe + .4·H dur .4·H → .media --dis = v; .media .is-dissolving while 0<v<1
rin pos yAe + .5·H dur .5·H → .row --rin = v; .row .is-anim while 0<v<1; cars 1–4 .is-hidden while v ≤ 0
par pos yC dur 1.4·H → .row --par = v
wind pos yC + 1.0·H dur 1.0·H → state.wind = easeIO(v); setCur(state.cur)
zoom pos yC + 1.2·H dur .8·H → .row --zoom = easeIO(v); state.zoom = that; setCur(state.cur)
tl.to({}, { duration:.001 }, yCe) (timeline length = scroll range)
setCur(cur): state.cur = cur; eff = cur·state.wind; for each car i (N = 5):
d = i − eff; d −= N·Math.round(d/N) (wrapped ring); ad = |d|;
vis = 1 − state.zoom · clamp01((ad − .5)/.4);
setVar --d d, --dir (ad < .001 ? 0 : Math.sign(d)), --vis vis; .is-anim while 0<vis<1;
style.zIndex = 10 − Math.round(ad);
.is-hidden if vis ≤ 0 or (i ≠ 0 and state.rin ≤ 0).
x is mirrored in the CSS (− d·pitch): positive d sits LEFT, so "next" drives the current car out to the right and brings the next one in from the left.
Init: ScrollTrigger.addEventListener('refreshInit', build); setCur(0); build(). export initMedia() → { state, media, cars, N, setCur, facts }.
words.js
Add .is-hidden (unless reduced). After fonts.ready → rAF: SplitText.create(el, { type:'words', wordsClass:'word', propIndex:true, autoSplit:false }); --n = words.length.
apply(): --p, --out; .is-words while 0<p<1; .is-anim while 0<out<1; .is-hidden when p ≤ 0 or out ≥ 1. export setP(p), setOut(o).
catalog.js (yC = catalog.offsetTop)
cars = JSON.parse(#cars). fill(i): name, desc, price, location, '+' + extra into [data-w-name] / [data-w-desc] / [data-w-price] / [data-w-loc] / [data-w-extra]. fill(0) on init.
swap(i) (skip if i === pending; reduced → fill): kill the previous swap; timeline defaults { ease:'magic', overwrite:true }: to(texts, { autoAlpha:0, y:'-.4em', duration:.25, stagger:.03 }) → call(fill(i)) → fromTo(texts, { autoAlpha:0, y:'.4em' }, { autoAlpha:1, y:0, duration:.4, stagger:.04, clearProps:'transform,opacity,visibility' }).
reset(i=0): kill swap, clearProps on texts, fill(i), pending = i.
Words triggers: { start:yC, end:yC + 1·H, onUpdate: words.setP(progress) }; { start:yC + 1·H, end:yC + 1.4·H, onUpdate: words.setOut(progress) }.
Widgets entrance (timed, reversible): paused timeline fromTo(all 5 .widget, { y:56, autoAlpha:0, scale:.96, transformOrigin:'50% 100%' }, { y:0, autoAlpha:1, scale:1, duration:.85, ease:'outExpo', stagger:.07 }); ScrollTrigger { start:yC + 1.85·H, end:yC + 1.85·H + 1, onEnter: play(), onLeaveBack: reverse() }.
Reduced → words.setP(1), widgets simply visible.
slider.js (arrows / keys only; the page ENDS where the widgets park — no extra scroll runway)
state { idx:0, cur:0 }.
go(idx): if idx === state.idx return; state.idx = idx; catalog.swap(idx); reduced → setCur(idx); else tween = gsap.to(state, { cur:idx, duration:1.6, ease:'magic', overwrite:true, onUpdate: () => media.setCur(state.cur) }).
step(dir): if media.state.zoom < .99 return; go(clamp(idx + dir, 0, N−1)).
[data-prev] click → step(−1); [data-next] click → step(+1).
window keydown: ignore alt/meta/ctrl or zoom < .99; ArrowRight → preventDefault, step(1); ArrowLeft → preventDefault, step(−1).
Reset: ScrollTrigger { start:yC + 1.0·H, end:+1, invalidateOnRefresh:true, onLeaveBack: kill tween, idx = cur = 0, media.setCur(0), catalog.reset(0) }.
═══════════════════════════════════════
8. src/main.js — BOOT ORDER
═══════════════════════════════════════
import 'bootstrap-icons/font/bootstrap-icons.min.css'; import './style.css'; then the modules.
ScrollTrigger.config({ ignoreMobileResize:true });
applyBeats();
const scroll = initScroll();
initSplit();
const header = initHeader();
const heroVideo = initHeroVideo();
const media = initMedia({ heroVideo, beats, onPol: setPol });
initHero({ header, beats, ready: heroVideo.ready });
initAbout({ beats });
initMarquee();
const words = initWords();
const catalog = initCatalog({ words, beats });
initSlider({ media, beats, onChange: (i) => catalog.swap(i), onReset: () => catalog.reset(0) });
matchMedia('(max-width: 767px)').addEventListener('change', () => { applyBeats(); ScrollTrigger.refresh(); });
═══════════════════════════════════════
9. ACCEPTANCE — WHAT THE VISITOR SEES
═══════════════════════════════════════
1. Load: black full-bleed video frame 0 (Mercedes S-Class, top-down, dark). Header items rise in one by one in white: skewed rounded-rect mark + "brand" · ghost "Overview" (40% white) · Fleet · Experience · Contact · white "Reserve" button with black label. Then "Premium" (40% white) and "Car / Rental" blur in at the bottom; the "New York" glass pill with a location icon + two-line description at left-middle; "Scroll ↓" bottom-left.
2. Scroll: the hero type blurs and fades out fast; the video scrubs forward while the plate shrinks (rounding to 1.5rem corners) into the left half of screen 2; the header flips to black ink and a black button between 12% and 22% of the first viewport.
3. Screen 2: statement lines rise out of their masks on the right, grey "Reserve" button below, grey brand-name marquee along the bottom (faded edges, 40 px/s). A reading pause, then everything lifts out with blur.
4. The plate glides to bottom-centre as a card (33.5vw); the last frame blurs into the transparent cut-out; four more cars blur in from the sides (pitch 28.57vw), sitting on the bottom edge.
5. The row rises while the centred statement reveals word by word from 12% opacity + blur; the cars cover its last line as it fades.
6. The centre car zooms to full width (neighbours fade, blur and shrink away), then five #f1f1f1 cards rise from the bottom one after another: ‹ | name + description + "Specifications" | New York pill + "+3" | "Per day" · $300 · black "Reserve" | ›.
7. ‹ › or ← → : the car slides out to the right, the next slides in from the left along a shallow arc (1.6 s); the texts drop out and the new copy rises in.
MOBILE (< 768px): hamburger icon button instead of the nav; title left-aligned; no scroll hint; description block at 44% height; the about slot is full width (52vw tall) with the copy stacked below it; the words are 88vw wide at 40svh; cars 58vw with 50vw pitch; the presented car sits in the upper half; widgets wrap — main card full width, location + price side by side, the two arrows side by side (3.25rem tall) at the bottom.
Reduced motion: no intro, no scrub; everything visible in its final state.
Deliver: the complete Vite project (every file above, fully written, no TODOs). `npm install && npm run dev` must run it with no console errors, and `npm run build` must succeed.[/hidecontent]