Skill 18 · Hyperframes Animation
Subchapter 18.103
rules/viewport-change.mdMarkdown9 KBView on GitHub
Simulates camera effects (zoom / pan / focus-lock on a moving element) by transforming a wrapper around ALL scene content. The “world” moves opposite to the perceived camera. Distinct from multi-phase-camera (2-3 discrete phases + drift) — viewport-change is a single continuous zoom/pan, often used for focus-lock following a moving element.
Camera intent → world transform. Camera pans right → world translateX(-distance); camera zooms in → world scale(>1); camera follows element X → world translateX(viewportCenter - elementWorldX) per-frame. Get the sign right or everything moves the wrong way. The single .world wrapper holds the camera transform; elements inside are positioned in world space, unchanged.
Single-element composite transform (this rule’s form). Both scale and translate live on ONE wrapper as translate(x, y) scale(S). CSS applies scale FIRST, then translate (right-to-left matrix composition), so a point at world offset (ox, oy) lands on screen at (S × ox + x, S × oy + y). To map the target to viewport center, solve S × offset + T = 0:
T = -offset × SThis is different from coordinate-target-zoom, which uses two nested wrappers (outer scales, inner translates) and derives T = -offset (independent of S). Mixing up the two forms drifts the target off-center as scale changes. Use this single-wrapper form when you want one source of truth for camera state (cam.scale, cam.x, cam.y) written via onUpdate; use nested wrappers when scale and translate can tween independently with shared ease.
<div class="world" id="world">
<div class="content">
<div class="hero">{Brand}</div>
<div class="tagline">{tagline}</div>
<div class="cta" id="cta">{ctaUrl}</div>
</div>
</div>.scene {
overflow: hidden; /* REQUIRED — any non-1.0 scale reveals edges or pushes content off-frame */
background: {bgGradient}; /* on .scene, NOT .world — a world-borne background warps with the camera */
}
.world {
position: absolute;
inset: 0;
display: grid;
place-items: center;
transform-origin: 50% 50%; /* centered scaling is what the math assumes */
will-change: transform;
}const world = document.getElementById("world");
// Camera state — single source of truth. The world transform is composed from
// this object in ONE place so the transform string order is stable.
const cam = { scale: 1, x: 0, y: 0 };
function applyCamera() {
world.style.transform = `translate(${cam.x}px, ${cam.y}px) scale(${cam.scale})`;
}
applyCamera(); // seed frame 0
// Zoom in on the CTA: single-element composite transform → T = -offset × S.
// TARGET_OFFSET_Y is the target's measured offset from viewport center at
// neutral camera (sign matters — positive = below center).
const counterY = -TARGET_OFFSET_Y * TARGET_SCALE;
tl.to(
cam,
{
scale: TARGET_SCALE,
y: counterY,
duration: ZOOM_DUR,
ease: "power3.inOut",
onUpdate: applyCamera,
},
ZOOM_START,
);| Effect | Scale | Feel |
|---|---|---|
| Subtle | 1.02 - 1.05 | Barely perceptible — “professional” |
| Medium | 1.05 - 1.15 | “Ta-da” emphasis |
| Noticeable | 1.15 - 1.30 | Focus on region |
| Dramatic | 1.5 - 2.5 | Element fills screen |
| Full-screen | 3.0+ | Element covers viewport |
Perception: < 5% scale change is imperceptible; 10-15% is comfortable emphasis; > 30% is cinematic/dramatic. For a natural product feel, prefer 1.05-1.15× over 2-3s; save big > 1.3× zooms for dramatic narrative moments.
The same single-cam math runs far past the table: a zoom-out workspace reveal opens punched-in at 4–12× on one detail (a single cell, message, or button) and pulls out to the full workspace in one continuous move. The mechanics don’t change — one cam object, T = -offset × S, one applyCamera() writer — only the authoring direction does:
cam.scale = 8, counter-translate aiming the opening detail; state it in a fromTo / seed via applyCamera() so a seek to t=0 lands punched-in). The wide landing frame is then everything at native design size — text crisp, raster assets at source resolution.getBoundingClientRect after fonts.ready, once at setup — the measuring doctrine in coordinate-target-zoom.md), never from a layout formula.S × its design size on the first frames (vector/DOM text is safe; raster needs sourceResolution ≥ rendered × S).onUpdate:const focusEl = document.querySelector(".moving-cursor");
const targetScreenX = VIEWPORT_WIDTH * FOCUS_SCREEN_X_FRAC; // 0.4–0.7; 0.5 = dead center
const focusUpdate = { p: 0 };
tl.to(
focusUpdate,
{
p: 1,
duration: FOLLOW_DUR, // matches how long the focused element is in motion
ease: "power2.inOut",
onUpdate: () => {
const rect = focusEl.getBoundingClientRect();
cam.x = targetScreenX - (rect.left + rect.width / 2);
applyCamera();
},
},
FOLLOW_START,
);cam.scale = scaleUp.v * scaleDown.v; applyCamera(). Combine a slow push-in (~1.15) with a brief release (~0.9) for a breath/punch shape.| token | range | notes |
|---|---|---|
| TARGET_OFFSET_Y | measured, not a free parameter | target’s offset from viewport center at neutral camera; measure via getBoundingClientRect |
| TARGET_SCALE | 1.3× modest → 1.6–2.0× typical → 3×+ | raster media needs sourceResolution ≥ rendered × TARGET_SCALE |
| ZOOM_START | content landed + ~0.5s scan time | let the viewer read before the camera moves |
| ZOOM_DUR | 1.0–2.0s | under 0.8s teleports, over 2.5s drags |
| DWELL | ≥ 1.0s after the zoom settles | the viewer must be able to read the focal point (climax dwell) |
| VIEWPORT_WIDTH | = the root’s data-width | real value, not abstract |
.world wrapper carries the whole camera — every scene element lives inside it; a second transformed wrapper is a second camera.cam object + applyCamera() — when scale and translate both change, write them in ONE place; never split them across tweens that touch world.style.transform directly (the transform string composition order becomes unpredictable).T = -offset × S — don’t import the nested-wrapper T = -offset formula.overflow: hidden on .scene; transform-origin: 50% 50% on .world; background on .scene, never on .world.coordinate-target-zoom.md (nested-wrapper alternative, T = -offset) · multi-phase-camera.md (viewport-change inside one phase) · sine-wave-loop.md (idle micro-drift after the viewport settles).