> ## Documentation Index
> Fetch the complete documentation index at: https://hyperframes.heygen.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Dolly Zoom (Vertigo Shot)

> 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. Focal length is solved from dolly distance every frame against d * tan(FOV/2) = const, not keyframed beside it. Requires depth: the subject sits on the z = 0 plane and the content behind it must be layered or genuinely 3D (translateZ at two or more distinct depths) -- a flat element gains nothing from this move.

export const InstallCommand = ({command}) => {
  const [copied, setCopied] = React.useState(false);
  const copy = async () => {
    try {
      if (navigator.clipboard && window.isSecureContext) {
        await navigator.clipboard.writeText(command);
      } else {
        const previous = document.activeElement;
        const scratch = document.createElement("textarea");
        scratch.value = command;
        scratch.setAttribute("readonly", "");
        scratch.style.position = "fixed";
        scratch.style.opacity = "0";
        document.body.appendChild(scratch);
        scratch.select();
        document.execCommand("copy");
        document.body.removeChild(scratch);
        previous?.focus?.();
      }
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    } catch {}
  };
  return <div className="hf-install-command not-prose my-4 flex items-stretch overflow-hidden rounded-xl border border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
      <code className="flex-1 overflow-x-auto whitespace-nowrap border-r border-zinc-200 px-4 py-3 font-mono text-sm text-zinc-800 dark:border-zinc-800 dark:text-zinc-100">
        {command}
      </code>
      <button type="button" onClick={copy} data-copied={copied ? "true" : "false"} aria-label={`Copy ${command} to the clipboard`} className="hf-install-copy">
        <svg className="hf-install-copy-clipboard" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M14.25 5.25H7.25C6.14543 5.25 5.25 6.14543 5.25 7.25V14.25C5.25 15.3546 6.14543 16.25 7.25 16.25H14.25C15.3546 16.25 16.25 15.3546 16.25 14.25V7.25C16.25 6.14543 15.3546 5.25 14.25 5.25Z" />
          <path d="M2.80103 11.998L1.77203 5.07397C1.61003 3.98097 2.36403 2.96397 3.45603 2.80197L10.38 1.77297C11.313 1.63397 12.19 2.16297 12.528 3.00097" />
        </svg>
        <svg className="hf-install-copy-check" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M2.75 9.5L6.5 13.25L15.25 4.5" />
        </svg>
      </button>
      <span className="hf-install-copy-status" role="status" aria-live="polite">
        {copied ? "Copied" : ""}
      </span>
    </div>;
};

<iframe className="w-full aspect-video rounded-xl border-0 bg-zinc-100 dark:bg-zinc-800" title="camera-dolly-zoom preview" loading="lazy" srcDoc={`<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%;overflow:hidden;background:transparent}hyperframes-player{display:block;width:100%;height:100%}</style><script src="https://cdn.jsdelivr.net/npm/@hyperframes/player@0.7/dist/hyperframes-player.global.js"><\/script></head><body><script>fetch("/public/catalog/blocks/camera-dolly-zoom.json").then(function(r){return r.json()}).then(function(d){var p=document.createElement("hyperframes-player");p.setAttribute("srcdoc",d.html);p.setAttribute("controls","");p.setAttribute("autoplay","");p.setAttribute("loop","");p.setAttribute("muted","");p.setAttribute("poster","https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/camera-dolly-zoom.png");document.body.appendChild(p)});<\/script></body></html>`} />

## Install

<InstallCommand command="npx hyperframes add camera-dolly-zoom" />

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:

```html index.html theme={null}
<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>
```

Move it in time with `data-start`. Put it on a different timeline row with
`data-track-index`. See [data attributes](/concepts/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` |              |

Set them with `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:

```html wrap theme={null}
<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

<Accordion title={`camera-dolly-zoom.html`}>
  ```html theme={null}
  <!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 &middot; d &times; 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>
  ```
</Accordion>

Tagged `camera` `depth` `3d` `cinematic`.

## Related topics

* [Browse the complete Catalog](/catalog)
* [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
* [Build a richer composition](/go-further)


## Related topics

- [Cinematic Zoom](/catalog/blocks/cinematic-zoom.md)
- [Parallax Zoom](/catalog/components/parallax-zoom.md)
- [Zoom Through Transition](/catalog/components/zoom-through-transition.md)
