类型:Site模板 · 收录:2026-11-05 01:12:52 · 源码:会员可见
# Recreate this site as a single HTML file: KELD STUDIO — Harmony in House
You are an expert creative front-end developer. Produce a **single self-contained `index.html`**
that reproduces the project below **exactly** — same layout, copy, visuals, motion and interaction.
Pure HTML/CSS/JS in one file: no build step, no framework, no bundler. ES modules inline in a
`<script type="module">`. **Lenis is the only external code**, pulled through an import map; there
is no three.js here at all. The spring solver, the shared ticker, the scroll trigger, the letter
engine, the frame-sequence canvas, the odometer, the stat drums, the reveal footer and the dock are
all written by hand. Hardcode every value given here as a fixed constant.
Where this document quotes source verbatim it is the project's own TypeScript — **strip the type
annotations and use it as it stands**. Every number in it is the shipped default. Nothing in it is
a suggestion.
## What it is
A one-page site for **Keld Studio**, an architecture and glazing studio ("Harmony in House"). The
whole page is **black** (`#000000`) with white type, except one **sand** section (`#f4f0eb`) for the
client story and a **forest** ground (`#212623`) under the footer's photograph. One face throughout:
**Instrument Sans**, from Google Fonts. Display words are set at **190px** on a 1440 board with
`-0.04em` tracking and a `0.9` leading; labels are tracked-out caps at 13px.
The page is one **cinematic scroll scene** on a pinned track fourteen viewports tall, and three
ordinary sections after it:
- **The fly-through.** A 210-frame render of a glass house, drawn to a 2D canvas and **scrubbed by
the scroll** across the first 40% of the track — not a video. Three statement blocks sit over it
in turn ("Harmony / in / House", "Precision / through / Detail", "Exquisite / by / Nature"), each
word pinned to a corner or an edge, letters sliding in out of a blur; each block leaves as a whole,
sliding left and blurring. Five hairlines rule the frame.
- **ECHO®.** An interior photograph rides up over the dimmed sequence with a frosted half-width
panel: "Award winning / Project 2026", the word ECHO resolving letter by letter with the scroll,
a description, three line icons, "Portfolio / Arch.Mono".
- **Details.** A second interior is **unmasked bottom-up** over ECHO, a hairline cross through the
centre, "Realization of your imagination in space" top-left and three stats counting up in the
other corners on a blurred, dropping drum — 300 / 10 / 32. Then a **four-point star** in the
middle **warps** out to fill the screen (scale 0.04 → 16 on a fifth-power curve), turning from
white to sage as it goes, and a **showreel** is uncovered on top of it and zoomed toward full
width, with a zoom title and the studio's address parting outward around it, grayscale until
hovered, a frosted PLAY badge trailing the cursor.
- **Client stories.** A sand section: grayscale portrait, a 160px quotation mark, a letter-rising
quote, author and role, a "01 / 01" pager.
- **The reveal footer.** Fixed behind the page and uncovered by an 85vh spacer scrolling past —
a full-bleed photograph under a forest tint, "Ready to shape / your vision in space?" rising letter
by letter, a frosted Contact button, four links, and a giant "Keld Studio" wordmark seated on the
bottom edge whose letters rise in two interleaved voices as the footer opens.
- **The dock.** A floating frosted glass bar at the foot of every screen — mark, HOME, a menu
button that opens the section links — which steps down and away as the footer starts to show.
- **The preloader.** A black curtain: brand label top-left, a hairline progress rule across the
middle, and a 190px rolling percentage that travels from the left edge to the right as the frames
load. It lifts only when BOTH a 2.8 s clock has run AND every frame has decoded, then four columns
wipe away, alternating up and down.
The page is written to a **1440-wide board** in `rem`: at 1440 the root is 16px and every rem is
one board pixel; below 1440 the root scales with the viewport; above it the root grows, damped.
Below 768 the footer is an ordinary block, the overlays hide, and the corner words move.
## Page shell & libraries
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Keld Studio — Harmony in House</title>
<meta name="description" content="A cinematic journey through premium architectural forms, exquisite glass craft, and bespoke minimalist living spaces.">
<meta name="theme-color" content="#000000">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@300;400;500;600&display=swap" rel="stylesheet">
<script type="importmap">
{ "imports": { "lenis": "https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.mjs" } }
</script>
```
`font-family: "Instrument Sans", system-ui, sans-serif` on `body`, `-webkit-font-smoothing:
antialiased`, `overflow-x: clip`, `background: var(--background)`, `color: var(--foreground)`.
Weights used: 300 (the quotation mark), 400 (everything), 500 (labels, author, links), 600 (the
PLAY badge).
### Smooth scroll
Lenis, at the project's own settings, driven from your one `requestAnimationFrame` loop — **first
thing every frame**, before any scroll reader, so every rect the page measures is this frame's:
```js
import Lenis from "lenis";
const lenis = new Lenis({ smoothWheel: true, anchors: true });
window.lenis = lenis;
// inside the shared ticker, before every subscriber:
lenis.raf(time);
```
`anchors: true` makes every in-page `#hash` link (footer nav, dock menu, "Get a quote", "Contact")
scroll through Lenis instead of jumping. Include Lenis's required rules (`html.lenis, html.lenis
body { height: auto }`, `.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain }`,
`.lenis.lenis-stopped { overflow: hidden }`). While the preloader is up: `lenis.stop()` and
`html { position: relative; overflow: hidden; height: 100% }`; when it goes: `lenis.start()` and
remove those three properties. `window.scrollTo(0, 0)` once at boot.
### The root font-size
```css
html { font-size: 16px; }
@media (max-width: 1440px) { html { font-size: 1.111111vw; } } /* 16 * 100 / 1440 */
@media (max-width: 1024px) { html { font-size: 1.5625vw; } } /* 16 * 100 / 1024 */
@media (max-width: 640px) { html { font-size: 4.444444vw; } } /* 16 * 100 / 360 */
```
Above 1440 a script sets an inline `font-size` on `<html>`, re-run on every resize (through the
ticker, coalesced), and removes it at or below 1440:
```js
const FONT_BASE = 16, BASE_WIDTH = 1440, COEF = 0.6666;
const interpolateFontSize = (w) => {
const widthReduction = ((BASE_WIDTH - w) / BASE_WIDTH) * 100; // negative above 1440
const fontReduction = widthReduction * COEF;
return FONT_BASE - (FONT_BASE * fontReduction) / 100;
};
// size > 16 → root.style.fontSize = size + "px"; else root.style.removeProperty("font-size")
```
So 1920 wide gives a 19.55px root — the board grows at two thirds of the viewport's rate.
### Breakpoints and the class vocabulary used below
The project is Tailwind v4 and the layout below is given in its utilities, with the numbers spelled
out where they matter. Read them as: **1 spacing unit = 0.25rem** (`left-10` = 2.5rem, `top-24` =
6rem, `w-80` = 20rem, `px-14` = 3.5rem, `size-11` = 2.75rem, `h-16` = 4rem, `w-90` = 22.5rem);
`sm:` = `(min-width: 640px)`, `md:` = `(min-width: 768px)`, `lg:` = `(min-width: 1024px)`,
`max-md:` = `(max-width: 767.98px)`, `max-sm:` = `(max-width: 639.98px)` — **px media queries,
not rem, whatever the root is**. `foreground/70` is the colour at 70% alpha (`color-mix(in oklab,
var(--foreground) 70%, transparent)`); `backdrop-blur-2xl` = `backdrop-filter: blur(40px)`, `-xl` =
24px, `-md` = 12px; `rounded-sm` = 0.25rem; `h-dvh` = `100dvh`; `-translate-1/2` = `translate:
-50% -50%`; `inset-x-10` = `left: 2.5rem; right: 2.5rem`. Tailwind v4 writes `translate`, `rotate`
and `scale` as the separate CSS properties, not inside `transform` — matter for the elements whose
`transform` a spring also writes (they use the property so the two do not fight).
## Fixed parameters — the whole token block, verbatim
Tier 1 are the only literals on the page. Tier 2 are the roles the page actually reads. Reproduce
both on `:root`.
```css
:root {
--raw-color-white: #ffffff;
--raw-color-black: #000000;
--raw-color-neutral-950: #0a0b09; /* dock + contact-button glass */
--raw-color-neutral-900: #121311; /* glass hover, ECHO panel tint */
--raw-color-forest-900: #212623; /* footer ground under the photograph */
--raw-color-sand-100: #f4f0eb; /* client-stories surface */
--raw-color-sand-200: #e2ddd5; /* portrait placeholder */
--raw-color-ink-900: #2c2e2a; /* copy on sand */
--raw-color-sage-500: #646c61; /* muted accent on sand; the star's final colour */
--raw-duration-fast: 200ms; /* icon hovers */
--raw-duration-normal: 350ms; /* buttons, dock, links */
--raw-duration-slow: 600ms; /* showreel grayscale → colour */
/* Type scale — the source's clamp() sizes evaluated at 1440, as rem (px / 16). */
--raw-font-size-display: 11.875rem; /* 190px — hero words, ECHO®, stats, preloader count */
--raw-font-size-display-tablet: 8.9375rem; /* 14vw on the 1024 base */
--raw-font-size-display-phone: 3.75rem; /* 60px */
--raw-font-size-stat-phone: 2.25rem; /* 36px — phone stats sit above the dock */
--raw-font-size-headline: 2.5rem; /* 40px — quote, footer CTA, zoom title */
--raw-font-size-headline-phone: 1.625rem; /* 26px */
--raw-font-size-title: 2rem; /* 32px — ECHO labels, details title, preloader label */
--raw-font-size-title-phone: 1.25rem; /* 20px */
--raw-font-size-lead: 1.125rem; /* 18px — author name, footer links */
--raw-font-size-body: 1rem; /* 16px — descriptions, stat labels */
--raw-font-size-body-phone: 0.875rem; /* 14px */
--raw-font-size-caption: 0.8125rem; /* 13px — tracked-out caps labels */
--raw-font-size-badge: 0.6875rem; /* 11px — PLAY cursor badge */
--raw-font-size-quote-mark: 10rem; /* 160px */
--raw-font-size-quote-mark-phone: 5rem; /* 80px */
--raw-font-size-wordmark: 19.5vw; /* footer "Keld Studio" — fills the width by design */
--raw-font-size-wordmark-phone: 18vw;
--raw-leading-hero: 0.9; /* display words — never clipped */
--raw-leading-wordmark: 0.75; /* the wordmark sits on the footer's bottom edge */
--raw-leading-heading: 1.15;
--raw-leading-copy: 1.5;
--raw-tracking-display: -0.04em;
--raw-tracking-title: -0.02em;
--raw-tracking-copy: -0.01em;
--raw-tracking-caps: 0.22em;
--raw-tracking-caps-wide: 0.25em;
/* Scene geometry — viewport-relative in the source, kept that way. */
--raw-size-showreel: 65vw; /* showreel frame before it zooms */
--raw-ratio-showreel: 65 / 37;
--raw-size-overlay-lead: 48vw; /* zoom-title column */
--raw-size-overlay-aside: 22vw; /* address column */
/* The star is laid out huge and scaled DOWN to its resting size, so its
final, screen-filling state is rasterised crisp rather than upsampled. */
--raw-size-star: 156.25rem; /* 2500px */
/* The fixed footer's height IS the scroll it takes to reveal it. */
--raw-size-footer-reveal: 85vh;
--raw-size-wordmark-drop: calc(11vw - 12.5rem); /* seats the wordmark on the bottom edge */
--background: var(--raw-color-black);
--foreground: var(--raw-color-white);
--surface-glass: var(--raw-color-neutral-950);
--surface-glass-raised: var(--raw-color-neutral-900);
--surface-footer: var(--raw-color-forest-900);
--surface-inverse: var(--raw-color-sand-100);
--surface-inverse-sunken: var(--raw-color-sand-200);
--foreground-inverse: var(--raw-color-ink-900);
--foreground-inverse-muted: var(--raw-color-sage-500);
--duration-fast: var(--raw-duration-fast);
--duration-normal: var(--raw-duration-normal);
--duration-slow: var(--raw-duration-slow);
/* every --font-size-*, --line-height-*, --letter-spacing-*, --size-* above maps 1:1 to a role of
the same name: --font-size-display: var(--raw-font-size-display), and so on */
--leading-display: 1.1; /* the clip floor for every letter reveal */
--ease-entrance: cubic-bezier(0.2, 0, 0, 1);
--ease-glide: cubic-bezier(0.25, 1, 0.5, 1); /* the hover curve — buttons, dock, showreel */
}
```
The type roles used below, in the order they scale: `text-display-phone` (3.75rem) on every width,
`sm:text-display-tablet` (8.9375rem) from 640, `lg:text-display` (11.875rem) from 1024 — that
triplet is what "display" means everywhere it appears. `text-title-phone` → `sm:text-title`,
`text-headline-phone` → `sm:text-headline`, `text-body-phone` → `sm:text-body` likewise.
## The motion engine you write
Every motion on the page is a spring or a scrubbed function of scroll. Nothing uses CSS
`@keyframes`; the only CSS transitions are the hover nudges named as such.
### The spring solver
`@react-spring/web`'s integrator, per animated value: `tension`, `friction`, `mass: 1`, stepped at
1 ms inside each frame with the frame delta capped at 64 ms.
```js
// per 1 ms step
const springForce = -tension * 0.000001 * (position - to);
const dampingForce = -friction * 0.001 * velocity;
velocity += (springForce + dampingForce) / mass; // dt = 1
position += velocity;
// at rest when |velocity| < precision / 10 and |position - to| <= precision (precision 0.01)
```
**Duration mode**: a config with `{ duration, easing }` ignores tension and friction and moves
`position = from + (to - from) * easing(min(1, elapsed / duration))`. The easings the page uses:
```js
const easeOutExpo = (t) => (t === 1 ? 1 : 1 - Math.pow(2, -10 * t));
const easeOutQuart = (t) => 1 - Math.pow(1 - t, 4);
const easeOutQuint = (t) => 1 - Math.pow(1 - t, 5);
const easeInQuad = (t) => t * t;
const easeOutQuad = (t) => 1 - (1 - t) * (1 - t);
const easeInOutQuart = (t) => (t < 0.5 ? 8 * t * t * t * t : 1 - Math.pow(-2 * t + 2, 4) / 2);
const easeInOutQuint = (t) => (t < 0.5 ? 16 * t ** 5 : 1 - Math.pow(-2 * t + 2, 5) / 2);
```
A spring value can be a number or a unit string (`"0%"`, `"blur(12px)"`, `"translateY(-40px)"`):
lerp the numbers inside the string and keep its shape. A spring with `delay` waits that many ms
before starting toward its new target. `immediate` jumps to the target with no motion.
**`Spring` element semantics** (used by the preloader, the grid, the dock, the odometer): an
element with a `from` and a `to`; while `enabled` it animates to `to` (after `delayIn`), while not
it animates back to `from` (after `delayOut`, or instantly if `immediateOut`). Setting the same
target twice is a no-op — a re-render never restarts a spring.
**`Hover`**: animates to `to` while the pointer is over the element, back to `from` when it leaves;
disabled entirely at or below **768px** (no hover on touch).
### The shared ticker
One `requestAnimationFrame` loop for the whole page, reference-counted (starts on the first
subscriber, stops when the last leaves). Each subscriber has its own minimum gap in ms; a subscriber
is skipped while `time - last <= gap`. A subscriber that throws is caught and logged, never allowed
to kill the loop. Lenis runs first, then every scroll reader, then the canvas.
### The scroll trigger
The scene and the footer each read one element's rect every frame while that element is on screen
(an `IntersectionObserver` gate; keep reading for 10 more frames after it leaves so a value never
freezes mid-way). The progress maths, verbatim:
```js
const bb = el.getBoundingClientRect();
const clientHeight = window.innerHeight;
const poses = {
top_top: bb.top,
center_top: bb.top + bb.height / 2,
bottom_top: bb.bottom,
top_bottom: bb.top - clientHeight,
center_bottom: bb.top + bb.height / 2 - clientHeight,
bottom_bottom: bb.bottom - clientHeight,
top_center: bb.top - clientHeight / 2,
center_center: bb.top + bb.height / 2 - clientHeight / 2,
bottom_center: bb.bottom - clientHeight / 2,
};
const scrollStart = poses[start.split(" ").join("_")];
const scrollEnd = poses[end.split(" ").join("_")];
const length = Math.abs(scrollStart - scrollEnd);
const progress = Math.min(Math.max(0, 1 - (scrollStart + length) / length), 1);
```
`start`/`end` are `"<element edge> <viewport edge>"` pairs. The scene track uses `"top top"` →
`"bottom bottom"` (progress 0 while the track's top is at the viewport top, 1 when its bottom
reaches the viewport bottom) with **no throttle** (`frameInterval 0` — every frame; a 10 ms
throttle halves the rate on a 120 Hz panel and judders against Lenis). The footer spacer uses
`"top bottom"` → `"bottom bottom"`. Only when `progress` changed since last frame is it pushed into
the follower spring and the flags recomputed.
### The letter engine (spring-text-engine's contract, hand-written)
A text block is split into **letters** (each letter in its own `inline-block` span with its own
spring; a space is a `whitespace-pre` span), grouped in **words** (each word an `inline-block`
span; when `clip` is on the word wrapper gets `overflow: hidden` and a line-height box, so a letter
can rise from under it), laid out in a **flex row that wraps** (`display: flex; flex-wrap: wrap`,
words as flex items, `column-gap: 0.28em` between words — so alignment is `justify-content`, never
`text-align`). The container's `line-height` is the clip box: **never below 1.1** where `clip` is on
(`leading-display`). Each letter has an **In** and an **Out** target; letter *i* (global index
across the text) is delayed `i × letterStagger` ms on the way in. The real sentence is duplicated
once in an `sr-only` span and the split letters are `aria-hidden`.
Modes used here:
- **`once`, gated by `enabled`** — block one's words: letters spring In when `enabled` turns true,
never replay.
- **`always`, gated by `enabled`** — every `RevealLines` (labels, zoom title, footer CTA, quote):
letters spring In while `enabled` and the text is in view (an `IntersectionObserver` with the
given `rootMargin`), and snap Out instantly when `enabled` drops.
- **`progress` / `toggle`** — blocks two and three, the ECHO title: read the scroll progress of a
**trigger box** (the same pose maths as above, `"top top"` → `"bottom bottom"`), and letter *i*
of *n* is In while `progress > i / n` (strict), Out otherwise — each letter springs both ways on
its own `letterConfig`. **Not** an interpolation: at progress 0 every letter is fully Out, which is
why nothing shows before the scroll reaches the word.
The two letter recipes (`presets.ts`, verbatim):
```js
/** Slide in from the left out of a blur — the hero words and ECHO®. */
const SLIDE_BLUR = {
letterIn: { opacity: 1, x: 0, filter: "blur(0px)" },
letterOut: { opacity: 0, x: -40, filter: "blur(12px)" }, // x is px, as translateX
};
/** Rise from under the line's clip out of a blur — labels, headings, quote. */
const RISE_BLUR = {
letterIn: { opacity: 1, y: "0%", filter: "blur(0px)" },
letterOut: { opacity: 0, y: "105%", filter: "blur(12px)" }, // y as translateY
};
const settle = (duration) => ({ duration, easing: easeOutExpo }); // the source's cubic-bezier(0.16, 1, 0.3, 1)
const REVEAL_TIMING = {
label: { stagger: 35, config: settle(1500) },
cta: { stagger: 12, config: settle(800) },
quote: { stagger: 7, config: settle(1600) },
};
/** Block one's entrance after the preloader: letters start across half of 1.5s and each takes the other half. */
const HERO_INTRO = { span: 750, config: { duration: 750, easing: easeOutQuart } };
/** Scroll-triggered letters: each springs in as the scroll passes its threshold, and back out on the way up. */
const SCROLL_LETTER = { tension: 170, friction: 26 };
```
**`RevealLines(lines, timing, clip = true, rootMargin?)`** — one engine per forced line, stacked in
a `flex-col`; the stagger is carried across lines (line *k* starts at
`sum(length of lines before it) × stagger` ms), so line two begins where line one's last letter left
off. With `clip` on, every word wrapper carries `-mb-3.5 pb-4 mask-b-from-45%` — a little room under
the clip (1rem of padding, 0.875rem pulled back) faded out from 45% down, so a descender is not
shaved. The quote passes `clip: false` (letters float up freely) and `rootMargin: "0px 0px -15% 0px"`
(waits until the text is 15% up from the bottom).
### The other springs (`presets.ts`, verbatim)
```js
const TIMELINE_FOLLOW = { tension: 90, friction: 26 }; // the scene trails the scroll — "cinematic inertia"
const FOOTER_FOLLOW = { tension: 140, friction: 30 }; // the footer reveal, a touch quicker
const BADGE_FOLLOW = { tension: 300, friction: 40 }; // the PLAY badge toward the cursor
const COUNT_UP = { duration: 2200, easing: easeOutQuint };
const PRELOADER_COUNT = { duration: 2800, easing: easeOutQuart };
const PRELOADER_WIPE = { duration: 1200, easing: easeInOutQuart };
const PRELOADER_FADE = { duration: 800, easing: easeInOutQuart };
const PRELOADER_RULE = { duration: 800, easing: easeInOutQuint };
const GRID_INTRO = { duration: 800, easing: easeInQuad };
const DOCK_TOGGLE = { duration: 350, easing: easeOutQuart };
const PORTRAIT_HOVER = { duration: 600, easing: easeOutQuad };
```
### The page's shared facts
Three booleans and a number that separate parts read:
```js
const store = { framesProgress: 0, introReady: false, dockHidden: false };
```
`framesProgress` — share of the frame sequence decoded, 0–1; the preloader waits on it.
`introReady` — the preloader has lifted far enough for the hero to play its entrance.
`dockHidden` — the footer is being uncovered, so the dock steps aside. Notify listeners on change.
## The timeline — `utils/timeline/`, verbatim
This is the design. One normalised progress `p` (0 → 1) across the pinned track drives every layer
of the scene; every export is a pure function of `p`.
```js
// range.ts
const clamp01 = (value) => Math.min(1, Math.max(0, value));
/** Local 0–1 progress of `p` through `range` — 0 before it, 1 after it. */
const span = (p, [start, end]) => clamp01((p - start) / (end - start));
const easeInOutSine = (t) => (1 - Math.cos(t * Math.PI)) / 2;
const easeOutSine = (t) => Math.sin((t * Math.PI) / 2);
// scene.ts
const TRACK_VH = 1400; // the track's height, in viewports
const TRAVEL_VH = TRACK_VH - 100; // distance the track scrolls while its sticky stage is pinned
const PHASE = {
sequence: [0, 0.4],
heroOneExit: [0.08, 0.13],
heroTwoEnter: [0.13, 0.18],
heroTwoExit: [0.21, 0.26],
heroThreeEnter: [0.26, 0.31],
echo: [0.4, 0.53],
details: [0.53, 0.66],
star: [0.66, 0.74],
showreelReveal: [0.74, 0.79],
showreelZoom: [0.79, 1],
};
/** In-page anchors inside the track, and the point each one lands on. */
const ANCHOR_AT = { echo: PHASE.echo[1], details: PHASE.details[1] };
/** A length down the track, for a fraction of its travel. */
const trackOffset = (fraction) => `${fraction * TRAVEL_VH}vh`;
/**
* Geometry of a letter-engine trigger for `range`: the box starts where the phase starts and is
* one viewport taller than the phase, read "top top" → "bottom bottom", so progress runs 0 → 1
* across exactly the phase and the box is still on screen when it reaches 1.
*/
const triggerBox = ([start, end]) => ({
top: trackOffset(start),
height: `${(end - start) * TRAVEL_VH + 100}vh`,
});
// ── Hero blocks ──
/** Where each block has fully gone, and may stop painting. Deliberately NO start gate. */
const HERO_GONE_AT = [PHASE.heroOneExit[1], PHASE.heroTwoExit[1], PHASE.echo[1]];
const heroVisibility = (index) => (p) => (p < HERO_GONE_AT[index] ? "visible" : "hidden");
/** The exit is block-level: a slide left and a blur. */
const EXIT_SHIFT_REM = 2.5;
const EXIT_BLUR_REM = 0.75;
const exitOpacity = (range) => (p) => 1 - easeInOutSine(span(p, range));
const exitTransform = (range) => (p) =>
`translate3d(${-EXIT_SHIFT_REM * easeInOutSine(span(p, range))}rem, 0, 0)`;
const exitFilter = (range) => (p) => {
const blur = EXIT_BLUR_REM * easeInOutSine(span(p, range));
return blur > 0 ? `blur(${blur}rem)` : "none"; // blur(0) still allocates a filter surface
};
const heroDescriptionOpacity = [
(p) => 1 - span(p, PHASE.heroOneExit),
(p) => span(p, PHASE.heroTwoEnter) * (1 - span(p, PHASE.heroTwoExit)),
(p) => span(p, PHASE.heroThreeEnter),
];
/** Block three is never exited letter by letter — it dims with the canvas. */
const heroThreeOpacity = (p) => 1 - span(p, PHASE.echo);
// ── Sequence + grid ──
const sequenceFrame = (p, count) => Math.min(count - 1, Math.floor(span(p, PHASE.sequence) * count));
const gridOpacity = (p) => 1 - span(p, PHASE.sequence);
const gridVisibility = (p) => (gridOpacity(p) > 0 ? "visible" : "hidden");
const canvasOpacity = (p) => 1 - span(p, PHASE.echo);
// ── ECHO (interior one) ──
const echoTransform = (p) => `translate3d(0, ${(1 - span(p, PHASE.echo)) * 100}%, 0)`;
/** Both interiors settle from a 1.1 overscan as they arrive — the parallax. */
const interiorScale = (range) => (p) => `scale(${1.1 - 0.1 * span(p, range)})`;
// ── Details (interior two) ──
const detailsClip = (p) => `inset(${(1 - span(p, PHASE.details)) * 100}% 0 0 0)`;
/** Power 5 is the source's "warp" — nothing, nothing, then the whole screen. */
const starTransform = (p) => `scale(${0.04 + Math.pow(span(p, PHASE.star), 5) * 16})`;
/** 0 → 1 across the warp: how far the star has turned from white to sage. */
const starTint = (p) => span(p, PHASE.star);
const reveal = (p) => span(p, PHASE.showreelReveal);
const zoom = (p) => easeInOutSine(span(p, PHASE.showreelZoom));
const showreelClip = (p) => `inset(${(1 - reveal(p)) * 100}% 0 0 0)`;
const showreelTransform = (p) =>
`translate(-50%, calc(-50% + ${(1 - reveal(p)) * 10}%)) scale(${1 + zoom(p) * 0.8})`;
/** Overlays fade in with the reveal, then part outward as the showreel zooms. */
const OVERLAY_SHIFT_REM = 7.5;
const overlayOpacity = reveal;
const overlayVisibility = (p) => (reveal(p) > 0 ? "visible" : "hidden");
const overlayLeadTransform = (p) => `translate3d(${(1 - zoom(p)) * OVERLAY_SHIFT_REM}rem, -50%, 0)`;
const overlayAsideTransform = (p) => `translate3d(${-(1 - zoom(p)) * OVERLAY_SHIFT_REM}rem, -50%, 0)`;
// ── Discrete flags — switched, not scrubbed; applied only when one flips ──
const sceneFlags = (p) => ({
canvas: p < PHASE.echo[1], // the canvas is still visible, so frames are worth drawing
echoLead: p >= PHASE.echo[0], // "Award winning / Project 2026" — the moment ECHO starts to rise
echoFoot: span(p, PHASE.echo) >= 0.9, // "Portfolio / Arch.Mono" — once ECHO is 90% up
details: p >= PHASE.details[0], // the stats count up as the details layer starts to reveal
showreel: p >= PHASE.star[0], // the showreel plays from the star zoom onward
zoom: p >= PHASE.showreelReveal[0], // zoom title reveal, as the showreel is uncovered
});
// footer.ts
/** The CTA letters start once a fifth of the footer is uncovered. */
const FOOTER_CTA_AT = 0.2;
/**
* The wordmark rises in two voices: even letters (1-based, within their word) start early,
* staggered, and travel less; odd letters start together, later, and travel further.
*/
const EVEN = { step: 0.02, start: 0, end: 0.48, shiftRem: 5.625 };
const ODD = { start: 0.12, end: 0.72, shiftRem: 8.75 };
const wordmarkShift = (f, indexInWord) => {
const even = (indexInWord + 1) % 2 === 0;
const start = even ? EVEN.start + indexInWord * EVEN.step : ODD.start;
const end = even ? EVEN.end + indexInWord * EVEN.step : ODD.end;
const settle = easeOutSine(clamp01((f - start) / (end - start)));
const shift = (1 - settle) * (even ? EVEN.shiftRem : ODD.shiftRem);
return `translate3d(0, ${shift}rem, 0)`;
};
```
**The two clocks.** The raw scroll progress feeds the letter engines directly (they read the
trigger boxes); `p` is a spring (`TIMELINE_FOLLOW`) that **trails** the raw progress and drives
everything else. Never gate a block's *start* on `p` — the letters would already be part-way in
when the gate opened. A block hides only once it has fully left.
## The preloader
A `fixed inset-0 z-200` curtain, `aria-hidden`, colour `foreground`:
- **Four columns** (`absolute inset-0 flex`, each `h-full flex-1 bg-background`; even columns
`origin-top`, odd `origin-bottom`).
- **The label** "Keld Studio", `absolute left-10 top-10`, `text-title-phone uppercase
tracking-title sm:text-title` (`max-md: left-6 top-6`).
- **The rule**: `absolute inset-x-10 top-1/2 h-px -translate-y-1/2 origin-right bg-foreground/15`
(`max-md:inset-x-6`), containing a `block h-full origin-left bg-foreground` fill whose
`transform: scaleX(min(clock, loaded))`.
- **The odometer**: `absolute inset-x-10 bottom-10 h-[0.9em]` in display type
(`text-display-phone sm:text-display-tablet lg:text-display`, `tabular-nums leading-hero
tracking-display`, `max-md:inset-x-6`). Inside, an `absolute bottom-0 flex h-[0.9em]
overflow-hidden` traveller with `left: ${count}%` and `transform: translateX(-${count}%)` — 0 is
flush left, 100 flush right, no measuring. It holds **three columns** (hundreds, tens, ones), each a
`flex flex-col text-center` strip of **101 cells** (`block h-[0.9em]`, one line of `leading-hero`)
rolled as one with `transform: translateY(-${count * 0.9}em)`, then a `%` sign (`ml-1`).
```js
const STEPS = Array.from({ length: 101 }, (_, value) => value);
const BLANK = " ";
const COLUMNS = [
(value) => (value < 100 ? BLANK : "1"),
(value) => (value < 10 ? BLANK : String(Math.floor((value % 100) / 10))),
(value) => String(value % 10),
];
// count = Math.min(clock, loaded) * 100 — the SMALLER of the 2.8 s clock and the real load
```
**Clocks and gate.** `t` runs 0 → 1 on `PRELOADER_COUNT` (2.8 s, quartic out) from mount;
`loaded` is a default spring (tension 170, friction 26) chasing `framesProgress`. `exiting =
clockDone && framesProgress >= 1` — both inputs only ever grow, so once true it stays true. Scroll
is stopped on mount and started when `exiting` flips.
**The exit**, all on `exiting`: each column springs `scaleY: 1 → 0` on `PRELOADER_WIPE` (1.2 s,
quart in-out) with `delayIn = column × 100` ms; the label `opacity 1 → 0, y 0 → -40` on
`PRELOADER_FADE` (0.8 s); the rule wrapper `scaleX: 1 → 0` on `PRELOADER_RULE` (0.8 s, quint
in-out, from the right); the three odometer columns fade `opacity 1 → 0, x 0 → -40` on
`PRELOADER_FADE` with `delayIn = (3 - index) × 50` ms — **right to left**, the `%` first (delay 0),
the hundreds last. `introReady` is set true **1200 ms** after `exiting` (the hero starts its
entrance while the wipe is still clearing); the curtain leaves the DOM after **1600 ms**.
## The scene — a 1400vh track with a sticky stage
```html
<section id="top" aria-label="House showcase" class="relative z-50 bg-background" style="height: 1400vh">
<!-- trigger boxes: invisible, absolute inset-x-0, positioned by triggerBox(range) -->
<div aria-hidden class="pointer-events-none invisible absolute inset-x-0" style="top: 169vh; height: 165vh"></div> <!-- heroTwoEnter [0.13, 0.18] -->
<div aria-hidden class="pointer-events-none invisible absolute inset-x-0" style="top: 338vh; height: 165vh"></div> <!-- heroThreeEnter [0.26, 0.31] -->
<div aria-hidden class="pointer-events-none invisible absolute inset-x-0" style="top: 520vh; height: 269vh"></div> <!-- echo [0.4, 0.53] -->
<!-- hash targets: absolute inset-x-0 h-px at trackOffset(at) -->
<span id="echo" aria-hidden class="pointer-events-none absolute inset-x-0 h-px" style="top: 689vh"></span> <!-- 0.53 × 1300 -->
<span id="details" aria-hidden class="pointer-events-none absolute inset-x-0 h-px" style="top: 858vh"></span> <!-- 0.66 × 1300 -->
<div class="sticky top-0 h-dvh w-full overflow-hidden">
… the canvas (z-1), the quote link (z-10), the grid (z-2), three hero blocks (z-5),
the ECHO layer (z-10), the details layer (z-20) …
</div>
</section>
```
The scene's spring: `p` on `TIMELINE_FOLLOW`, set to the raw track progress every frame it changes
(`"top top"` → `"bottom bottom"`, no throttle). `sceneFlags(progress)` is recomputed on the same
frame and applied only when a flag flips.
**"Get a quote"** — an `ArrowLink` (below) at `absolute right-10 top-10 z-10` (`max-md: right-6
top-6`), `href="#contact"`.
**The grid** — `pointer-events-none absolute inset-0 z-2`, a `Spring` from `opacity 0` to `1` on
`GRID_INTRO` enabled by `introReady`; inside it a layer whose `opacity = gridOpacity(p)` and
`visibility = gridVisibility(p)` holding the **GridLines**: `absolute inset-x-8 inset-y-0 grid
grid-cols-4`, four cells each `border-l border-foreground/30`, the last also `border-r` — five
hairlines, drawn as the borders of a four-column grid so anything laid on the same grid lands on a
line by construction.
### The frame sequence — `frames.ts` and the canvas, verbatim
```js
const FRAME_TIERS = {
desktop: { name: "desktop", count: 210, width: 1920, height: 1080 },
mobile: { name: "mobile", count: 105, width: 1280, height: 720 },
};
/** Below this width, or on a coarse pointer, the mobile tier is used. */
const TIER_BREAKPOINT = 1024;
const COARSE_POINTER_QUERY = "(hover: none) and (pointer: coarse)";
/** Frames in flight at once — enough to fill the pipe, few enough to stay ordered. */
const FETCH_CONCURRENCY = 6;
/** Read ONCE, at mount. A window dragged across the breakpoint keeps the frames it loaded with. */
const pickFrameTier = () => {
const coarse = window.matchMedia(COARSE_POINTER_QUERY).matches;
return window.innerWidth < TIER_BREAKPOINT || coarse ? FRAME_TIERS.mobile : FRAME_TIERS.desktop;
};
const ASSET_BASE = "https://storage.getlayers.ai/assets/house-22508188f8";
const framePath = (tier, index) =>
`${ASSET_BASE}/scene/sequence/${tier.name}/${String(index).padStart(3, "0")}.webp`;
/** The first desktop frame — the poster under the canvas. */
const FRAME_POSTER = framePath(FRAME_TIERS.desktop, 0);
/**
* Fetch and decode the set, in order, a few at a time. `decode()` resolves once the frame is
* paintable, so the first `drawImage` of it never stalls on a synchronous decode mid-scroll.
* A frame that fails is reported as `null` and the canvas falls back to its nearest neighbour.
*/
const loadFrames = async (tier, onFrame, signal) => {
let cursor = 0;
const pump = async () => {
while (!signal.aborted) {
const index = cursor;
cursor += 1;
if (index >= tier.count) return;
const image = new Image();
image.decoding = "async";
image.crossOrigin = "anonymous";
image.src = framePath(tier, index);
let loaded = image;
try { await image.decode(); } catch { loaded = null; }
if (signal.aborted) return;
onFrame(index, loaded);
}
};
await Promise.all(Array.from({ length: FETCH_CONCURRENCY }, pump));
};
```
**Every frame `Image` and the showreel `<video>` MUST carry `crossOrigin = "anonymous"`** — they
are drawn to a canvas from another origin; without it the canvas is tainted and `drawImage` is
refused silently. The bucket already answers with `access-control-allow-origin: *`.
The canvas layer — `absolute inset-0 z-1` with `opacity = canvasOpacity(p)` — holds the **poster**
(frame zero, `<img>` filling the box, `object-fit: cover`, loaded eagerly) and, over it, the
`<canvas aria-hidden class="absolute inset-0 size-full object-cover">` sized to the tier's pixels:
```js
const tier = pickFrameTier();
canvas.width = tier.width; canvas.height = tier.height;
const frames = new Array(tier.count).fill(null);
let settled = 0, painted = -1;
/** The nearest frame that has arrived — a fast flick early in loading shows the last frame that made it. */
const nearest = (index) => {
for (let step = 0; step < tier.count; step += 1) {
const image = frames[index - step] ?? frames[index + step];
if (image) return image;
}
return null;
};
const draw = (force = false) => {
const index = sequenceFrame(p.get(), tier.count);
if (!force && index === painted) return;
const image = nearest(index);
if (!image) return;
context.drawImage(image, 0, 0, tier.width, tier.height);
painted = index;
};
loadFrames(tier, (index, image) => {
frames[index] = image;
settled += 1;
store.framesProgress = settled / tier.count;
if (image) draw(true);
}, controller.signal);
// on the shared ticker, every frame:
if (!flags.canvas || document.hidden) return;
draw();
```
The canvas draws only when the wanted frame index changed, the canvas is still visible (`flags.canvas`)
and the tab is in front — a held scroll costs nothing.
### The three statement blocks
Each block is `pointer-events-none absolute inset-0 z-5` with `visibility = heroVisibility(index)(p)`
and, for block three only, `opacity = heroThreeOpacity(p)`. Inside: a heading (`h1` for block one,
`h2` for the others) `absolute inset-0` holding an `sr-only` copy of the three words, then an
`aria-hidden` `absolute inset-0` **exit layer** with `opacity = exitOpacity(range)`, `transform =
exitTransform(range)`, `filter = exitFilter(range)` for blocks one (`heroOneExit`) and two
(`heroTwoExit`); block three has no exit layer. Each word sits in its own `absolute` wrapper at its
slot — the positioning goes on the **wrapper**, never on the letter engine:
| slot | classes |
|---|---|
| top-left | `left-10 top-10 max-md:left-6 max-md:top-24` |
| top-right | `right-10 top-10 max-md:right-6 max-md:top-24` |
| center-left | `left-10 top-1/2 -translate-y-1/2 max-md:left-6` |
| center-right | `right-10 top-1/2 -translate-y-1/2 max-md:right-6` |
| center | `left-1/2 top-1/2 -translate-1/2` |
| bottom-left | `bottom-10 left-10 max-md:bottom-52 max-md:left-6` |
| bottom-right | `bottom-10 right-10 max-md:bottom-52 max-md:right-6` |
Word type: `text-display-phone leading-hero tracking-display sm:text-display-tablet lg:text-display`.
- **Block one** — `Harmony` top-left · `in` center-right · `House` bottom-left. Letters on
`SLIDE_BLUR`, mode `once` enabled by `introReady`, `letterStagger = 750 / word.length` ms,
`letterConfig = HERO_INTRO.config` (750 ms, quart out) — the letters of each word start across
half of 1.5 s and each takes the other half. Description right-aligned.
- **Block two** — `Precision` top-right · `through` center-left · `Detail` bottom-right. Letters on
`SLIDE_BLUR`, mode `progress` / `toggle` against the **heroTwoEnter** trigger box, `letterConfig
= SCROLL_LETTER`. Description left.
- **Block three** — `Exquisite` top-left · `by` center · `Nature` bottom-right. Same as block two,
against the **heroThreeEnter** box. Description left.
The **description** is `absolute bottom-10 w-80 text-body-phone leading-copy tracking-copy
text-foreground/70 sm:text-body` (`max-md: inset-x-6 bottom-28 w-auto`), on the right (`right-10
text-right max-md:text-left`) or the left (`left-10 text-left`), with `opacity =
heroDescriptionOpacity[index](p)`. The three descriptions, verbatim:
1. "A cinematic journey through premium architectural forms, exquisite glass craft, and bespoke minimalist living spaces."
2. "Exploring the delicate balance between structural transparency, premium framing, and high-performance bespoke glass panels."
3. "Bringing the surrounding landscape inside, blurring visual boundaries, and creating light-filled sanctuaries of calm."
### ECHO — interior one
`absolute inset-0 z-10 overflow-hidden` with `transform = echoTransform(p)` (rides up from 100%
below). Inside: a full-bleed image layer with `transform = interiorScale(PHASE.echo)(p)` holding
`scene/interior-echo.webp` (1075×565, `object-fit: cover`; alt "Sunlit open-plan living space
behind floor-to-ceiling glazing, with oak lounge chairs and a timber kitchen island"). Over it the
**panel**: `<article aria-labelledby="echo-title">`, `absolute inset-y-0 left-0 z-15 flex w-1/2
flex-col justify-between border-r border-foreground/8 bg-surface-glass-raised/45 px-14 py-14
backdrop-blur-2xl` (`max-md: w-full px-8 pb-32 pt-12`):
- top: `RevealLines` p — lines `["Award winning", "Project 2026"]`, timing `label`, active on
`flags.echoLead`, in `text-title-phone uppercase tracking-title text-foreground/95 sm:text-title`;
- middle (`mt-10 flex grow flex-col justify-center`): `<h2 id="echo-title">` — `mb-6 flex
items-start` in display type, `uppercase leading-hero tracking-display` — with `sr-only` "Echo®",
then the letter engine on **"Echo"** (`SLIDE_BLUR`, `progress`/`toggle` against the **echo**
trigger box, `SCROLL_LETTER`), then `<sup aria-hidden class="ml-1 mt-[0.12em] text-[0.35em]
leading-none">®</sup>`; under it the description `max-w-[85%] text-body-phone leading-copy
tracking-copy text-foreground/70 sm:text-body` (`max-md:max-w-none`): "Step back in time with our
latest architectural showcase, a homage to the vibrant and eclectic era of modern glass.";
- bottom (`mt-8 flex flex-col gap-3`): a `flex gap-4` row of three icons — plus-circle, globe,
play-circle — each `size-6`, `transition-transform 200ms ease-glide hover:scale-110`; then
`RevealLines` p — `["Portfolio", "Arch.Mono"]`, timing `label`, active on `flags.echoFoot`, same
label type.
### Details — interior two
`absolute inset-0 z-20 overflow-hidden` with `clip-path = detailsClip(p)` (unmasked bottom-up).
Inside, in order:
1. The image layer, `transform = interiorScale(PHASE.details)(p)`, `scene/interior-details.webp`
(1200×800, cover; alt "Dusk-lit lounge with walnut-panelled walls, a grey sectional sofa and a
glowing paper floor lamp").
2. The **cross**: `pointer-events-none absolute inset-0 z-22` with a horizontal hairline
`absolute inset-x-0 top-1/2 h-px bg-foreground/18` and a vertical `absolute inset-y-0 left-1/2
w-px bg-foreground/18`.
3. The title `<h2>`: `absolute left-10 top-10 z-25 flex flex-col text-title-phone uppercase
leading-display tracking-title sm:text-title` — two lines, "Realization of your" / "imagination
in space".
4. Three **stat counters** in a `<dl class="contents">`.
5. The **zoom overlays**.
6. The **star**: `pointer-events-none absolute left-1/2 top-1/2 z-30 aspect-square w-star
-translate-1/2` (2500px square, `translate: -50% -50%` as a property) with `transform =
starTransform(p)`. An SVG `viewBox="0 0 24 24"` filling it with **two stacked paths** of the same
shape — the first `fill: var(--foreground)`, the second `fill: var(--foreground-inverse-muted)`
with `opacity = starTint(p)`. Compositing at opacity *t* is the white → sage mix. Never animate a
colour string; animate this number.
```
M0 12C6 12 12 6 12 0C12 6 18 12 24 12C18 12 12 18 12 24C12 18 6 12 0 12Z
```
7. The **showreel**.
**Stat counter** — `absolute z-25 flex flex-col-reverse gap-3` (`max-md: max-w-[42%] gap-2`) at:
| slot | classes |
|---|---|
| top-right | `right-10 top-10 items-end text-right max-md:right-6 max-md:top-36` |
| bottom-left | `bottom-10 left-10 items-start max-md:bottom-32 max-md:left-6` |
| bottom-right | `bottom-10 right-10 items-end text-right max-md:bottom-32 max-md:right-6` |
`<dt>` label: `text-body-phone uppercase leading-copy tracking-copy text-foreground/70 sm:text-body`.
`<dd>` value: `text-stat-phone tabular-nums leading-hero tracking-display md:text-display-tablet
lg:text-display`, holding an `sr-only` real value and an `aria-hidden inline-block` drum. A spring
`n` runs to the value on `COUNT_UP` (2.2 s, quint out) while `flags.details`, and drops to 0
**instantly** when it clears. With `rest = 1 - n / value`: `filter: blur(${rest * 0.5}rem)`,
`transform: translate3d(0, ${rest * 1.25}rem, 0)`, text `Math.floor(n)`. The three: **300**
"Projects completed" top-right · **10** "Years of experience" bottom-left · **32** "Successful
partnerships" bottom-right.
**Zoom overlays** (all `max-md:hidden`; `visibility` follows opacity so hidden links leave the tab
order):
- lead: `absolute left-10 top-1/2 z-45 w-overlay-lead` (48vw), `opacity = overlayOpacity(p)`,
`visibility = overlayVisibility(p)`, `transform = overlayLeadTransform(p)`; inside, `RevealLines`
h3 — `["A place where precision", "and creativity connect"]`, timing `label`, active on
`flags.zoom`, `text-headline-phone uppercase leading-heading tracking-title sm:text-headline`;
- aside: `absolute right-10 top-1/2 z-45 flex w-overlay-aside flex-col items-end` (22vw), same
opacity/visibility, `transform = overlayAsideTransform(p)`; an `<address class="flex flex-col
items-end gap-2 text-right text-body not-italic leading-copy tracking-copy text-foreground/70">`
with an `uppercase` "Address" and two block lines "Orbital 25 Business Park, Unit 11" / "Watford
WD18 9DA, UK";
- the **Showroom** link: `absolute inset-x-10 top-10 z-45 flex items-center gap-3 border-t
border-foreground/22 pt-4 text-body uppercase tracking-copy`, same opacity/visibility, `target=
"_blank" rel="noopener"` to
`https://www.google.com/maps/search/?api=1&query=Orbital+25+Business+Park+Unit+11+Watford+WD18+9DA`;
a `✦` (`inline-block leading-none`, rotates 90° on hover over 350 ms ease-glide) then "Showroom";
hover: `-translate-y-0.5`, border and text to `foreground/85`, 350 ms ease-glide.
**Showreel** — `absolute left-1/2 top-1/2 z-40 aspect-showreel w-showreel` (65vw wide, 65/37) with
`clip-path = showreelClip(p)` and `transform = showreelTransform(p)`. The `<video muted loop
playsinline preload="metadata" crossorigin="anonymous" poster="…/scene/showreel-poster.webp"
aria-label="Keld Studio showreel">` with `<source src="…/scene/showreel.mp4" type="video/mp4">`,
`size-full object-cover grayscale`, `transition: filter 600ms ease-glide`, `hover:grayscale-0`.
`video.play()` (errors swallowed — a refused autoplay leaves the poster, the right failure) when
`flags.showreel` turns true, `pause()` when it drops. The **PLAY badge**: appended to `<body>`
(every scene ancestor carries a transform, which would make `fixed` relative to it), `pointer-events-
none fixed left-0 top-0 z-110`, four springs on `BADGE_FOLLOW` — `x`, `y` (pixel position, written
as `transform: translate3d(x, y, 0)`), `opacity`, `scale`; on `mouseenter` of the video set `x, y`
to the cursor **immediately** and spring `opacity → 1, scale → 1`; on `mousemove` spring `x, y` to
the cursor; on `mouseleave` spring `opacity → 0, scale → 0.7`. The badge itself: `block
-translate-1/2 rounded-sm border border-foreground/25 bg-foreground/12 px-6 py-2.5 text-badge
font-semibold uppercase tracking-caps text-foreground shadow-lg backdrop-blur-md` reading "Play".
## Client stories
```html
<section id="reviews" aria-labelledby="reviews-title"
class="relative z-50 flex flex-col bg-surface-inverse p-10 text-foreground-inverse md:h-dvh max-md:px-6 max-md:py-20">
```
**Header row** — `flex flex-wrap items-center justify-between gap-4 border-t
border-foreground-inverse/18 pt-4`: `<h2 id="reviews-title">` `flex items-center gap-2
whitespace-nowrap text-caption font-medium uppercase tracking-caps` with a `✦` in
`foreground-inverse-muted` then "Client stories"; a pager `<p aria-live="polite">` in the same
type, `foreground-inverse-muted`, reading **"01 / 01"** (zero-padded, `index + 1` of `total` — the
count is the data's); two `flex size-11 items-center justify-center border
border-foreground-inverse-muted/20` buttons (`aria-label` "Previous story" / "Next story", a 16px
arrow-left / arrow-right icon), `transition background-color, border-color 350ms ease-glide`,
`hover: border-foreground-inverse bg-foreground-inverse-muted/5`, **disabled** (`opacity-40`,
`pointer-events-none`) while there are fewer than two stories — there is one.
**The story** in a `mt-10 grow` wrapper that cross-fades its content on a `gentle` spring
(tension 120, friction 14) when the index changes — `<figure class="grid h-full items-center gap-10
md:grid-cols-[12.5rem_1fr] lg:grid-cols-[23.75rem_1fr]">`:
- the portrait box `relative aspect-3/4 w-full overflow-hidden bg-surface-inverse-sunken
max-md:max-w-60 md:-translate-y-17.5` (up 4.375rem from `md`), holding a `Hover` span (`absolute
inset-0 block`, `scale 1 → 1.05` on `PORTRAIT_HOVER`) with `testimonials/founder.webp`
(1024×1024, cover, **grayscale**; alt "Vaidas Vileikis at a drafting table with site plans, in
black and white");
- the copy column `flex flex-col md:translate-y-18`: the opening quotation mark `“` as an
`aria-hidden` span `mb-4 text-quote-mark-phone font-light leading-[0.5] md:mb-10
md:-translate-x-5 md:-translate-y-40 md:text-quote-mark`; a `<blockquote class="mb-10">` with
`RevealLines` p, one line — "Unrivaled customer service, cutting edge design and quality. Fluid
Glass is firmly lodged in our list of preferred suppliers of glazing products." — always active,
`clip: false`, timing `quote`, `rootMargin "0px 0px -15% 0px"`, `text-headline-phone uppercase
leading-heading tracking-title sm:text-headline`; a `<figcaption class="md:translate-y-10">` with
"Vaidas Vileikis" (`text-lead font-medium uppercase tracking-copy`) and "Founder, Name Architects"
(`mt-2 text-body leading-copy tracking-copy text-foreground-inverse/70`).
At the section's foot, the **dock sentinel**: an `absolute inset-x-0 bottom-0 h-px` span observed
by an `IntersectionObserver`; `store.dockHidden = entry.boundingClientRect.top < window.innerHeight`
on every callback — the dock is aside from the moment the reviews' bottom edge reaches the bottom of
the viewport, and stays aside while that edge is anywhere above it.
## The reveal footer
Below 768 the footer is an ordinary block in flow. From `md`:
- a **spacer** `<div aria-hidden class="hidden h-footer-reveal md:block">` (85vh) follows the
reviews;
- the `<footer>` is `fixed inset-x-0 bottom-0 h-footer-reveal z-10 flex overflow-hidden
bg-surface-footer text-foreground` — **`visibility: hidden` until the spacer's progress is
above 0** (so a covered full-screen photograph is never painted under the scene);
- the spacer's progress (`"top bottom"` → `"bottom bottom"`, no throttle) feeds a spring `f` on
`FOOTER_FOLLOW`, flips `revealed` on `progress > 0`, and flips `ctaActive` on `progress >= 0.2`.
Phone classes: `max-md: relative flex-col items-center px-6 pt-20`. Contents, in order:
1. `footer/footer-bg.webp` (2560×1429) filling the box, `object-fit: cover`, alt "";
2. `aria-hidden absolute inset-0 bg-surface-footer/20`;
3. **GridLines** with `z-2 max-md:inset-x-6`;
4. the content grid `relative z-15 flex flex-col items-center gap-10 md:absolute md:inset-x-8
md:top-10 md:grid md:grid-cols-4 md:items-start` — the same four columns as the hairlines, so
its children sit 20px right of line one and line four:
- `flex flex-col items-center gap-8 md:col-span-2 md:max-w-126 md:items-start md:pl-5`:
`RevealLines` h2 — `["Ready to shape", "your vision in space?"]`, timing `cta`, active when
stacked (phone) or `ctaActive`, `text-headline-phone uppercase leading-heading tracking-title
sm:text-headline`, engine `justify-center text-center md:justify-start md:text-left`; then an
`ArrowLink` **glass** variant "Contact" → `#contact`;
- `<nav aria-label="Footer" class="md:col-start-4 md:pl-5">` with a `flex flex-wrap
justify-center gap-6 md:flex-col md:gap-4.5` list of **Home** `#top` · **Echo** `#echo` ·
**Details** `#details` · **Reviews** `#reviews`, each `inline-block text-lead font-medium
uppercase tracking-copy`, hover `translate-x-1 opacity-70` over 350 ms ease-glide;
5. the **wordmark**: `<p class="relative z-5 flex w-full justify-center max-md:mt-16 md:h-full
md:items-end">` with `sr-only` "Keld Studio" and an `aria-hidden` span `text-center
text-wordmark-phone leading-wordmark tracking-display md:translate-y-wordmark-drop
md:whitespace-nowrap md:text-wordmark` (19.5vw; `translate: 0 calc(11vw - 12.5rem)` seats it on
the bottom edge). Each word is an `inline-block whitespace-nowrap` span of `inline-block` letters;
letter *i* of its word has `transform = wordmarkShift(f, i)` — on desktop only; on a phone the
letters are still.
6. after the footer, `<span id="contact" aria-hidden class="block h-px">` — `#contact` lands at
the very end of the page, the footer fully uncovered.
## The dock
`pointer-events-none fixed inset-x-0 bottom-10 z-100 flex justify-center` (`max-md:bottom-6`).
Inside, a `<nav aria-label="Primary">` that is a `Spring` from `opacity 1, y 0` to `opacity 0, y
40` on `DOCK_TOGGLE`, **enabled by `store.dockHidden`** (it animates *away* when hidden), `inert`
while hidden, `pointer-events-auto` while not:
- the **menu** `<ul id="dock-menu">`: `absolute inset-x-0 bottom-full mb-2 flex flex-col gap-3
border border-foreground/10 bg-surface-glass/85 p-5 backdrop-blur-2xl`, a `Spring` from
`opacity 0, y 12` to `opacity 1, y 0` on `DOCK_TOGGLE` enabled while open (and not hidden),
`inert` and `pointer-events-none` otherwise; links **Home · Echo · Details · Reviews · Contact**
(`#top #echo #details #reviews #contact`), `text-lead font-medium uppercase tracking-copy`,
hover `opacity-70` 200 ms; clicking one closes the menu; `Escape` closes it;
- the **bar**: `flex h-16 w-90 items-center justify-between border border-foreground/10
bg-surface-glass/85 px-5 backdrop-blur-2xl` (`max-sm:w-70`), `transition background-color,
border-color, translate 350ms ease-glide`, hover `-translate-y-0.5 border-foreground/22
bg-surface-glass-raised/95`; a home link (`aria-label` "Keld Studio — back to top", `#top`) with
the **LogoMark** at `size-6.5`, hover `opacity-80`; the label "Home" in `text-caption uppercase
leading-display tracking-caps-wide`; a `<button aria-expanded aria-controls="dock-menu">`
(`aria-label` "Open menu" / "Close menu") showing the menu icon or the close icon at `size-5.5`.
## Shared pieces
**ArrowLink** — `<a class="group inline-flex w-fit items-center gap-3 text-foreground
transition-[opacity,translate,background-color,border-color] 350ms ease-glide">`: the corner-arrow
icon `size-4.5` (`transition-transform 350ms ease-glide`, `group-hover: translate-x-0.5
translate-y-0.5`) then the label in `text-caption leading-display tracking-caps uppercase`.
Variant **plain** (on the scene): hover `opacity-80 translate-y-0.5`. Variant **glass** (footer):
`rounded-sm border border-foreground/8 bg-surface-glass/75 px-7 py-3.5 backdrop-blur-xl`, hover
`-translate-y-0.5 border-foreground/22 bg-surface-glass-raised/95`. External hrefs get
`target="_blank" rel="noopener"`.
**Icons** — all `viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round"
stroke-linejoin="round" aria-hidden focusable="false"`:
```
corner arrow (stroke 1.8): M8 8v8h8 · M12 12l4 4-4 4
plus circle (1.8): circle 12,12 r10 · M8 12h8 · M12 8v8
globe (1.8): circle 12,12 r10 · M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20
play circle (1.8): circle 12,12 r10 · m10 15 5-3-5-3v6Z
arrow left (1.5): line 19,12 → 5,12 · polyline 12 19 5 12 12 5
arrow right (1.5): line 5,12 → 19,12 · polyline 12 5 19 12 12 19
menu (1.5): lines y=6, y=12, y=18 from x4 to x20
close (1.5): line 6,6 → 18,18 · line 18,6 → 6,18
logo mark (1.8): M12 2.5L3.5 7.5v9l8.5 5 8.5-5v-9L12 2.5z · M7.5 10l3.5-2
· M12 12.5v6l5.5-3.2v-6L12 12.5z with fill="currentColor" stroke="none"
```
**Focus rings** — every link and button: `focus-visible: outline 1px, offset 4px` (2px on the
pager buttons) in the surrounding foreground colour.
## Responsive rules, in one place
- **Root scale**: 16px at 1440, `1.111111vw` below, `1.5625vw` below 1024, `4.444444vw` below 640,
damped growth above 1440 (`coef 0.6666`).
- **Frame tier**: `innerWidth < 1024` or a coarse pointer → the 105-frame 1280×720 set; read once.
- **Below 768** (`max-md`): scene margins 2.5rem → 1.5rem; the top words drop to `top-24`, the
bottom words lift to `bottom-52`, descriptions go `inset-x-6 bottom-28`; the ECHO panel is full
width (`px-8 pb-32 pt-12`); the top stat drops to `top-36` and the bottom stats sit at
`bottom-32`, each `max-w-[42%]`; the zoom overlays and the Showroom link are hidden; reviews
`px-6 py-20` with the portrait `max-w-60`; the footer is an ordinary block (`pt-20`, the wordmark
`18vw` and still); the dock sits at `bottom-6`.
- **Below 640** (`max-sm`): the dock bar is `17.5rem` wide; display type is the phone size
(3.75rem) and stats `2.25rem`.
- **`prefers-reduced-motion: reduce`**: every spring jumps straight to its end state (react-spring's
global `skipAnimation`); the scroll-scrubbed selectors still follow the scroll.
## Accessibility
One `<h1>` (block one's three words, in the `sr-only` copy). Every split text carries its plain
sentence once in an `sr-only` span and hides the letters with `aria-hidden`. The stats speak their
real value. The preloader and the dock's hidden menu are `aria-hidden` / `inert`. The section is
labelled "House showcase"; the reviews section by its title; the footer nav "Footer"; the dock nav
"Primary". The pager announces politely.
## Fixed parameters (bake these in)
`TRACK_VH 1400` · `PHASE` as listed · `EXIT_SHIFT_REM 2.5` · `EXIT_BLUR_REM 0.75` · star scale
`0.04 + t⁵ × 16` · showreel `65vw`, ratio `65/37`, zoom `× 1.8`, reveal lift `10%` ·
`OVERLAY_SHIFT_REM 7.5` · drum blur `0.5rem`, drop `1.25rem` · preloader clock `2800ms`, column
stagger `100ms`, intro after `1200ms`, unmount after `1600ms`, odometer exit stagger `50ms` ·
`FOOTER_CTA_AT 0.2` · wordmark `EVEN {0.02, 0, 0.48, 5.625rem}` / `ODD {0.12, 0.72, 8.75rem}` ·
footer reveal `85vh` · `FETCH_CONCURRENCY 6` · `TIER_BREAKPOINT 1024` · hover disabled at `≤ 768` ·
every spring config in the presets block above.
## Assets
```
ASSET_BASE_URL = https://storage.getlayers.ai/assets/house-22508188f8
```
Load every file from that folder, absolutely. The frame sequences are two tiers, zero-padded:
| what | URL | notes |
|---|---|---|
| desktop frames | `…/scene/sequence/desktop/000.webp` … `…/scene/sequence/desktop/209.webp` | **210 frames**, 1920×1080, 14.2 MB in all (≈ 55–105 KB each) |
| mobile frames | `…/scene/sequence/mobile/000.webp` … `…/scene/sequence/mobile/104.webp` | **105 frames**, 1280×720, every other frame of the desktop set, 3.3 MB in all |
| showreel | `…/scene/showreel.mp4` | 1600×844 H.264, no audio, 4.4 MB, loops |
| showreel poster | `…/scene/showreel-poster.webp` | 52 KB |
| ECHO interior | `…/scene/interior-echo.webp` | 1075×565, 105 KB |
| details interior | `…/scene/interior-details.webp` | 1200×800, 65 KB |
| footer photograph | `…/footer/footer-bg.webp` | 2560×1429, 339 KB |
| portrait | `…/testimonials/founder.webp` | 1024×1024, 106 KB |
The poster under the canvas is `…/scene/sequence/desktop/000.webp`. **Every frame `Image` and the
`<video>` must carry `crossOrigin = "anonymous"` / `crossorigin="anonymous"`** — the frames are
drawn to a canvas and the bucket is another origin. **A failed load must be visible**: if a frame
set or the video cannot be fetched, show a banner naming the URL rather than a black stage over a
correct DOM.[/hidecontent]