Install
That writes one file:compositions/camera-dolly-zoom.html.
Add it to your video
It runs for 4 seconds at 1920×1080. Paste this into your composition:index.html
<div
data-composition-id="camera-dolly-zoom"
data-composition-src="compositions/camera-dolly-zoom.html"
data-start="0"
data-duration="4"
data-track-index="1"
data-width="1920"
data-height="1080"
></div>
data-start. Put it on a different timeline row with
data-track-index. See data attributes for the rest.
Variables
Every one of these has a default, so the piece works untouched. Set the ones you want to change on the element:| Variable | Default | Accepts | What it does |
|---|---|---|---|
direction | out | out, in | |
strength | 2 | 1.1 to 4, step 0.1 | |
subjectDistance | 1400 | 600px to 3000px, step 50px | |
easing | power2.inOut | none, sine.inOut, power1.inOut, power2.inOut, power3.inOut |
data-variable-values on the element that mounts it. These are the
defaults, so this behaves exactly like the preview above until you change one:
<div
data-composition-id="camera-dolly-zoom"
data-composition-src="compositions/camera-dolly-zoom.html"
data-variable-values='{"direction":"out","strength":2,"subjectDistance":1400,"easing":"power2.inOut"}'
></div>
Source
camera-dolly-zoom.html
camera-dolly-zoom.html
<!doctype html>
<html
lang="en"
data-composition-variables='[
{
"id": "direction",
"type": "enum",
"label": "Dolly direction",
"default": "out",
"options": [
{ "value": "out", "label": "Dolly out + zoom in (background rushes in)" },
{ "value": "in", "label": "Dolly in + zoom out (background falls away)" }
]
},
{
"id": "strength",
"type": "number",
"label": "Move strength (end / start distance ratio)",
"default": 2,
"min": 1.1,
"max": 4,
"step": 0.1
},
{
"id": "subjectDistance",
"type": "number",
"label": "Camera-to-subject distance at the start of the move",
"default": 1400,
"min": 600,
"max": 3000,
"step": 50,
"unit": "px"
},
{
"id": "easing",
"type": "enum",
"label": "Dolly easing",
"default": "power2.inOut",
"options": [
{ "value": "none", "label": "Linear" },
{ "value": "sine.inOut", "label": "Sine in-out" },
{ "value": "power1.inOut", "label": "Power1 in-out" },
{ "value": "power2.inOut", "label": "Power2 in-out" },
{ "value": "power3.inOut", "label": "Power3 in-out" }
]
}
]'
>
<head>
<meta charset="UTF-8" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 1920px;
height: 1080px;
overflow: hidden;
}
</style>
</head>
<body>
<!--
===================================================================
DOLLY ZOOM (Vertigo / Hitchcock shot)
===================================================================
The camera tracks toward or away from the subject while the field of
view changes the opposite way, so the SUBJECT HOLDS ITS FRAME SIZE
while the background collapses behind it or rushes in.
THE CONSTRAINT (this is the whole effect, and it is exact, not taste):
d * tan(FOV / 2) = constant
where d is the camera-to-subject distance. Get it wrong and the shot
reads as a clumsy zoom. So focal length is SOLVED from distance every
frame; the two are never keyframed side by side.
In CSS 3D, `perspective: P px` IS the focal length in pixels, and it
also places the camera exactly P px in front of the z = 0 plane, so
tan(FOV / 2) = (viewportWidth / 2) / P. Substituting collapses the
invariant to a single ratio:
P(t) / d(t) = SUBJECT_SCALE (constant)
Everything the rig animates falls out of that one line (see the script).
-------------------------------------------------------------------
WHAT THIS PRIMITIVE REQUIRES OF ITS CONTENT
-------------------------------------------------------------------
DEPTH. A flat element gains nothing from a dolly zoom -- with one
layer there is no background to collapse and the move is invisible.
Content dropped into #dz-rig MUST be layered or genuinely 3D:
* The SUBJECT sits on the z = 0 plane -- `translateZ(0)`. That is
the plane the solve holds; anything you want frame-locked goes
there and nowhere else.
* BACKGROUND layers get `translateZ(-N px)`, N > 0, at least two or
three distinct depths (this demo ships five arches plus a back
wall). More separation = stronger effect.
* Everything inside #dz-rig must keep `transform-style: preserve-3d`
on its ancestors and must NOT sit behind `overflow: hidden`, or
the browser flattens the scene and the depth disappears.
* Keep every layer at a depth where `d + N > 0` for the whole move,
i.e. no layer in front of the camera's closest approach.
-------------------------------------------------------------------
DETERMINISM
-------------------------------------------------------------------
State at frame N is computed from N alone. d(t) is a lerp of the
eased tween progress, P is solved from d, and both are written by a
property setter on the tweened driver object. Nothing accumulates,
nothing reads a clock, and no frame depends on the frame before it.
(The setter matters: `tl.eventCallback("onUpdate", ...)` does NOT
fire on `tl.seek()`, so anything driven that way freezes on frame 0.)
-->
<div
id="dz-root"
data-composition-id="camera-dolly-zoom"
data-start="0"
data-duration="4"
data-width="1920"
data-height="1080"
>
<style>
#dz-root {
width: 1920px;
height: 1080px;
position: relative;
overflow: hidden;
background: #0b1020;
font-family: Inter, sans-serif;
}
/* The camera. `perspective` is the focal length and is solved from
the dolly distance every frame -- see the script. */
#dz-stage {
position: absolute;
inset: 0;
perspective-origin: 50% 50%;
}
/* The rig root. Carries the camera's z offset so the subject plane
always sits exactly `d` in front of the camera. */
#dz-rig {
position: absolute;
inset: 0;
transform-style: preserve-3d;
}
/* --- authored depth layers ------------------------------------ */
.dz-arch {
position: absolute;
left: -140px;
top: -80px;
width: 2200px;
height: 1240px;
border: 16px solid #465280;
border-radius: 8px;
transform-style: preserve-3d;
}
#dz-backwall {
position: absolute;
left: -3040px;
top: -1710px;
width: 8000px;
height: 4500px;
background: #10162c;
transform-style: preserve-3d;
}
/* Bright doorway on the back wall -- the layer whose on-screen size
changes while the subject's does not. */
#dz-portal {
position: absolute;
left: 3300px;
top: 1850px;
width: 1400px;
height: 1500px;
background: #cfe4ff;
}
/* --- the subject: z = 0, the plane the solve holds ------------- */
#dz-subject {
position: absolute;
left: 840px;
top: 300px;
width: 240px;
height: 620px;
transform-style: preserve-3d;
}
.dz-fig-head {
width: 120px;
height: 120px;
margin: 0 auto;
border-radius: 50%;
background: #f2b134;
}
.dz-fig-body {
width: 240px;
height: 470px;
margin-top: 30px;
border-radius: 120px 120px 10px 10px;
background: #f2b134;
}
/* --- flat overlay (outside the 3D rig) ------------------------- */
.dz-label {
position: absolute;
left: 64px;
bottom: 56px;
padding: 14px 22px;
border-radius: 8px;
background: #0b1020;
color: #e9eeff;
font-size: 26px;
letter-spacing: 0.14em;
text-transform: uppercase;
}
</style>
<div id="dz-stage" class="clip" data-start="0" data-duration="4" data-track-index="0">
<div id="dz-rig">
<div id="dz-backwall" data-layout-allow-overflow style="transform: translateZ(-3200px)">
<div id="dz-portal"></div>
</div>
<div
class="dz-arch"
data-layout-allow-overflow
style="transform: translateZ(-2360px); border-color: #384166"
></div>
<div
class="dz-arch"
data-layout-allow-overflow
style="transform: translateZ(-1720px); border-color: #465280"
></div>
<div
class="dz-arch"
data-layout-allow-overflow
style="transform: translateZ(-1120px); border-color: #57669c"
></div>
<div
class="dz-arch"
data-layout-allow-overflow
style="transform: translateZ(-560px); border-color: #6b7cb8"
></div>
<div
class="dz-arch"
data-layout-allow-overflow
style="transform: translateZ(0px); border-color: #8194d4"
></div>
<div id="dz-subject" style="transform: translateZ(0px)">
<div class="dz-fig-head"></div>
<div class="dz-fig-body"></div>
</div>
</div>
</div>
<div class="dz-label">Dolly zoom · d × tan(fov/2) = const</div>
</div>
<script>
window.__timelines = window.__timelines || {};
(function () {
var DEFAULTS = {
direction: "out",
strength: 2,
subjectDistance: 1400,
easing: "power2.inOut",
};
var declared =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
var vars = Object.assign({}, DEFAULTS, declared);
function clamp(value, lo, hi, fallback) {
var n = Number(value);
if (!isFinite(n)) return fallback;
return Math.min(hi, Math.max(lo, n));
}
function oneOf(value, allowed, fallback) {
return allowed.indexOf(String(value)) >= 0 ? String(value) : fallback;
}
var direction = oneOf(vars.direction, ["in", "out"], DEFAULTS.direction);
var strength = clamp(vars.strength, 1.1, 4, DEFAULTS.strength);
var ease = oneOf(
vars.easing,
["none", "sine.inOut", "power1.inOut", "power2.inOut", "power3.inOut"],
DEFAULTS.easing,
);
// d0 = camera-to-subject distance at the start of the move (px).
// d1 = distance at the end. "out" pulls the camera back and zooms in;
// "in" pushes the camera forward and zooms out.
var d0 = clamp(vars.subjectDistance, 600, 3000, DEFAULTS.subjectDistance);
var d1 = direction === "in" ? d0 / strength : d0 * strength;
// ---- the solve --------------------------------------------------
// Subject size is fixed iff d * tan(FOV/2) = const. With CSS 3D,
// tan(FOV/2) = (width/2) / perspective, so that invariant is exactly
// perspective / distance = SUBJECT_SCALE. Focal length is therefore
// derived from the dolly, never animated alongside it.
var SUBJECT_SCALE = 1; // subject renders 1:1 => its plane is z = 0
var stage = document.getElementById("dz-stage");
var rig = document.getElementById("dz-rig");
function applyCamera(u) {
var d = d0 + (d1 - d0) * u; // dolly -- pure function of u
var P = SUBJECT_SCALE * d; // focal length solved from d
var camZ = P - d; // put the subject plane at camera distance d
stage.style.perspective = P + "px";
rig.style.transform = "translateZ(" + camZ + "px)";
}
// GSAP suppresses timeline-level onUpdate during seek(), so the camera
// is driven by a property setter on the tweened object instead: that
// fires on every render, seeks included.
var camera = {
_u: 0,
get u() {
return this._u;
},
set u(value) {
this._u = value;
applyCamera(value);
},
};
applyCamera(0);
var tl = gsap.timeline({ paused: true });
tl.to(camera, { u: 1, duration: 4, ease: ease }, 0);
window.__timelines["camera-dolly-zoom"] = tl;
})();
</script>
</body>
</html>
camera depth 3d cinematic.