类型:Site模板 · 收录:2026-11-05 01:12:48 · 源码:会员可见
# Recreate this site as a single HTML file: Kumo — Layers, a hand-painted animation studio
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.
Use pure HTML/CSS/JS in one file, with no build step, framework or bundler. ES modules go inline in a
`<script type="module">`. **Lenis is the only external code**, pulled through an import map. You write
everything else by hand: the spring engine, the shared ticker, the scroll-progress triggers, the pinned
scenes, the image-sequence painter, the cloud curtains, the text reveals, the 3D film ring, the
preloader and the CRT overlay. Hardcode every value given here as a fixed constant. Where this document
gives code, it is the shipped behaviour: use it as it stands, and treat every number in it as the
shipped default.
```js
const ASSET_BASE_URL = "https://storage.getlayers.ai/assets/kumo-0d94304971";
```
Every image, frame and mask below loads from that base (the bucket sends
`Access-Control-Allow-Origin: *`, so CSS masks, `fetch()` and canvas `drawImage` all work cross-origin).
## What it is
A one-page site for **Layers**, a small hand-painted 2D animation studio ("We paint the city one
frame at a time."). The whole page is **one continuous, scroll-scrubbed camera move** through an
anime-painted autumn city. Three 150-frame image sequences are painted onto canvases and scrubbed by
scroll position: a sunlit street flythrough, a schoolgirl looking up at towers while leaves fall, and a
camera that moves from the street through a sunlit studio and out of its window to the skyline. They are
stitched into one shot by **cloud curtains**: soft, pre-blurred cloud cut-outs drift in from opposite
sides, meet across the screen, and carry straight on out of the far side. The incoming shot is already
moving under them as they part. Display copy **writes itself in letter by letter out of a blur**, and
small copy surfaces line by line out of a light mist. Each chapter carries a vertical kanji mark that
is drawn stroke by stroke (秋の街 Autumn City, 見上げる Look Up, 描く To Draw, 作品 The Works).
The palette is warm paper `#f4ece0`, near-black ink `#14110f`, one antique-gold accent `#9a7536`, a
cloud white `#e9eef4`, and an ember orange `#e4602a` that appears only in the phone menu. There are four
faces: **Reggae One** (display), **Onest** (body, nav), **IBM Plex Mono** (labels, tickers, the ASCII
art) and **Noto Serif JP 700** (the kanji). A fixed **CRT treatment** sits over the whole viewport:
drifting gold scanlines, an edge vignette, film grain, and rounded black screen corners.
The order is:
0. **Preloader**: an ASCII-art TV on paper that draws a picture of the city line by line while the first
frames load, with a `000%` → `100%` counter. Then the TV screen grows into the full viewport and
becomes a window onto the live page underneath.
1. **Hero** (over the street sequence's first frame): "We paint the city / one frame at a time.",
the studio intro, the 秋の街 mark, and a frosted-glass film ticker.
2. **Studio caption**: "Small team. / Slow pencils. / Big skies." over the street flythrough.
3. **Sky wipe**: clouds roll across the street and part onto the girl looking up (見上げる).
4. **What we make**: "Films, clips / and brand stories" over the girl sequence.
5. and 6. **Cloud pass**: the clouds close over her. "When did you / last look up?" is written by the
scroll, then "It all starts in one room." as the clouds part onto the studio's street (描く).
7. **Process**: "Sketch, layout, / paint, motion." as the camera moves into the studio and out of its
window.
8. **Works intro**: out of the window the city sinks into cloud. Cloud banks rise, turn to paper along a
cloud-shaped edge, and the paper closes over the screen while the held frame keeps pushing in.
"Selected works" and a numbered film index sit on that paper.
9. **Works ring**: two counter-rotating CSS-3D carousels of film stills turn with the scroll. The film
at the front is written in the middle.
10. **Contact**: back to the sky. A huge running line ("Have a story that needs a sky? · Write to us")
writes itself in, followed by the email, four footer columns and a closing ticker.
The fixed header's ink crossfades between dark (over bright sky, cloud and paper) and light (over the
darker shots), driven by the scroll of whichever scene is pinned.
**Everything that moves is a spring or a timed tween, never a CSS keyframe.** The only CSS transitions are
the nav label rolling up to its Japanese twin (350ms) and the menu links' hover colour (150ms).
## Page shell & libraries
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#f4ece0">
<title>Layers — Hand-painted 2D animation studio</title>
<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=Onest:wght@100..900&family=Reggae+One&family=IBM+Plex+Mono:wght@400;600&display=swap" rel="stylesheet">
<!-- Noto Serif JP 700, only the glyphs the copy uses -->
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+JP:wght@700&display=swap&text=%E5%B1%A4%E4%BD%9C%E5%93%81%E3%82%AF%E3%83%AA%E3%83%83%E3%83%97%E3%83%96%E3%83%A9%E3%83%B3%E3%83%89%E6%98%A0%E5%83%8F%E7%A7%81%E3%81%9F%E3%81%A1%E5%B7%A5%E7%A8%8B%E6%97%A5%E8%AA%8C%E3%81%93%E3%82%93%E3%81%AB%E3%81%AF%E6%8E%A1%E7%94%A8%E5%A7%8B%E3%82%81%E3%81%BE%E3%81%97%E3%82%87%E3%81%86%E7%A7%8B%E3%81%AE%E8%A1%97%E8%A6%8B%E4%B8%8A%E3%81%92%E3%82%8B%E6%8F%8F%E3%81%8F" rel="stylesheet">
<script type="importmap">
{ "imports": { "lenis": "https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.mjs" } }
</script>
```
Font stacks: `--font-sans: "Onest"`, `--font-display: "Reggae One"`, `--font-mono: "IBM Plex Mono"`,
`--font-kanji: "Noto Serif JP"`. Each one falls back to `ui-sans-serif, system-ui, sans-serif`, or to
`ui-monospace, monospace` for mono and `serif` for kanji. The page default is Onest.
**Reset** (Tailwind-preflight equivalent): `*, ::before, ::after { box-sizing: border-box; margin: 0;
padding: 0; border: 0 solid; }`, `html { line-height: 1.5; -webkit-text-size-adjust: 100%; font-family:
var(--font-sans) }`. Headings inherit font-size and font-weight (`h1,h2,h3 { font-size: inherit;
font-weight: inherit }`). Lists have no bullets. Links are `color: inherit; text-decoration: inherit`.
`img, canvas { display: block; max-width: 100% }`. Buttons are unstyled (`background: none; font:
inherit; color: inherit; cursor: pointer`). Add `.sr-only` (the standard visually-hidden clip).
`body { background: #ffffff; color: #171717; min-height: 100svh; width: 100% }`. Under
`prefers-color-scheme: dark` this becomes `#0a0a0a` / `#ededed`. It is almost never seen, because every
block paints its own ground.
**No visible scrollbar**: `html { scrollbar-width: none } html::-webkit-scrollbar { display: none }`.
The page is a scrubbed timeline, and a thumb jumping through pinned scenes reads as a broken control.
Wheel, touch and keys still scroll.
**Smooth scroll**: on load, call `window.scrollTo(0, 0)`, then
`const lenis = new Lenis({ smoothWheel: true })` driven by its own
`requestAnimationFrame(t => { lenis.raf(t); … })` loop. **Stopping scroll** (during the preloader, and
while the phone menu is open) means `lenis.stop()` plus `html.style.position = "relative";
html.style.overflow = "hidden"; html.style.height = "100%"`. **Starting it again** means `lenis.start()`
and removing those three properties.
## Tokens
```css
:root {
--paper: #f4ece0;
--ink: #14110f;
--ink-over-image: #ffffff; /* type set straight over a picture */
--accent: #9a7536; /* gold */
--ascii: #3d66b8;
--ascii-deep: #1f3b6e;
--bezel: #0a0908;
--cloud: #e9eef4;
--ember: #e4602a;
--glass: rgb(255 255 255 / 0.28);
--glass-edge: color-mix(in srgb, #ffffff 60%, transparent);
--bezel-edge: color-mix(in srgb, #0a0908 25%, transparent);
--shadow-legible: 0 1px 0.75rem rgb(20 17 15 / 0.45);
--shadow-mark: 0 0 1px rgb(20 17 15 / 0.85), 0 1px 0.1875rem rgb(20 17 15 / 0.7), 0 0 0.75rem rgb(20 17 15 / 0.5);
--shadow-card: 0 0.5rem 1.5rem rgb(20 18 15 / 0.12);
--shadow-sheet: 0 0.5rem 2rem rgb(20 17 15 / 0.08);
--blur-cloud-far: 0.1875rem;
--blur-cloud-near: 0.125rem;
--blur-cloud-soft: 0.09375rem;
--tracking-slate: 0.0625rem;
--tracking-tight: -0.0625rem;
--ease-entrance: cubic-bezier(0.2, 0, 0, 1);
--layer-header: 80;
--layer-intro: 90;
--layer-overlay: 100;
}
```
Wherever this document writes `ink/72`, it means `--ink` at 72% alpha (`rgb(20 17 15 / 0.72)`), and the
same notation applies to `paper/90` and the rest. Gradients run in oklab, which is Tailwind v4's
default: `linear-gradient(to top in oklab, …)`.
### Breakpoints, which are used by everything below
There are four layouts, matching the four Figma frames (1440, 1024, 768, 390). **Orientation picks a
layout as well as width.**
- **tablet**: `(max-width: 1024px)`. The root-token block and the `html` font-size use this.
Component overrides use the equivalent `(width < 64.0625rem)`.
- **portrait**: `(width < 64.0625rem) and (orientation: portrait), (width < 48.0625rem) and (height > 31.25rem)`
- **mobile**: `(width < 40.0625rem) and (orientation: portrait), (width < 40.0625rem) and (height > 31.25rem)`
They cascade **tablet → portrait → mobile**, with later ones overriding earlier ones, so write the
media blocks in that order. A phone held sideways (at most 500px tall) keeps the tablet frames.
Below, "tablet:", "portrait:" and "mobile:" mean these queries.
### Root font size (the adaptive grid)
The layout is entirely in `rem`, and the root font size scales so that the reference frames
(1440×900, 1024×768, 768×1024 and 390×844) render 1:1:
```css
html { font-size: 16px; }
@media (max-width: 1440px) { html { font-size: min(1.111111vw, 2svh); } }
@media (max-width: 1024px) { html { font-size: min(1.5625vw, 2.5svh); } }
@media <portrait> { html { font-size: min(2.083333vw, 1.777778svh); } }
@media <mobile> { html { font-size: min(4.102564vw, 2.285714svh, 1.25rem); } }
```
Above 1440px, a script sets the size inline. It runs on load and on every resize:
```js
function applyGrid() {
const root = document.documentElement;
if (innerWidth > 1440) {
const size = 16 - 16 * (((1440 - innerWidth) / 1440) * 100 * 1) / 100; // coef 1 → 16 * innerWidth / 1440
const fit = (16 * innerHeight) / 800; // a wide but short screen scales by height
root.style.setProperty("font-size", `${Math.min(size, fit)}px`);
} else root.style.removeProperty("font-size");
}
```
### Type scale (`font-size / line-height`)
| token | 1440 | tablet | portrait | mobile |
|---|---|---|---|---|
| display | 4.5rem / 5rem | 4rem / 4.5rem | 4rem / 4.5rem | 2.75rem / 3rem |
| running | 8rem / 8.5rem | 6rem / 6.5rem | 6rem / 6.5rem | 4rem / 4.5rem |
| title | 3rem / 3rem | 2.25rem / 2.75rem | 3rem / 3.25rem | 2rem / 2.25rem |
| subtitle | 2.5rem / 3rem | 2.25rem / 2.75rem | 3rem / 3.5rem | 2rem / 2.25rem |
| link | 2rem / 2.25rem | 1.75rem / 2rem | 2rem / 2.5rem | 1.5rem / 2rem |
| body | 1.25rem / 1.75rem | 1.125rem / 1.75rem | 1.5rem / 2.125rem | 1.125rem / 1.625rem |
| nav | 1rem / 1.5rem | = | 1.25rem / 1.75rem | 1rem / 1.5rem |
| label | 0.875rem / 1.25rem | = | 1rem / 1.5rem | 0.875rem / 1.25rem |
| meta | 0.75rem / 1rem | = | 0.875rem / 1.25rem | 0.75rem / 1rem |
| micro | 0.625rem / 0.875rem | = | 0.75rem / 1rem | 0.625rem / 0.875rem |
| kanji | 1.375rem / 1.625rem | = | 1.75rem / 2.125rem | 1.375rem / 1.625rem |
| counter | 2rem / 2.5rem | = | 2.5rem / 3rem | 2rem / 2.5rem |
| menu | 2.75rem / 3.25rem | = | = | 2rem / 2.5rem |
| tag | 1rem / 1.5rem | = | = | 0.9375rem / 1.5rem |
| mark | 2rem / 2.5rem | = | = | 1.75rem / 2.5rem |
| logo | 2rem / (inherits 1.5) | | | |
| kanji-logo | 2.125rem / 1 | | | |
| glyph | 1rem / 1rem | | | |
Make each of these a custom property (`--type-display`, `--type-display-leading`, …) redefined in the
media blocks, and add utility classes `.t-display { font-size: var(--type-display); line-height:
var(--type-display-leading) }` and so on.
**Intro-screen sizes** (TV → opened screen), as `--tv-width/--tv-height/--open-width/--open-height`:
1440: `72rem × 33.0625rem → 84rem × 36.0625rem`; tablet: `60rem × 31.1875rem → 60rem × 34.0625rem`;
portrait: `44rem × 40.0625rem → 44rem × 49.625rem`; mobile: `21.875rem × 32.25rem → 21.875rem × 38.75rem`.
**Ring zoom**: `--ring-zoom: 1` by default and `0.7111` in tablet. Portrait sets
`--ring-zoom-x: 0.6; --ring-zoom-y: 0.815`, and mobile sets `--ring-zoom-x: 0.56; --ring-zoom-y: 0.73`.
## The motion engine you write
### Springs and tweens (react-spring semantics)
Every animated value is a `SpringValue`. Each one has a current value and a target, and is animated
either by **physics** (`{ tension, friction }`, mass 1) or by a **timed tween**
(`{ duration, easing }`). The default config is `{ tension: 170, friction: 26 }`. Also support:
- `set(v)` jumps immediately and stops any animation. Use it wherever this document says *set*,
because those values **are** the scroll position and must have no motion of their own.
- `start(to, config?, delay?)` animates toward `to` and returns a Promise that resolves when it settles.
A new `start` retargets from the current value and velocity.
- Subscribers run on every change, and write styles directly (`el.style.opacity = …`).
- Physics: integrate in 1ms sub-steps of `a = (-tension * (x - to) - friction * v) / 1`. Settle when
`|v| < p && |x - to| < p`, with `p = max(1e-4, |to - from| * 0.001)`.
- Tweens: `x = from + (to - from) * easing(min(1, elapsed / duration))`.
- Easings: `linear t`, `easeOutCubic 1-(1-t)^3`, `easeInCubic t^3`, `easeOutQuint 1-(1-t)^5`.
- **Loops**, used by the CRT drift, marquees and live dot, tween 0 → 1 over a duration and then restart
from 0, forever.
- **Reduced motion** (`matchMedia('(prefers-reduced-motion: reduce)')`, read at startup and on change):
every `start` completes immediately, and loops never start, so they stay at their `from` value.
### One shared ticker
There is a single `requestAnimationFrame` loop that subscribers join, each with its own minimum interval
in ms (a callback runs only if `time - last > interval`). The loop stops when nobody is subscribed.
Lenis drives its own rAF.
### `ramp` and `transformRange`
```js
const transformRange = (value, min, max, newMin, newMax) =>
newMin + ((Math.min(Math.max(value, min), max) - min) / (max - min)) * (newMax - newMin);
/** 0 before the window, 1 after it, linear across it. */
const ramp = (value, [from, to]) => transformRange(value, from, to, 0, 1);
```
### Scroll progress triggers
A trigger reports a 0–1 progress for an element on the ticker, at a 10ms interval, and only while the
element is within a screen of the viewport (an IntersectionObserver gates it). It reports only when the
value changes. Position names:
```js
const bb = el.getBoundingClientRect(), vh = innerHeight;
const poses = {
top_top: bb.top, bottom_top: bb.bottom,
top_bottom: bb.top - vh, bottom_bottom: bb.bottom - vh,
top_center: bb.top - vh / 2, bottom_center: bb.bottom - vh / 2,
};
const s = poses[start], e = poses[end], length = Math.abs(s - e);
const progress = Math.min(Math.max(0, 1 - (s + length) / length), 1);
```
### Pinned scenes
A pinned scene is a `<section>` that is `span × 100svh` tall. It holds one stage: `position: sticky;
top: 0; height: 100svh; width: 100%; overflow: hidden`. Its progress defaults to `start = "top top"`
and `end = "bottom bottom"`, which runs 0 → 1 across the `span − 1` pinned viewports.
**Lead-in scenes** (`leadIn`) get `margin-top: -100svh`. They start one viewport early, over the
previous scene's last pinned viewport, and their stage stays `visibility: hidden` until
`scrollY >= sectionDocumentTop`. At that point they **cut in at the exact scroll position where the
previous scene lets go**. Each previous scene ends on the picture the next one starts on, so the hand-over
is invisible. Make the visibility check a comparison against a cached top, re-measured on resize and on a
`ResizeObserver(document.body)`. Run it in Lenis's `scroll` event (`lenis.on('scroll', ({scroll}) => …)`)
**and** the native `scroll` event, in the same frame the position changes. A lead-in scene must stack
above the one before it (rising `z-index`).
### Visibility detection ("shown")
Text reveals and chapter marks replay each time they are shown. An IntersectionObserver says when the
element is on screen at all. Only then, every 50ms on the ticker, it checks:
`onScreen = rect.bottom > 0 && rect.top < innerHeight * (1 - 0.04)`, then
`visible = onScreen && el.checkVisibility({ opacityProperty: true, visibilityProperty: true })`.
It becomes **shown** when `visible` and the element's opacity multiplied up through every ancestor is
`>= 0.6`. It becomes **hidden** only when it is no longer `visible`, so a fade does not flicker it.
Leaving the IntersectionObserver also reports hidden.
## Text reveals
### Written lines: display headings, letter by letter out of blur
```js
const LETTER_MS = 560, SCATTER_MS = 460, SCATTER_SHARE = 0.45, BLUR_PX = 12;
const scatter = (line, index) => { const x = Math.sin(line * 127.1 + index * 311.7) * 43758.5453; return x - Math.floor(x); };
```
Structure each line as a span (with the caller's class, e.g. `display:block`). Inside it put an
`.sr-only` copy of the whole line, then one span per **word**:
`<span><span style="display:inline-block" aria-hidden="true">…letters…</span> </span>`. A word never
breaks across a wrap, and the trailing space sits outside the inline-block. Each letter is an
`inline-block` span. Its offset is `scatter(lineIndex, letterIndexWithinLine)`, where letters are counted
across the line's words and spaces are not counted. Resting pose: `opacity: 0; filter: blur(12px)`.
A letter's style for a value `s` is `opacity: s; filter: s >= 1 ? "none" : blur(${(1 - s) * 12}px)`.
There are three driving modes:
- **Own visibility** (no driver given): when shown, write in. When hidden, write out.
- **`show` flag** from the caller: same as above, but the caller says when.
- Write in: each letter `start(1, { duration: 560, easing: easeOutCubic }, delay + offset * 460)`.
- Write out: each letter `start(0, { duration: 560, easing: easeInCubic }, offset * 460)`. This is the
mirror image, and the letters sink back in the order they surfaced.
- **Scrub** (`setScrollProgress(p)`): each letter is **set** to
`clamp((p - offset * 0.45) / (1 - 0.45), 0, 1)`. The pen is the scroll.
Under reduced motion the letters jump (in scrub mode, `p > 0 ? 1 : 0`).
### Misted lines: small copy, line by line out of mist
```js
const LINE_MS = 950, STAGGER_MS = 140, MIST_PX = 4, ROW_TOLERANCE_PX = 2;
```
A line given as plain text is split into words. Each word is an inline-block span followed by a space.
A line given as markup (a link, a list row) is one piece. Watch the lines' **parent** for shown/hidden.
On shown, group the pieces into the **rows the browser actually wrapped them onto**: a new authored line
starts a row, and so does an `offsetTop` change of more than 2px. Then each piece runs
`start(1, { duration: 950, easing: easeOutCubic }, delay + row * 140)` with
`opacity: m; filter: m >= 1 ? "none" : blur(${(1 - m) * 4}px)`. On hidden, every piece is set to 0.
### Chapter mark: kanji drawn stroke by stroke
```js
const STROKE_MS = 400, STAGGER_MS = 280;
const strokeClip = (index, draw) => {
if (draw >= 1) return "none";
const rest = `${(1 - draw) * 100}%`;
return index % 2 === 0 ? `inset(0 0 ${rest} 0)` : `inset(0 ${rest} 0 0)`; // even glyphs draw down, odd across
};
```
Markup: `<p lang="ja" class="mark">` is `display: flex; flex-direction: column; align-items: center;
gap: 0.75rem; text-align: center`. Inside it:
- a column of glyph spans in `font-kanji`, the kanji size, bold
- a `lang="en"` column of the translation words in `font-mono`, the micro size,
`letter-spacing: var(--tracking-slate)`, uppercase
There is one value per glyph plus one for the translation. On shown, value `i` runs
`start(1, { duration: 400, easing: linear }, i * 280)`. Glyph `i` gets `clip-path: strokeClip(i, v)`.
The translation gets `opacity: v * 0.75`. On hidden, all values are set to 0.
### Kanji flip: nav labels roll up to their Japanese twin
`<span style="position:relative;display:inline-block;overflow:hidden;vertical-align:top">` holds
the label (`display:block`) and an `aria-hidden` `lang="ja"` twin
(`display:block; position:absolute; top:0; left:0; transform:translateY(100%); font-family: kanji;
white-space: nowrap`). Both have `transition: transform 350ms cubic-bezier(0.2,0,0,1)`, with no
transition under reduced motion. On the parent link's `:hover` / `:focus-visible`, the label goes to
`translateY(-100%)` and the twin to `translateY(0)`.
## Image sequences
### Manifests
There are three compositions, each 150 frames, named `frame-00000.webp` … `frame-00149.webp`:
| sequence | folder (1920×1080) | portrait cut (608×1080) | label |
|---|---|---|---|
| skyline | `sequence-1/` | `sequence-1-portrait/` | "Flythrough along a sunlit city street lined with autumn trees, buildings rising on both sides" |
| student | `sequence-2/` | `sequence-2-portrait/` | "A student in school uniform looks up at high-rise buildings while autumn leaves fall around her" |
| window | `sequence-3/` | `sequence-3-portrait/` | "A camera move from an autumn street, through a sunlit studio where someone works at a desk, out an open window to a city skyline above the treetops" |
```js
const frames = (folder) => Array.from({ length: 150 }, (_, i) => `${ASSET_BASE_URL}/${folder}/frame-${String(i).padStart(5, "0")}.webp`);
```
The **portrait cut** is the centre 608×1080 of each frame. A canvas cover-fits and centres its frame,
so a box no wider than that aspect shows nothing outside the cut. That gives the same picture for a
third of the bytes and decode:
```js
const PORTRAIT_FRAME_ASPECT = 608 / 1080;
const fitsPortraitFrames = (w, h) => w > 0 && h > 0 && w / h <= PORTRAIT_FRAME_ASPECT;
```
### The painter (one per canvas)
```js
const MAX_PIXEL_RATIO = 2, PRELOAD_MARGIN = "100%";
const BITMAPS_AHEAD = 16, BITMAPS_BEHIND = 5, BITMAPS_KEEP = 22, REQUESTS_PER_TASK = 6;
```
- **Load late.** Start loading when the canvas comes within one screen of the viewport
(`IntersectionObserver`, `rootMargin: "100%"`).
- **Pick the frame set.** At that moment, choose a set from the canvas's own box. Use the portrait set
if `fitsPortraitFrames(canvas.clientWidth, canvas.clientHeight)`. After a resize the choice may only
widen back to the full frames, never the reverse.
- **Request order.** Issue the requests **nearest the current frame first**, 6 per task
(`setTimeout(batch, 0)` between batches).
- **Full frames.** Use one `new Image()` per frame, with `decoding = "async"`, then `.decode()`. Paint
as soon as any frame lands, so the canvas is never blank while the rest download.
- **Portrait frames (phones).** `fetch()` each frame to a Blob and keep the blobs. Decode a window
around the current frame off the main thread with `createImageBitmap(blob)`: 16 frames ahead in the
scroll direction and 5 behind, nearest first. `close()` any bitmap more than 22 frames away. Release
every bitmap when the canvas leaves the 100% margin, and re-hold the window when it comes back.
Without this, a phone decodes each frame on the main thread inside the scroll frame that shows it.
- **`showFrame(i)`** is a no-op if `i` is already current. Otherwise it records the direction, moves the
window, and paints. A frame that is not decoded yet leaves the previous picture up.
- **Paint (cover fit, DPR capped by the frame's own resolution):**
```js
const sourcePerCss = 1 / Math.max(cssW / srcW, cssH / srcH);
const ratio = Math.max(1, Math.min(devicePixelRatio || 1, 2, sourcePerCss));
const tw = Math.round(cssW * ratio), th = Math.round(cssH * ratio);
if (canvas.width !== tw || canvas.height !== th) { canvas.width = tw; canvas.height = th; }
const scale = Math.max(tw / srcW, th / srcH), dw = srcW * scale, dh = srcH * scale;
ctx.drawImage(src, (tw - dw) / 2, (th - dh) / 2, dw, dh);
```
Repaint after a resize, debounced by 300ms.
### Scroll sequence
A pinned scene whose stage holds `<canvas role="img" aria-label="{label}" style="display:block;
width:100%;height:100%">`, followed by any overlays. Defaults are `span 4`, `frameEnd 0.985`,
`frameStart 0` and `zoomToEnd 1`.
```js
onProgress(progress) {
const scrubbed = Math.min(progress / frameEnd, 1);
showFrame(Math.round((frameStart + (1 - frameStart) * scrubbed) * 149));
if (zoomToEnd > 1 && frameEnd < 1)
zoom.start(Math.max(0, (progress - frameEnd) / (1 - frameEnd))); // default spring
// canvas.style.transform = `scale(${1 + zoom * (zoomToEnd - 1)})`, will-change: transform
}
```
The frame index never goes through a spring: it is discrete, and interpolating it would show frames
the scroll position is not on.
## The CRT overlay (over everything)
`<div aria-hidden="true">` is `position: fixed; inset: 0; z-index: 100; pointer-events: none`. It holds
four absolutely positioned layers:
```css
.crt-scanlines { /* inset-x 0, top 0 */
height: calc(100% + 4px);
background-image: repeating-linear-gradient(to bottom, transparent 0, transparent 2px, #9a7536 2px, #9a7536 4px);
opacity: 0.12; mix-blend-mode: multiply;
transform: translate3d(0, calc(4px * var(--overlay-scanline-drift, 0) * -1), 0);
will-change: transform;
}
.crt-vignette { inset: 0; box-shadow: inset 0 0 26.667vmin -2.222vmin #000; opacity: 0.28; }
.crt-grain {
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='grain'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23grain)'/%3E%3C/svg%3E");
background-size: 180px 180px; background-repeat: repeat; opacity: 0.05;
}
.crt-bezel { /* rounded black screen corners at any aspect */
inset: 0; border-radius: 2rem;
box-shadow: 0 0 0 4rem #0a0908, inset 0 0 0.1875rem 0.0625rem var(--bezel-edge);
}
```
The scanline period is 4px, not 3px, so it lands on whole device pixels at DPR 1/1.5/2/3 and doesn't
moiré. `--overlay-scanline-drift` is a **loop** from 0 to 1, `linear`, every **2600ms**, written to the
scanlines element. Under reduced motion it stays at 0.
## The preloader (an ASCII TV that opens into a window)
```js
const LOAD_CEILING = 0.96, MIN_LOADING_MS = 2400, HOLD_MS = 120, EXIT_MS = 1300;
const SCREEN_CLEAR = [0, 0.3], ASCII_OUT = [0, 0.2], NOISE_IN = [0, 0.08], NOISE_OUT = [0.08, 0.6], CHROME_OUT = [0, 0.45];
const NOISE_OPACITY = 0.45, PRELOAD_FRAME_COUNT = 30;
const hash = (row, col) => { const x = Math.sin(row * 127.1 + col * 311.7) * 43758.5453; return x - Math.floor(x); };
```
**DOM.** The root is `role="status" aria-busy="true" aria-label="Drawing the city — loading"`, with
class `intro-screen`, and is `position: fixed; inset: 0; z-index: 90; overflow: hidden; color: var(--ink)`.
It sits under the CRT overlay, so the intro plays on the same screen. It holds, in order:
1. `.intro-surround`: paper everywhere **except** the screen.
2. A full-bleed paper layer **inside** the screen too, with `opacity: 1 - ramp(e, SCREEN_CLEAR)`.
3. `.intro-screen-mask` (`absolute; inset: 0`) containing the **ASCII noise** `<pre>`.
4. The **ASCII art** `<pre aria-hidden>`: `absolute; top: 50%; left: 50%; translate: -50% -50%`, mono,
glyph size (1rem/1rem), weight 600, colour `#3d66b8`, `opacity: 1 - ramp(e, ASCII_OUT)`. It holds one
`display:block` span per row, initially filled with spaces.
5. The **chrome** (`absolute; inset: 0; opacity: 1 - ramp(e, CHROME_OUT)`):
- The logo with wordmark "Layers" at `top 1.5rem; left 3rem` (tablet left 2rem; mobile top 1rem,
left 1.25rem).
- "Hand-painted 2D animation studio" at `top 2.625rem; right 3rem`, mono, label size, uppercase
(tablet right 2rem; mobile hidden).
- "Drawing the city" at `bottom 2.625rem; left 3rem`, mono, label size, uppercase (tablet bottom 2rem,
left 2rem; mobile bottom 1.25rem, left 1.25rem).
- The counter (`aria-hidden`) at `right 3rem; bottom 2.5rem`, Reggae One, counter size,
`letter-spacing: -0.0625rem` (tablet right 2rem, bottom 2rem; mobile 1.25rem / 1.25rem). Its text
is `String(Math.round(p * 100)).padStart(3, "0") + "%"`.
```css
.intro-screen { /* e is written to both --screen-open and --screen-reveal */
--screen-w: calc(var(--tv-width) + (var(--open-width) - var(--tv-width)) * var(--screen-open, 0) + (100vw - var(--open-width)) * var(--screen-reveal, 0));
--screen-h: calc(var(--tv-height) + (var(--open-height) - var(--tv-height)) * var(--screen-open, 0) + (106svh - var(--open-height)) * var(--screen-reveal, 0));
}
.intro-screen-mask { mask-image: url("https://storage.getlayers.ai/assets/kumo-0d94304971/home/screen-mask.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: var(--screen-w) var(--screen-h); }
.intro-surround {
mask-image: linear-gradient(black, black), url("https://storage.getlayers.ai/assets/kumo-0d94304971/home/screen-mask.svg");
mask-repeat: no-repeat; mask-position: center; mask-size: 100% 100%, var(--screen-w) var(--screen-h);
mask-composite: exclude;
}
.ascii-noise { /* glyphs cut out of the hero frame, under a deep-blue tint */
background-image: linear-gradient(color-mix(in srgb, #1f3b6e 50%, transparent), color-mix(in srgb, #1f3b6e 50%, transparent)),
url("https://storage.getlayers.ai/assets/kumo-0d94304971/sequence-1/frame-00000.webp");
background-size: auto, cover; background-position: center;
-webkit-background-clip: text; background-clip: text; color: transparent;
}
```
`screen-mask.svg` is a barrel-shaped TV screen: its top and bottom edges bow outward
(`M0 12C448 -4 896 -4 1344 12V565C896 581 448 581 0 565Z`, `preserveAspectRatio="none"`). The final
height of 106svh overshoots, so the curved edges end up off-screen and the window reads as flat.
**ASCII noise.** After mount, fill a `<pre aria-hidden>` (`absolute; inset: 0; overflow: hidden`, mono,
1rem/1rem, weight 600) with random glyphs from `"%+@*#="`. Use
`cols = ceil(innerWidth / (rootFontPx * 0.6))` and `rows = ceil(innerHeight / rootFontPx)`, joined by
`n`. Add the `.ascii-noise` class only once the text exists. Its opacity is
`0.45 * ramp(e, NOISE_IN) * (1 - ramp(e, NOISE_OUT))`.
**The ASCII art** is drawn by progress `p`. Each character has a threshold
`(row / rows) * 0.7 + hash(row, col) * 0.3`, and appears once `p` passes it. That is mostly top to
bottom, like a CRT painting its lines, loosened by per-glyph noise. On every change of `p`, rewrite each
row's `textContent`: the character where `threshold <= p`, otherwise a space. The art, verbatim (34 rows of
120 characters):
```js
const ASCII_ART = [
"=+===--------:::---++*#%%%%%%@%%%###***********+=--=+++*####***++====+*%@@@@@@@@%%%%%###*++==+++++++#%@@%%%%%@%%%%%#**+=",
"=+===------::::::::::-=+++**##%@%%#***##***++++++===+*****++===#%%%%@@@@@@@@@@%%@@@@@%%###**+===+*#%%%@@@@@@@@@%%##**+=-",
"===----::::::::...:-:...:::--=+*########*+++++++++*******++==+@@@@@@@@@@@@@@%#*+++*%@@@@@@@@%*=-=+*##%@@@@@@@@@%%#***+=-",
"++==---+*++*%%##*#@@@%#+:.:::::-+++*##***++++=====+++++++**+*@@@@@@@@@@@@%%#*+++====*#%%%#%%@@#=-=*#%@@@@@@@@@%####***+=",
"===--+%@@@@@@@@@@@@@@@@@#-.....::--=+*+++++======-------#@@@@@@@@@@@@@@%%##*++====--===+==--=+++**#%@@@@@@@@%%%###%%#*+=",
"-=-=*%@@@@@@@@@@@%%%##*#%%%##+-----==++++++**====*+===*#@@@@@@@@@@@@@@@@@%#*++==---------::::::=+#%@@@@@@@@@%%%####*++=-",
"=+++*%@@@@@@@@@@@@#**++++*#@@@@@@#++=+++++===--=#%#*#@@@@@@@@@@@@@@@@@@%%##+++===----::::::::::...:-+%%@@@@%%%%#**+===-:",
"----+%@@@@@@@@@%%#+++++++==+*%@@@@@%*++++++++++##%%%%%%####%@@@@@@@@@%##**++====-------:::::::.......-*%%##****++==----:",
":=+*%@@@@@%####*++*%@@@@@%#+=++*####%#+=+++++*###**##%@@@@@@@@@@@@@@%#**++=====---------::::::......:-***#*%%###*+=----:",
"*@@@@@@@%##****+**%@@@%####+=====++++++==++++++=+*%@@@@@@@@@@@@@@@@@%#*++==--------:::::::..........:=+*#%%#%%%%%#*=-::.",
"%@@@@%%##***+***#%@@%#*++============+##+====+*#%@@@@@@@@@@@@@@@@@@@@@@@@%*=------:::::::..........:----===--==--===-:..",
"%@@%%##**++++*###%@%*++====---------==+##**++%@@@@@@@@@@@@@%%%@@@@@@@@@@@@@%*=---::::::::.........::::::.........::::...",
"%@%##**##%%%%##****++====--------------=+*%@@@@@@@@@@@@@%%%##########%@%##%@@@*+---::..............:::..................",
"%@%#**#@@@@@@@%%*+=====------:::::::-=---=*@@@@@@@@@@@@%####*****+++++++++++**#@@@@%**=.................................",
"#%%###########%%#*==-------:::::=***%@@#+++#@@@@@@@@@%%###***+++++++========-=+%@@@@@@@*:............................-+*",
"*#####*++===========----:::::.-%@@@@@@@@@@@%@@@@@@@@%%###***+++++++======----=*%%%@@@@@%#*+-::.......................#@%",
"+****++==-----------:::::...-*%@@@@@@@@@@@@@@@@@@@@%%###*****+++++=======----=+****######%%#+-:...::.................#@%",
"=++++==---::::::::::::......-#%%@@@@@@@@@@@@@@@@@@@%@@@@#***+++===-----=-------====+++++%@@@@@#**%@@#+-:............-#@%",
"-=====--::::::::............:*##%@%%##%%%%%@@@%@@@@@@@@@@#**+===-----------------+#%@@@@@@@@@@@@@@@@@@@@+............*@%",
":-----::::...................-*##*********####%@@@@@@@%#**++==-----------------=%@@@@@@@@@@@@@@@@@@@@@@%%*=+*++==----*@%",
"::::::::.......................=+++++++++++**#%@@@@@@@@%+====-----------:::::-#@@@@@@@@@@@@@@@@@@%%%######%%%%#%%%@%@%%#",
".:.............................:=========+++*###%%#%%%%%%%%*+=-------:::::::::#@@@@@@@@@@@@@@@@@%%#****++++**#%%%%%%@@%#",
"......................:--:--==----------=====+++**++++**@@%#*=-----::=*#@%*+=--#@@@@@@%@@@@%%####***+++====++*#%%%@@@@@#",
"...................+#%@@@@@@@@@%+-----------============+++==-------+@@@@@@@@%#%@@@@@%@@@@@@@@%*++++++=======+#%@@@@@@@%",
"...............-*#@@@@@@@@@@@@@@@+.:::::::-------------------------=*@@@@@@@@@@@@@@@%@@@@@@@%%%*+++++=======+*%@@@@@@@@%",
"=*+=---.......=%@@@@@@@@@@@@@@@@%*-=++-:::::::::::::::::::::::::---=+#@%%####%@@@@@%%#***##*+++====----===+*#%%%%%%%%%%%",
"%@@@%@%+:....-@@@@@@@@@%%%%%%%%%%@@@@@@#-::::::::::.:::.:::::::::----=+**+++*#%@@%%%@@%*+++====----------=+***++++*###%#",
"%@@@@@@%=-::=%@@@@@@@@%%########%%%@@@@@%-::::::::-=-:...........::----===+==++***%@@%%*+=====-------------===--==+***#*",
"@@@@@@@@%@@@@@@@@@@@%%##***********##%%%*:..:=***#@@@%+............::---------====+##**++=---------::::::::::::--==+++++",
"%@@@@@@@@@@@@@@@@@@%###%%%*++++++++***##+==+%@@@@@@@@@@=............:::-----------====+*+---:::::::::-::::::::::-======-",
"%@%@@@@@@@@@@@@@@%%%@@@@@@@*+=++++++++*#%@@@@@@@@@@@@%%#-............:::---------=+=------::=++-::-*%@%%#+-::::::::----:",
"#%%%%%@@@@@@@@@%%%@@@@@@@@@@#+=======++*#%@@@@@@@@@@%##*#%+--==--::.::-::----------:::::::=#@@@@#*%@@@@@@%#=:.......:::.",
"*###%@@@@@@@@%%%#%@@@@@@@%%%%*=---=====++*#%@@@@@@@%#***%%%#*+-:......:::::::::---:::::--+@@@@@@@@@@@@@%%###+-..........",
"+**##%%%%%%%##**#%@@%###*****+=---------==+**######***++*####=............::::::::::::-*%@@@@@@@@@@@@@%%#*++=-..........",
];
```
**Timeline.** It uses two spring values, `p` (load) and `e` (exit).
```js
stopScroll(); window.scrollTo(0, 0);
const set = fitsPortraitFrames(innerWidth, innerHeight) ? skylinePortrait : skyline; // warm what the scrub will paint
const urls = [skyline[0] /* hero frame, full size */, ...set.slice(0, 30)];
let loaded = 0;
const assets = urls.map(src => { const im = new Image(); im.src = src; return im.decode().catch(() => {}).then(() => {
loaded += 1; p.start((loaded / urls.length) * 0.96, { tension: 40, friction: 26 });
}); });
await Promise.all([...assets, document.fonts.ready, wait(2400)]);
await p.start(1, { duration: 450, easing: easeOutCubic });
await wait(120);
phase = "revealing"; // the hero and the header start their entrances NOW, inside the opening window
await e.start(1, { duration: 1300, easing: easeOutQuint });
startScroll(); phase = "done"; preloader.remove();
```
`e` is written to `--screen-open` and `--screen-reveal` (the same value), so the window grows linearly
in `e` from the TV size to the full viewport, and `e` carries the one easing curve. The screen is a
**real window**. What shows through it is the live page: the skyline canvas on its first frame, with the
hero writing itself in on top.
## The header (fixed, over every block)
`<header class="header-ink">` is `position: fixed; inset-inline: 0; top: 0; z-index: 80; height: 10rem`
(portrait 6.5rem). Its `opacity` is the `shown` spring, or 0 while the phone menu is open.
`pointer-events` is `auto` only when `phase === "done"` and the menu is closed. When the phase leaves
`loading`, `shown.start(1, { tension: 60, friction: 22 })`.
```css
.header-ink { color: color-mix(in oklab, var(--paper) calc(var(--light, 0) * 100%), var(--ink)); }
@media <portrait> { /* no dark scrim there, so light ink gets a soft shadow that fades in with --light */
.header-ink { text-shadow: 0 1px 0.75rem color-mix(in srgb, var(--ink) calc(var(--light, 0) * 55%), transparent); }
}
```
`--light` (0 = ink, 1 = paper) is written straight to the header's style on every change, with no
re-render. While the menu is open it is forced to 0.
Children, in order:
- **Paper scrim** (`aria-hidden`, pointer-events none): `absolute; inset-inline 0; top 0; height
18.75rem` (tablet 18rem, portrait 24rem, mobile 19.8125rem).
`linear-gradient(to bottom, paper/90, paper/50, paper/0)`, `opacity: calc(1 - var(--light, 0))`,
`visibility: hidden` while the menu is open.
- **Ink scrim**: same box (tablet 18rem, **hidden on portrait**).
`linear-gradient(to bottom, ink/85, ink/47, ink/0)`, `opacity: var(--light, 0)`.
- **Logo link** `aria-label="Layers — home"` at `top 1.5rem; left 3rem` (tablet left 2rem; hidden on
portrait). It is the mark alone, plus an `.sr-only` "Layers".
- **Portrait logo link**: hidden, except on portrait where it is `display: flex; align-items: center;
gap 1.5rem; top 1.5rem; left 2rem` (mobile left 1.25rem, gap 1rem). It holds an `aria-hidden` 層 in
kanji font, mark size, bold, and "Layers" in mono, tag size, slate tracking, uppercase.
- **Nav** `aria-label="Primary"` at `top 1.5rem; left 24.375rem` (tablet left 17.375rem; hidden on
portrait). It is a flex row, `align-items: start; gap 4rem` (tablet 1.5rem), Onest, nav size. There
are three columns, each `width 5.625rem` (tablet 8.75rem) and each an inner column list with
`gap 0.25rem`. Every link is a **misted line** (markup) containing a **kanji flip**:
- Films / 作品 → #films · Clips / クリップ → #clips · Brand films / ブランド映像 → #brand-films
- About / 私たち → #about · Process / 工程 → #process · Journal / 日誌 → #journal
- Say hello / こんにちは → #contact · Careers / 採用 → #careers
- **Scene clock** block at `top 1.5rem; left 60rem; width 8.1875rem` (hidden from tablet down). It is a
column with `gap 0.5rem`, mono, meta size, slate tracking:
- A `<time>` showing the **visitor's local time** `HH:MM:SS`, ticking on the second boundary
(`setTimeout(tick, 1000 - ms)`) and showing "15:40:12" until the first tick. It is
`display: flex; height 1.625rem; width: fit-content; align-items: center; border: 1px solid
currentColor; border-radius 0.25rem; padding 0 0.625rem; tabular-nums`.
- A column (`gap 0.125rem`, uppercase) with "Above the city" and "SC 01".
- **CTA link** to `#contact`, with the hover-flip group, at `top 1.5rem; right 3rem` (tablet right 2rem;
hidden on portrait). It is `display: flex; align-items: center; gap 0.75rem`, Onest, nav size. It holds
a misted line containing the kanji flip "Start a project / 始めましょう", then the **live dot**.
- **Menu button** (`aria-expanded`, `aria-controls="site-menu"`): hidden, except on portrait where it is
`display: block; top 2rem; right 2rem` (mobile right 1.25rem). Mono, tag size, slate tracking,
uppercase, text "Menu +". It toggles the menu.
- **Hairline** (portrait only): `absolute; inset-inline 2rem; top 5rem; height 1px; background:
currentColor; opacity 0.4` (mobile inset 1.25rem).
**Logo**: `display:flex; align-items:center; gap:0.875rem`. The mark is a `3.5rem` square
(`position: relative`, flex-centred) containing 層 in kanji font, `2.125rem`, `line-height: 1`, bold.
It has **four corner brackets** built from eight 1px `background: currentColor` arms, each 0.5625rem
long. Each corner has one horizontal and one vertical arm: top-left, top-right, bottom-left and
bottom-right. The wordmark, when present, is "Layers" in Onest, `2rem`, weight 500, tracking
`-0.0625rem`.
**Live dot**: an `aria-hidden` `0.75rem` square (`position: relative; display: inline-block`) holding
two `border-radius: 50%` gold circles, one sitting still and one pulsing. The pulse is a **loop**
0 → 1 over 1600ms with `easeOutCubic`. The ring has `opacity: 0.55 * (1 - p)` and `scale: 1 + 1.6 * p`.
Under reduced motion the ring has opacity 0 and no loop.
### The menu (portrait and phones only)
The overlay is `position: fixed; inset: 0; z-index: 80`, displayed only on portrait. A `reveal` spring
`{ tension: 260, friction: 30 }` goes to 1 when open and 0 when closed, with
`visibility: reveal > 0.01 ? visible : hidden`. Clicking the overlay closes the menu.
The sheet (`id="site-menu" role="dialog" aria-modal="true" aria-label="Menu"`; clicks stop
propagation) is `position: absolute; inset: 2rem; display: flex; flex-direction: column;
border-radius: 2rem; border: 1px solid var(--glass-edge); background: paper/72; padding: 1.75rem 2rem
2rem; color: ink; box-shadow: var(--shadow-sheet); backdrop-filter: blur(16px)`. On mobile it is
`inset 1.25rem; border-radius 1.5rem; padding 1.25rem 1.5rem 1.5rem`. It has
`opacity: reveal; transform: translate3d(0, ${(1 - r) * -1.5}rem, 0) scale(${0.98 + r * 0.02})`.
- **Top row**: `height 2.25rem` (mobile 2rem), flex, space-between. On the left, a home link with 層
(kanji, mark size, bold) and "Layers" (mono, tag size, slate tracking, uppercase), `gap 1rem`
(mobile 0.875rem). On the right, the close button "Close ×" in the same mono style.
- **Nav** `aria-label="Menu"`: `margin-top 2rem` (mobile 1.75rem), `border-top: 1px solid ink/20`.
There is one group per nav column, labelled **Work / Studio / Contact**. Each group is a grid
`11.25rem 1fr auto`, `align-items: start`, `border-bottom: 1px solid ink/12` (none on the last),
`padding: 2rem 0`. On mobile it is `1fr auto`, `row-gap 0.5rem`, `padding 1.5rem 0`.
- The label is mono, meta size, slate tracking, gold, uppercase, `margin-top 1rem` (mobile 0), and
misted with delay `index * 260`.
- The links list is Reggae One, menu size (mobile: spanning both columns, row 2). Each link is a
**written line** with delay `index * 260 + linkIndex * 120`. Links have
`transition: color 150ms var(--ease-entrance)` and turn `#e4602a` on hover/focus-visible.
- The count ("03", "03", "02") is mono, meta size, gold, `margin-top 1rem` (mobile: column 2, row 1,
margin 0), misted with delay `index * 260`.
- **Foot** (`margin-top: auto`, column):
- `mailto:hello@layers.studio` in Onest, nav size, hidden on mobile.
- "Instagram · Vimeo · Behance · YouTube" in mono, meta size, ink/60, `margin-top 1rem` (mobile 0).
- A pill CTA "Start a project →" to `#contact`: `margin-top 1.5rem; height 4rem` (mobile 3.5rem),
flex-centred, `border-radius: 9999px; background: #e4602a; color: paper`, Onest, nav size, weight
500 (mobile body size).
- All three are misted with delay 700.
While the menu is open: stop scroll, focus the close button, and close on Escape. Any link also
closes it. On close: start scroll and return focus to the menu button. Links get `tabindex=-1` while
the menu is closed.
## The story: scenes, in order, inside `<main id="top" style="isolation:isolate">`
The header ink is set from scene progress:
```js
const SKY_TO_STREET = [0.015, 0.07], GIRL_TO_SKY = [0.9, 0.99], STREET_TO_ROOM = [0.009, 0.073], ROOM_TO_PAPER = [0.76, 0.81];
const lightWhilePinned = (p, light) => { if (p > 0 && p < 1) setLight(light); };
```
### Scene A: skyline sequence (blocks 1 and 2)
This is a scroll sequence: `span 4`, `z-index 10` (position relative), frames `sequence-1`, not a
lead-in. Its progress `p` drives:
- the hero (`setScrollProgress(p)`)
- the studio caption
- `skylineOut.set(p)`, which feeds this scene's cloud approach
- `if (p < 1) setLight(ramp(p, SKY_TO_STREET))`, which includes the top of the page, where `p` is 0
Overlays on the stage, in order: the Hero, the studio caption, and the cloud curtain
`composition "drift"` with `progress = approach(0.7)(skylineOut)`.
#### The hero (block 1)
```js
const LEAVE_SPAN = 0.1, ENTER_RISE_REM = 1.5;
```
The wrapper is `absolute; inset: 0; color: ink`. It uses two springs:
- `shown` runs `start(1, { tension: 60, friction: 22 })` when the phase becomes `revealing`.
- `scrolled` runs `start(min(p / 0.1, 1))` with the default config.
`visible = shown * (1 - scrolled)`, and `rise = translate3d(0, ${(1 - shown) * 1.5 - scrolled * 1.5}rem, 0)`.
- **Faded group** (`absolute; inset: 0; opacity: visible`) contains three things:
- **Intro** `<p>`, Onest, body size, `transform: rise`. It sits at `top 13rem; left 60rem; width 27rem`.
- tablet: `left 37.875rem; width 19rem`
- portrait: `top auto; bottom 6.5rem; left 2rem; width 32.75rem`
- mobile: `bottom 5rem; left 1.25rem; right 1.25rem; width auto`
It is two **misted lines**, "We make animated films the slow way:" and "pencil first, paint second,
software last.". Each line is `display:block`, or `inline` from tablet down, so on tablet they run
together and wrap.
- **Chapter mark** 秋の街 / Autumn City, `color: ink`, at `right 3rem; bottom 7.8125rem`.
- tablet: `right 2rem; bottom 6.3125rem`
- portrait: `top 8rem; bottom auto`
- mobile: `top 7rem; right 1.25rem`
- **Film ticker** at `inset-inline 0; bottom 1.5rem` (mobile bottom 1.25rem), with the items
"Scene 01 — Sky over the city", "Frame 0001 / 0450", "24 fps", "Painted by hand",
"Above the city, 15:40".
- **`<h1>`**, outside the fade: Reggae One, display size, `transform:
translate3d(0, ${(1 - shown) * 1.5}rem, 0)`, at `bottom 7rem; left 3rem`.
- tablet: `bottom 6rem; left 2rem`
- portrait: `bottom 12.75rem`
- mobile: `bottom 9.75rem; left 1.25rem; right 1.25rem; width auto`
It holds the **written lines** "We paint the city" and "one frame at a time.", each `display:block`
(mobile `inline`), driven by a `show` flag. The flag turns true when the phase becomes `revealing`.
After that it is `opened && p < LEAVE_SPAN * 0.2`, so the letters sink back as soon as the scrub
starts, while the rest of the hero is still there to see it against. The headline rises in with the
rest but does **not** lift on the way out. Its exit is the letters sinking back where they stand.
#### Film ticker (used by the hero and contact)
This is a **marquee**. The container is `overflow: hidden; height 2.5rem` (portrait 3rem, mobile
2.5rem), `background: var(--glass); backdrop-filter: blur(8px)`. The track is a `<ul
aria-label="Scene details">`: `display: flex; width: max-content; height: 100%; align-items: center;
gap 1rem; padding-right 1rem`, mono, meta size, slate tracking, ink/85, uppercase. It holds the items
**four times**, each followed by a separator `<li aria-hidden>` containing a `0.375rem` gold dot
(`https://storage.getlayers.ai/assets/kumo-0d94304971/home/ticker-dot.svg`, 6×6). Copies 2–4 are `aria-hidden`. A **loop** moves the track
from `translate3d(0%)` to `translate3d(-50%)` (exactly two copies), `linear`, over **80000ms**. Under
reduced motion it is still.
#### Captions (blocks 2, 4 and 7)
The wrapper is `absolute; inset: 0; color: paper; text-shadow: var(--shadow-legible)`. A `visible`
spring (default config) runs `start(ramp(p, fadeIn) * (1 - ramp(p, fadeOut)))`. The heading's letters
are driven by `show = p >= fadeIn[0] && p < fadeOut[0]`. The heading is **not** faded. Its letters are
its appearance both ways.
- **Scrim** (`aria-hidden`): `absolute; inset-inline 0; bottom 0; height 27.5rem` (tablet 26.375rem,
portrait 35.1875rem, mobile 29rem). `linear-gradient(to top, ink/72, ink/0)`, `opacity: visible`.
- **Copy group**: on portrait it is `position:absolute; inset-inline 2rem; bottom 2rem; display:flex;
flex-direction:column; gap 2rem` (mobile inset 1.25rem, bottom 1.25rem, gap 1.5rem). Elsewhere it is a
plain wrapper.
- **`<h2>`**: Reggae One, display size, `absolute; bottom 2.5rem; left 3rem` (tablet `bottom 2rem;
left 2rem`; portrait `position: static`). It holds the written lines, each `display:block`.
- **Body `<p>`**: Onest, body size, `opacity: visible`, `absolute; bottom 2.5rem; left 60rem;
width 19.875rem`.
- tablet: `bottom 2rem; left 37.875rem; width 19rem`
- portrait: `static; width 32.75rem`
- mobile: `width auto`
It holds misted lines. Every line after the first starts with a `<br>` that is hidden on portrait.
- **Chapter mark** in a wrapper with `opacity: visible`. It uses `text-shadow: var(--shadow-mark)` and
sits at `right 3rem; bottom 2.8125rem`.
- tablet: `right 2rem; bottom 2.3125rem`
- portrait: `top 8rem; bottom auto`
- mobile: `top 7rem; right 1.25rem`
**Studio caption** (scene A): fadeIn `[0.16, 0.26]`, fadeOut `[0.82, 0.92]`. Heading "Small team." /
"Slow pencils." / "Big skies.". Body (one line) "Eleven artists who still start on paper. Every shot is
painted in layers, so it can breathe, move and change without starting over.". Mark 秋の街 / Autumn City.
### The cloud curtain (CloudMeet)
```js
const COVER_AT = 0.5, FILL_IN = [0.28, 0.44], FILL_OUT = [0.56, 0.72];
/** outgoing sequence: nothing until `from`, then a constant drift that closes exactly as the shot runs out */
const approach = (from) => (p) => Math.min(Math.max((p - from) / (1 - from), 0), 1) * COVER_AT;
/** transition scene: opens already covered and carries on at the same speed */
const depart = (p, over = 1) => COVER_AT + Math.min(p / over, 1) * COVER_AT;
const COMPOSITIONS = {
/** Low and wide — the bank rolls across the street. */
drift: [
{ side: 1, width: 175, travel: 138, top: -20, aspect: 709 / 405, depth: "soft", opacity: 0.96 },
{ side: -1, width: 198, travel: 158, top: 8, aspect: 1158 / 509, depth: "deep", opacity: 0.92 },
{ side: 1, width: 186, travel: 144, top: 30, aspect: 1116 / 526, depth: "soft", opacity: 1 },
{ side: -1, width: 208, travel: 164, top: 50, aspect: 1158 / 768, depth: "deep", opacity: 1 },
],
/** Taller and heavier, entering from the opposite sides — the sky closes in. */
swell: [
{ side: -1, width: 162, travel: 132, top: -26, aspect: 751 / 447, depth: "deep", opacity: 1 },
{ side: 1, width: 216, travel: 168, top: 16, aspect: 1116 / 467, depth: "soft", opacity: 0.95 },
{ side: -1, width: 182, travel: 146, top: 42, aspect: 1158 / 568, depth: "soft", opacity: 0.98 },
{ side: 1, width: 202, travel: 156, top: -6, aspect: 1116 / 726, depth: "deep", opacity: 1 },
],
};
```
The cut-outs are `home/cloud-{n}-soft.webp` and `home/cloud-{n}-deep.webp` for n = 1…4. The blur is
**baked into the images**, so use no CSS filter. Band `i` uses cut-out `i+1` at the band's `depth`.
The container is `aria-hidden; absolute; inset: 0; overflow: hidden`. It holds:
- **Fill**: `absolute; inset: 0; background: #e9eef4; opacity: ramp(p, FILL_IN) * (1 - ramp(p, FILL_OUT))`.
Four shapes cannot promise a seam-free cover at every aspect, so a flat fill rises behind them across
the middle of the pass.
- **Each band**: `position: absolute; left: 50%; margin-left: ${-width / 2}vw; width: ${width}vw;
aspect-ratio: aspect; top: ${top}%; opacity; will-change: transform`.
- `transform: translate3d(${(side * (1 - 2 * p) * reach).toFixed(3)}%, 0, 0)`, with
`reach = travel / width * 100`. That is a percentage of its own width, so the move is
compositor-only.
- Inside it, an `<img alt="" loading="eager">` that is `width:100%; height:100%; object-fit: contain`.
- **Insert the images only after `window.load` plus one idle callback (`requestIdleCallback`,
`timeout 1500`)**, so they don't compete with the first screen.
**Both halves of one transition use the same composition**, or the clouds change shape at the scene
boundary.
### Scene B: sky wipe (block 3)
This is a lead-in pinned scene: `span 2`, `z-index 20`, `aria-label` "The autumn street is brushed away
as clouds drift in, onto a student looking up at the towers".
- A canvas (`absolute; inset: 0`) paints the **student sequence's first 51 frames** (`sequence-2`
frames 0–50, plus the portrait cut) with its own painter.
- Its frame is `showFrame(Math.round(clamp(p, 0, 1) * 50))`, straight off the scroll, so it is already
moving when the clouds part.
- `wipe.set(p)` feeds the cloud curtain `composition "drift"` with `progress = depart(wipe)`.
- `lightWhilePinned(p, 1)`.
- Chapter mark 見上げる / Look Up, `color: paper; text-shadow: var(--shadow-mark)`, at
`right 3rem; bottom 2.8125rem`.
- tablet: `right 2rem; bottom 1.6875rem`
- portrait: `top 8rem; bottom auto`
- mobile: `top 7rem; right 1.25rem`
It is not faded, so it draws itself when the stage cuts in.
```js
const LEAD_FRAMES = 51;
const leadShare = (LEAD_FRAMES - 1) / (150 - 1); // 50/149 — the next sequence resumes from here
```
### Scene C: student sequence (block 4)
This is a scroll sequence: `span 4`, `z-index 30`, lead-in, frames `sequence-2`,
`frameStart = 50/149`. Its progress `p` drives:
- the caption
- `studentOut.set(p)`
- `lightWhilePinned(p, 1 - ramp(p, GIRL_TO_SKY))`
The **caption** has fadeIn `[0.16, 0.26]` and fadeOut `[0.8, 0.9]`. Its heading is "Films, clips" /
"and brand stories", and the heading is `width 24.9375rem` from tablet down (`width: auto` on portrait).
The body is two lines, "From a fifteen-second loop to" and "a seven-minute short, for musicians,
brands and festivals.". The mark is 見上げる / Look Up.
The cloud curtain is `composition "swell"` with `progress = approach(0.7)(studentOut)`.
### Scene D: cloud pass (blocks 5 and 6)
This is a lead-in pinned scene: `span 3`, `z-index 40`, `aria-label` "Clouds rise over the student
looking up, then lift away from an autumn street in front of the studio".
```js
const DEPART_OVER = 0.45;
const QUESTION = [[0.05, 0.17], [0.38, 0.52]];
const OUTGOING_CHAPTER = [[0.03, 0.11], [0.45, 0.55]];
const INCOMING_CHAPTER = [[0.53, 0.63], [0.94, 1]];
const SUBTITLE = [[0.56, 0.68], [0.86, 1]];
const within = (v, [fadeIn, fadeOut]) => ramp(v, fadeIn) * (1 - ramp(v, fadeOut));
```
On progress `p`:
- `showFrame(round(clamp(p) * 50))` over **window sequence frames 0–50** (`sequence-3`, plus the
portrait cut).
- `cloud.set(p)`.
- The question's written lines are scrubbed with `within(p, QUESTION)`.
- The subtitle's written lines are scrubbed with `within(p, SUBTITLE)`.
- `lightWhilePinned(p, 0)`.
Stage contents, in order:
- **Canvas** (`absolute; inset: 0`).
- **Cloud curtain**: `composition "swell"`, `progress = depart(cloud, 0.45)`.
- **`<h2>`**: `absolute; inset-inline 0; top 50%; translate: 0 -50%; padding-inline 3rem` (tablet 2rem,
mobile 1.25rem), centred, Reggae One, **title** size, colour `#ffffff`, `text-shadow:
var(--shadow-legible)`. It holds the scrubbed written lines "When did you" / "last look up?",
`display:block` (mobile `inline`).
- **Subtitle group** (`absolute; inset: 0`):
- A scrim at `bottom 0; height 20rem` (tablet 19.1875rem, portrait 25.625rem, mobile 21.125rem):
`linear-gradient(to top, ink/80, ink/40, ink/0)`, `opacity: within(cloud, SUBTITLE)`.
- A `<p>` centred the same way as the question, Reggae One, **subtitle** size, `color: paper;
text-shadow: var(--shadow-legible)`, with the scrubbed written line "It all starts in one room.".
The subtitle's window ends exactly at 1, where the next scene cuts in.
- **Outgoing mark** 見上げる / Look Up in a wrapper with `opacity: within(cloud, OUTGOING_CHAPTER)`.
It is `color: paper; text-shadow: var(--shadow-mark)` at `right 3rem; bottom 2.8125rem`.
- tablet: `right 2rem; bottom 2.3125rem`
- portrait: `top 8rem; bottom auto`
- mobile: `top 7rem; right 1.25rem`
- **Incoming mark** 描く / To Draw, with `opacity: within(cloud, INCOMING_CHAPTER)`. It has the same
placement, except tablet `bottom 2.5rem`.
### Scene E: window sequence (blocks 7 and 8)
```js
const WINDOW_VIEWPORTS = 6.5, WINDOW_FRAME_END = 0.709, WINDOW_ZOOM = 1.15;
```
This is a scroll sequence: `span 6.5`, `z-index 50`, lead-in, frames `sequence-3`,
`frameStart = 50/149`, `frameEnd = 0.709`, `zoomToEnd = 1.15`. The camera move runs out at 0.709 and
the last frame is held. From there the held frame **creeps toward the camera** to scale 1.15 (default
spring), so nothing is ever still. Its progress `p` drives:
- the process caption
- the works intro (`setScrollProgress`)
- `lightWhilePinned(p, ramp(p, STREET_TO_ROOM) * (1 - ramp(p, ROOM_TO_PAPER)))`
The **process caption** has fadeIn `[0.2, 0.273]` and fadeOut `[0.472, 0.545]`. Its heading is
"Sketch, layout," / "paint, motion.". Its body is three lines: "Sketches go up on one wall.",
"Every layer is painted by hand" and "and moves at its own pace.". The mark is 描く / To Draw.
#### Works intro (block 8's page: the city sinks into cloud, then paper)
```js
const RISE = [0.709, 0.818], COVER = [0.818, 1];
lift = `translate3d(0, ${(1 - ramp(p, RISE)) * 60 - ramp(p, COVER) * 100}svh, 0)`; // p is SET (no motion of its own)
```
The wrapper is `absolute; inset: 0`. It holds one moving sheet (`aria-hidden`): `absolute;
inset-inline 0; bottom 0; height 50rem` (tablet 48rem, portrait 64rem, mobile 52.75rem),
`will-change: transform; transform: lift; opacity: p < 0.709 ? 0 : 1`. Its children are below, with
values given as `top / left / height / width`, then tablet, portrait and mobile.
1. **Cloud** (`home/cloud.png`, 560×282). A div with `opacity 0.95; filter: blur(var(--blur-cloud-soft))`
holding the image, `object-fit: cover` in the box.
- `15.5 / -4 / 17.625 / 35rem`
- tablet `16.5625 / -3.8125 / 13.5625 / 26.875`
- portrait `22.0625 / -10.6875 / 18.0625 / 35.8125`
- mobile `18.1875 / -11.125 / 14.875 / 29.5625`
2. **Cloud bank A** (`home/cloud-bank.png`, 900×202, stretched to the box, `max-width: none`).
`opacity 0.9; filter: blur(var(--blur-cloud-far)); mask-image: linear-gradient(to bottom, black 55%,
transparent 100%)`.
- `12 / -25 / 30.3125 / 135rem`
- tablet `14.4375 / -21.625 / 23.25 / 103.6875`
- portrait `19.25 / -46.4375 / 31.0625 / 138.25`
- mobile `15.875 / -45.4375 / 25.5625 / 113.9375`
3. **Cloud bank B** (same image), `filter: blur(var(--blur-cloud-near))`, same mask, full opacity.
- `16.5 / -35 / 33.6875 / 150rem`
- tablet `19.0625 / -29.125 / 25.875 / 115.1875`
- portrait `25.4375 / -55.5 / 34.5 / 153.625`
- mobile `20.9375 / -52.5 / 28.4375 / 126.625`
4. **Cloud edge**: a paper band whose top is cut to the bank's silhouette.
`absolute; left -7.5rem; right -7.5rem; top 25rem; height 13.75rem` (tablet top 23.75 / h 15;
portrait top 23 / h 17.5; mobile top 20 / h 17.5), `background: paper; filter:
blur(var(--blur-cloud-near))`, plus the `.cloud-edge` mask:
```css
.cloud-edge {
mask-image: url("https://storage.getlayers.ai/assets/kumo-0d94304971/home/cloud-bank.png"), url("https://storage.getlayers.ai/assets/kumo-0d94304971/home/cloud-bank.png"), linear-gradient(transparent 62%, black 84%);
mask-size: auto 100%, auto 72%, 100% 100%;
mask-position: -4rem 100%, 41% 100%, 0 0;
mask-repeat: repeat-x, repeat-x, no-repeat;
mask-composite: add;
}
```
5. **Solid paper**: `absolute; inset-inline 0; top 37.5rem` (mobile 35rem), `bottom: -100svh;
background: paper`.
The images and the `.cloud-edge` class are added after load and idle, as with the clouds. The banks
drift up over a city that is still moving, the clouds turn to paper along a cloud-shaped edge rather
than a ruled line, and then the paper keeps climbing and closes over the held frame. **The frame itself
never travels**: by the time the stage unpins the screen is solid paper.
### Scene F: the works sheet (block 8's copy and block 9)
This is one paper sheet in document flow: `position: relative; z-index: 60; margin-top: -115svh;
background: paper`. It rises over the paper laid down by the works intro, and moves with native scroll,
so the gap between its two blocks is a CSS margin, with no script involved.
#### Works list
The block is `position: relative; padding: 3.5rem 3rem 6.5rem; color: ink` (tablet `3rem 2rem 5rem`;
mobile `2.5rem 1.25rem 3rem`).
- **`<h2>`**: Reggae One, display size (portrait `padding-right 6rem`), with written lines (own
visibility) "Selected" / "works", each `display:block`.
- **`<ol>`**: `absolute; bottom 6.6875rem; left 60rem; width 19.875rem; display:flex;
flex-direction:column; gap 0.5rem`, mono, label size.
- tablet: `bottom 5.1875rem; left 37.875rem; width 19rem`
- portrait: `position: static; margin-top 2rem; width auto; gap 0`
- mobile: `margin-top 1.5rem`
It holds misted `<li>` lines, one per film. Each is a link to `#films`: `display:flex; gap 1.5rem`
(portrait `gap 2rem; border-top: 1px solid ink/15; padding 0.75rem 0`; mobile `gap 1rem; padding
0.625rem 0`). The link holds `01`, then the title (`flex: 1`, uppercase), then the year.
- **Chapter mark** 作品 / The Works at `right 3rem; bottom 6.8125rem`.
- tablet: `right 2rem; bottom 5.3125rem`
- portrait: `top 3rem; bottom auto`
- mobile: `top 2.5rem; right 1.25rem`
**Films** (title · meta · year · stills [near-ring face, far-ring face]). `S1/S2/S3` are
`sequence-1/2/3` full frames, where *mid* is frame 75 and *last* is frame 149:
| # | title | meta | year | stills |
|---|---|---|---|---|
| 01 | October Line | Music video · 3:40 · 2026 | 2026 | S1 mid, S3 mid |
| 02 | Paper Weather | Brand film · 1:30 · 2026 | 2026 | S3 last, S1 frame 0 |
| 03 | Upstairs | Short film · 7:40 · 2025 | 2025 | S2 frame 0, S1 last |
| 04 | Kite Season | Short film · 5:20 · 2025 | 2025 | S2 last, S2 mid |
| 05 | Last Tram | Music video · 4:05 · 2024 | 2024 | S3 frame 0, S1 mid |
| 06 | Window Seat | Brand film · 0:45 · 2024 | 2024 | S3 mid, S2 frame 0 |
#### Works ring (block 9)
```js
const CARDS_PER_RING = 12, STEP_DEG = 30, TURN = [0, 1], NAME_HOLD = 0.18, NAME_FADE = 0.45;
```
This is a pinned scene: `span 5`, **`progressStart "top bottom"`** and **`progressEnd "bottom top"`**,
so the rings are already turning as they rise into view and keep turning on the way out. The stage is
`background: paper; color: ink`, and `aria-label` is "Selected works".
```js
onProgress(p) {
const position = ramp(p, TURN) * (films.length - 1); // 0 → 5
turn.start(position); // default spring
const nearest = Math.round(position);
const written = 1 - ramp(Math.abs(position - nearest), [NAME_HOLD, NAME_FADE]);
name.setScrollProgress(written); // scrubbed written line
metaOpacity.set(written);
current = nearest; // swaps title/meta/counter/link
lightWhilePinned(p, 0);
}
```
The name swaps at a distance of 0.5, past the fade, so the words change on a blank line. Rebuild the
title's letter spans when `current` changes, then apply `written`.
**The ring box** has `.zoom-ring` and is `absolute; top 0; left 0; perspective: 200rem`. The ring scales
with a transform, not `zoom`, because `zoom` mis-projects the 3D cards:
```css
.zoom-ring { --zx: var(--ring-zoom-x, var(--ring-zoom)); --zy: var(--ring-zoom-y, var(--ring-zoom));
width: calc(100% / var(--zx)); height: calc(100% / var(--zy)); transform: scale(var(--zx), var(--zy)); transform-origin: 0 0; }
```
Inside it, in order:
- **Two flat orbits** (`aria-hidden`, `border-radius: 9999px; border: 1px solid ink/14`). They are drawn
flat because a 1px ring seen edge-on in 3D thins to nothing at the front and back.
- Upper: `top 3.5rem; left 7.5rem; height 7rem; width 75rem` (portrait `top calc(50% - 14.514rem);
left 2.5rem`; mobile `top calc(50% - 18.13rem); left -15.75rem`).
- Lower: `bottom 4.75rem; left 2.5rem; height 7rem; width 85rem` (portrait `bottom calc(50% -
18.514rem); left -2.5rem`; mobile `bottom calc(50% - 18.9425rem); left -20.75rem`).
- **Far ring** (`aria-hidden`, `absolute; left 50%; transform-style: preserve-3d; top 7.625rem`;
portrait `top calc(50% - 13.951rem)`; mobile `top calc(50% - 16.38rem)`).
- `transform: rotateY(${turn * 30}deg)`.
- Its 12 cards use **far stills**: every film's second still, then every film's first.
- Card `i` has angle `15 + i * 30` and transform `rotateY(angle) translateZ(37.5rem)
translateY(2.5625rem)`.
- Card box: `absolute; top 0; left 0; transform-style: preserve-3d; margin-top -5.75rem; margin-left
-4.375rem; height 11.5rem; width 8.75rem` (portrait `-7.1875rem / -5.5rem / 14.375rem / 11rem`).
- **Near ring**: `bottom 9.0625rem` (portrait `bottom calc(50% - 16.139rem)`; mobile `bottom calc(50%
- 17.63rem)`).
- `transform: rotateY(${-turn * 30}deg)`, so it **counter-rotates**.
- Its 12 cards use **near stills**: every film's first still, then every film's second.
- Card `i` has angle `i * 30` and transform `rotateY(angle) translateZ(42.5rem) translateY(-2.75rem)`.
- Card box: `margin-top -7.6875rem; margin-left -5.75rem; height 15.375rem; width 11.5rem` (portrait
`-9.625rem / -7.1875rem / 19.25rem / 14.375rem`).
- **Each card** has two faces, each `absolute; inset: 0; overflow: hidden; backface-visibility:
hidden`:
- Front: `box-shadow: var(--shadow-card)`, with the still as `object-fit: cover`.
- Back: `transform: rotateY(180deg); opacity: 0.55`, with the same still mirrored (`scale: -1 1`).
The far side of a ring therefore reads unmirrored and dimmed.
- Use `loading="lazy" decoding="async"` on the card images.
Over the rings:
- **Top fade**: `absolute; inset-inline 0; top 0; height 12.5rem` (tablet 12rem, portrait 14.125rem,
mobile 11.25rem), `linear-gradient(to bottom, paper/96, paper/0)`.
- **Bottom fade**: `height 13rem` (tablet 12.5rem, portrait 10.3125rem, mobile 7.8125rem),
`linear-gradient(to top, paper/96, paper/0)`.
- **Centre glow**: `top 50%; left 50%; translate -50% -50%; height 20rem; width 45rem`
(tablet `19.1875 × 38.375rem`; portrait `22.375 × 43.1875rem`; hidden on mobile),
`border-radius: 9999px; background: paper/90; filter: blur(40px)`.
- **Title block**: centred at `top 50%; left 50%`, a column with `align-items: center; gap 0.5rem;
text-align: center`.
- `<h2>`: Reggae One, display size, `white-space: nowrap`, holding the scrubbed written film title.
- `<p>`: Onest, body size, ink/64, `opacity: metaOpacity`, holding the misted meta line. It mists in
once when shown. After that its text swaps in place under the opacity.
- **Hint** "Scroll to turn the ring" at `bottom 2.6875rem; left 3rem`, mono, label size, uppercase
(tablet `bottom 2rem; left 2rem`; hidden on mobile).
- **Counter row** at `right 3rem; bottom 2.6875rem`, `display:flex; gap 0.75rem`, mono, label size.
- tablet: `right 2rem; bottom 2rem`
- mobile: `right auto; bottom 1.25rem; left 50%; translate -50% 0; white-space: nowrap`
It holds the gold `01 / 06` (current + 1, padded), then a link to `#films`, uppercase and underlined:
"{Title} — watch film".
## Contact (block 10): back to the sky
`<section id="contact" aria-label="Contact">` is `position: relative; height: 100svh; overflow: hidden;
color: ink`. It sits after the works sheet in `<main>`. Contents:
- **Background**: `https://storage.getlayers.ai/assets/kumo-0d94304971/sequence-1/frame-00000.webp` as a full-bleed `object-fit: cover`
image.
- **Top fade**: `top 0; height 20rem` (tablet 19.1875rem, portrait 25.625rem, mobile 21.125rem),
`linear-gradient(to bottom, paper 0%, paper/85 35%, paper/0 100%)`.
- **Bottom fade**: `bottom 0; height 23.75rem` (tablet 26rem, portrait 30.375rem, mobile 25.0625rem),
`linear-gradient(to top, paper/92 0%, paper/60 55%, paper/0 100%)`.
- **`<h2 class="sr-only">Contact</h2>`**.
- **Running-line band**: `absolute; inset-inline 0; top 13.5rem; height 14rem` (tablet `top 17.1875;
h 9.625`; portrait `top 16; h 9.5`; mobile `top 13.75; h 7.5`).
- **Two rules**, top and bottom, each `height 1px; background: ink/30; left 4rem; width 82rem`
(tablet `left 2rem; width 60rem`; portrait `right 2rem; width auto`). The top rule is `left/right
1.25rem` on mobile.
- **A marquee** at `top 1.5rem` (tablet 1.5625rem, portrait 1.125rem, mobile 1.25rem), with
`aria-label "Have a story? Write to us"`. Its track is `gap 3rem; padding-right 3rem` (portrait
2.5rem, mobile 1.75rem), Reggae One, **running** size, ink/88.
- The items are "Have a story that needs a sky?" and "Write to us", each `white-space: nowrap` and
each a **written line with its own visibility** (every copy writes itself in when shown).
- The separator is a `3rem` ink/88 circle (tablet 2.25rem, portrait 2.5rem, mobile 1.75rem).
- The loop is **60000ms**.
- **Email**: a `mailto:hello@layers.studio` link at `top 29.5rem; left 3rem` (tablet `top 28.8125rem;
left 2rem`; portrait `top 27rem`; mobile `top 22.5rem; left 1.25rem`). Onest, link size, weight 500,
tracking `-0.0625rem`, underlined, with the misted text "hello@layers.studio →".
- **Footer columns**: `absolute; bottom 5.5625rem; left 3rem; display:flex; gap 1.5rem`.
- tablet: `bottom 5.5rem; left 2rem`
- portrait: `bottom 6.5rem`, a 2-column grid with `column-gap 1rem; row-gap 2rem`
- mobile: `bottom 5rem; left 1.25rem; column-gap 0.625rem; row-gap 1.5rem`
Each column is a flex column with `gap 0.75rem; width 19.875rem` (tablet 13.875rem, portrait 21.5rem,
mobile 10.625rem). It holds an `<h3>` (mono, label size, gold, uppercase, misted with delay
`col * 120`) and a `<p>` (Onest, nav size), whose lines are `display:block` and misted with delay
`col * 120 + 90`:
- **Studio**: "Somewhere under this sky" (mobile: "Under this sky") · "Working with teams worldwide"
(mobile: "Working worldwide")
- **Say hello**: hello@layers.studio (mailto) · jobs@layers.studio (mailto)
- **Follow**: "Instagram · Vimeo" · "Behance · YouTube"
- **Layers**: "© 2026 Layers animation studio" (from tablet down: "© 2026 Layers") · "Back to top ↑"
(link `#top`)
- **Portrait rule**: `top 41.5rem; inset-inline 2rem; height 1px; background: ink/30`, portrait only
(mobile `inset 1.25rem; top 34.25rem`).
- **Film ticker** at `bottom 1.5rem` (mobile 1.25rem), with "Scene 05 — Back to the sky",
"Frame 0450 / 0450", "Thanks for watching" and "hello@layers.studio".
In-page links (`#contact`, `#top` and so on) scroll with `lenis.scrollTo(target)`.
## Reduced motion
Everything still lays out and every scrubbed picture still follows the scroll. The changes are:
- Every spring and tween lands immediately: the hero, header, captions, preloader steps and menu.
- Written and misted lines appear without blur.
- The kanji marks appear whole.
- The CRT drift, both marquees and the live-dot ring don't run.
- The nav flip has no transition.
## Fixed parameters (bake these in)
- Sequences: 3 × 150 frames, 1920×1080 WebP, with a portrait cut of 608×1080 (centre crop).
- Portrait-cut threshold: `w / h <= 608 / 1080`. Canvas DPR is `max(1, min(dpr, 2, framePx/cssPx))`.
- Painter constants: preload margin `100%` of the viewport; 6 requests per task, nearest first; bitmap
window 16 ahead, 5 behind, 22 kept.
- Preloader: 30 warm frames plus the hero frame; ceiling 0.96; minimum 2400ms; `p` steps at
`{40, 26}`; then 450ms easeOutCubic to 1; 120ms hold; exit 1300ms easeOutQuint. Windows on `e`:
`SCREEN_CLEAR [0, .3]`, `ASCII_OUT [0, .2]`, `NOISE_IN [0, .08]`, `NOISE_OUT [.08, .6]`,
`CHROME_OUT [0, .45]`. Noise peak opacity 0.45.
- Scene spans (viewports): skyline 4 · wipe 2 · student 4 · cloud pass 3 · window 6.5 · ring 5 ·
contact 1. Lead-ins: wipe, student, cloud pass, window. Works sheet `margin-top: -115svh`.
- Lead frames 51, so `frameStart = 50/149` for the student and window sequences. Default `frameEnd`
is 0.985, and the window sequence uses 0.709 with zoom 1.15.
- Clouds: `COVER_AT 0.5`, approach from 0.7, depart over 1 (wipe) and 0.45 (cloud pass), fill
`[.28, .44]`/`[.56, .72]`.
- Text: letters 560ms, scatter 460ms, scrub share 0.45, blur 12px. Mist lines 950ms, row stagger
140ms, blur 4px. Kanji strokes 400ms, stagger 280ms, translation opacity 0.75. The shown threshold is
opacity 0.6, the entry margin 4%, polled every 50ms.
- Springs: default `{170, 26}`; hero and header entrance `{60, 22}`; menu `{260, 30}`.
- Loops: CRT drift 2600ms linear; ticker 80000ms; running line 60000ms; live dot 1600ms easeOutCubic,
ring to 2.6×.
- Header light ramps: `SKY_TO_STREET [.015, .07]`, `GIRL_TO_SKY [.9, .99]`,
`STREET_TO_ROOM [.009, .073]`, `ROOM_TO_PAPER [.76, .81]`.
- Ring: 12 cards a ring at 30°, radius 37.5rem (far, offset 15°) and 42.5rem (near); perspective
200rem; name hold 0.18, fade 0.45.
## Assets
All from `ASSET_BASE_URL = https://storage.getlayers.ai/assets/kumo-0d94304971`:
- `sequence-1/frame-00000.webp` … `sequence-1/frame-00149.webp`: street flythrough, 1920×1080.
- `sequence-1-portrait/frame-00000.webp` … `frame-00149.webp`: its centre 608×1080 cut.
- `sequence-2/…` and `sequence-2-portrait/…`: the student looking up (same naming).
- `sequence-3/…` and `sequence-3-portrait/…`: street → studio → window → skyline (same naming).
- `home/cloud-1-soft.webp` (709×405), `home/cloud-1-deep.webp` (751×447), `home/cloud-2-soft.webp`
(1116×467), `home/cloud-2-deep.webp` (1158×509), `home/cloud-3-soft.webp` (1116×526),
`home/cloud-3-deep.webp` (1158×568), `home/cloud-4-soft.webp` (1116×726), `home/cloud-4-deep.webp`
(1158×768). These are the pre-blurred cloud cut-outs for the curtains.
- `home/cloud.png` (560×282) and `home/cloud-bank.png` (900×202): the works-intro clouds, and the bank
is also the cloud-edge mask.
- `home/screen-mask.svg`: the barrel-shaped TV screen mask.
- `home/ticker-dot.svg`: the 6×6 gold ticker separator.
Fonts come from Google Fonts as above. There are no other assets: the grain is an inline SVG data URI,
and everything else is CSS.[/hidecontent]