> ## Documentation Index
> Fetch the complete documentation index at: https://hyperframes-fix-nested-composition-media-inpoint.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Portal

> VFX composition block

export const InstallCommand = ({command, item}) => {
  const [copied, setCopied] = React.useState(false);
  const [tuned, setTuned] = React.useState("");
  React.useEffect(() => {
    if (!item) return;
    const read = () => {
      try {
        const raw = new URLSearchParams(window.location.search).get(`vars-${item}`);
        if (!raw) return setTuned("");
        const parsed = JSON.parse(raw);
        if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return setTuned("");
        if (Object.keys(parsed).length === 0) return setTuned("");
        setTuned(` --vars '${JSON.stringify(parsed)}'`);
      } catch {
        setTuned("");
      }
    };
    read();
    window.addEventListener("hf-vars-changed", read);
    window.addEventListener("popstate", read);
    return () => {
      window.removeEventListener("hf-vars-changed", read);
      window.removeEventListener("popstate", read);
    };
  }, [item]);
  const fullCommand = `${command}${tuned}`;
  const copy = async () => {
    try {
      if (navigator.clipboard && window.isSecureContext) {
        await navigator.clipboard.writeText(fullCommand);
      } else {
        const previous = document.activeElement;
        const scratch = document.createElement("textarea");
        scratch.value = fullCommand;
        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">
        {fullCommand}
      </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>;
};

<video className="w-full aspect-video rounded-xl object-cover bg-zinc-100 dark:bg-zinc-800" src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-portal.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vfx-portal.png" autoPlay muted loop playsInline />

## Install

<InstallCommand command="npx hyperframes add vfx-portal" item="vfx-portal" />

That writes one file: `compositions/vfx-portal.html`.

<Danger>
  Live preview needs the `chrome://flags/#canvas-draw-element` flag switched on.
  Without it this item's screen renders black. Rendering from the CLI switches
  it on for you. [How it works](/guides/html-in-canvas)
</Danger>

## Change the colors

Set these CSS variables on the block:

* `--bg-color` — Background. Defaults to `#000000`.
* `--portal-color` — Portal color. Defaults to `#fafafa`.

## Source

<Accordion title={`vfx-portal.html`}>
  ```html theme={null}
  <!doctype html>
  <html lang="en">
    <head>
      <meta charset="utf-8" />
      <meta name="viewport" content="width=1920, height=1080" />
      <title>HTML Portal Transition</title>
      <link
        href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
        rel="stylesheet"
      />
      <script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
      <script src="https://cdn.jsdelivr.net/npm/three@0.147.0/build/three.min.js"></script>
      <style>
        *,
        *::before,
        *::after {
          margin: 0;
          padding: 0;
          box-sizing: border-box;
        }
        body {
          background: #000;
          overflow: hidden;
          font-family: "Inter", system-ui, sans-serif;
        }

        #root {
          position: relative;
          width: 1920px;
          height: 1080px;
          overflow: hidden;
          background: #000;
        }

        .panel {
          position: absolute;
          top: 0;
          left: 0;
          width: 1920px;
          height: 1080px;
          overflow: hidden;
        }

        /* ── Shared Site Layout ──────────────────────────────── */
        .site-wrap {
          display: flex;
          flex-direction: column;
          width: 100%;
          height: 100%;
        }

        .topbar {
          display: flex;
          align-items: center;
          justify-content: space-between;
          padding: 28px 64px;
        }
        .logo {
          font-size: 24px;
          font-weight: 800;
          letter-spacing: -0.5px;
        }
        .nav {
          display: flex;
          gap: 36px;
        }
        .nav span {
          font-size: 15px;
          font-weight: 500;
        }
        .nav-cta {
          font-size: 14px;
          font-weight: 600;
          padding: 10px 24px;
          border-radius: 8px;
          border: none;
          cursor: pointer;
        }

        .hero {
          flex: 1;
          display: flex;
          flex-direction: column;
          align-items: center;
          justify-content: center;
          text-align: center;
          gap: 24px;
          padding: 0 200px;
        }
        .hero-title {
          font-size: 72px;
          font-weight: 800;
          letter-spacing: -2px;
          line-height: 1.05;
        }
        .hero-sub {
          font-size: 20px;
          line-height: 1.6;
          max-width: 640px;
        }
        .hero-btn {
          display: inline-block;
          padding: 16px 40px;
          border-radius: 12px;
          font-size: 17px;
          font-weight: 700;
          border: none;
          cursor: pointer;
          letter-spacing: -0.2px;
        }

        .cards-row {
          display: flex;
          gap: 24px;
          padding: 0 64px 56px;
        }
        .feature-card {
          flex: 1;
          padding: 32px;
          border-radius: 16px;
        }
        .card-icon {
          width: 44px;
          height: 44px;
          border-radius: 10px;
          display: flex;
          align-items: center;
          justify-content: center;
          font-size: 22px;
          margin-bottom: 16px;
        }
        .card-title {
          font-size: 18px;
          font-weight: 700;
          margin-bottom: 6px;
        }
        .card-desc {
          font-size: 14px;
          line-height: 1.5;
        }

        /* ── Light Mode ──────────────────────────────────────── */
        .light .site-wrap {
          background: #fafafa;
          color: #111;
        }
        .light .logo {
          color: #111;
        }
        .light .nav span {
          color: #555;
        }
        .light .nav-cta {
          background: #7c3aed;
          color: #fff;
        }
        .light .hero-title {
          color: #111;
        }
        .light .hero-sub {
          color: #666;
        }
        .light .hero-btn {
          background: #7c3aed;
          color: #fff;
        }
        .light .feature-card {
          background: #fff;
          border: 1px solid #e5e7eb;
        }
        .light .card-icon {
          background: #f3f0ff;
          color: #7c3aed;
        }
        .light .card-title {
          color: #111;
        }
        .light .card-desc {
          color: #777;
        }

        /* ── Dark Mode ───────────────────────────────────────── */
        .dark .site-wrap {
          background: #0a0a0f;
          color: #f0f0f5;
        }
        .dark .logo {
          background: linear-gradient(135deg, #a78bfa, #c084fc);
          -webkit-background-clip: text;
          -webkit-text-fill-color: transparent;
        }
        .dark .nav span {
          color: rgba(255, 255, 255, 0.5);
        }
        .dark .nav-cta {
          background: linear-gradient(135deg, #7c3aed, #a855f7);
          color: #fff;
          box-shadow: 0 0 24px rgba(124, 58, 237, 0.3);
        }
        .dark .hero-title {
          color: #f0f0f5;
        }
        .dark .hero-sub {
          color: rgba(255, 255, 255, 0.5);
        }
        .dark .hero-btn {
          background: linear-gradient(135deg, #7c3aed, #a855f7);
          color: #fff;
          box-shadow: 0 0 32px rgba(124, 58, 237, 0.35);
        }
        .dark .feature-card {
          background: rgba(255, 255, 255, 0.03);
          border: 1px solid rgba(255, 255, 255, 0.08);
          box-shadow: 0 0 20px rgba(124, 58, 237, 0.06);
        }
        .dark .card-icon {
          background: rgba(124, 58, 237, 0.15);
          color: #a78bfa;
        }
        .dark .card-title {
          color: #f0f0f5;
        }
        .dark .card-desc {
          color: rgba(255, 255, 255, 0.45);
        }

        #error-overlay {
          position: absolute;
          inset: 0;
          background: #000;
          display: none;
          align-items: center;
          justify-content: center;
          z-index: 100;
          flex-direction: column;
          gap: 16px;
          color: #fff;
        }
        #error-overlay.visible {
          display: flex;
        }
        #error-overlay h2 {
          font-size: 24px;
          font-weight: 600;
        }
        #error-overlay p {
          font-size: 14px;
          color: rgba(255, 255, 255, 0.5);
          max-width: 480px;
          text-align: center;
          line-height: 1.6;
        }
        #error-overlay code {
          background: rgba(255, 255, 255, 0.1);
          padding: 2px 8px;
          border-radius: 4px;
          font-size: 13px;
        }
      </style>
    </head>
    <body>
      <div
        id="root"
        data-composition-id="portal-transition"
        data-width="1920"
        data-height="1080"
        data-start="0"
        data-duration="10"
        data-root="true"
      >
        <!-- Light mode panel (front) -->
        <canvas
          id="cap-light"
          layoutsubtree
          width="1920"
          height="1080"
          style="
            position: absolute;
            top: 0;
            left: 0;
            width: 1920px;
            height: 1080px;
            z-index: -1;
            pointer-events: none;
          "
        >
          <section
            class="panel light"
            id="panel-light"
            data-panel-start="0"
            data-panel-duration="10"
            style="width: 1920px; height: 1080px"
          >
            <div class="site-wrap">
              <div class="topbar">
                <div class="logo">HyperFrames</div>
                <div class="nav">
                  <span>Docs</span>
                  <span>Catalog</span>
                  <span>GitHub</span>
                  <button class="nav-cta">Sign In</button>
                </div>
              </div>
              <div class="hero">
                <h1 class="hero-title">Write HTML<br />Render Video</h1>
                <p class="hero-sub">
                  Ship faster with a platform that handles the hard parts. Analytics, infrastructure,
                  and developer tools — all in one place.
                </p>
                <button class="hero-btn">Start Creating</button>
              </div>
              <div class="cards-row">
                <div class="feature-card">
                  <div class="card-icon">&#9889;</div>
                  <div class="card-title">Lightning Fast</div>
                  <div class="card-desc">
                    Sub-millisecond response times with edge deployment across 40+ regions worldwide.
                  </div>
                </div>
                <div class="feature-card">
                  <div class="card-icon">&#128274;</div>
                  <div class="card-title">Secure by Default</div>
                  <div class="card-desc">
                    SOC2 certified. End-to-end encryption. Role-based access out of the box.
                  </div>
                </div>
                <div class="feature-card">
                  <div class="card-icon">&#128268;</div>
                  <div class="card-title">Plug and Play</div>
                  <div class="card-desc">
                    200+ integrations. Connect your existing stack in minutes, not weeks.
                  </div>
                </div>
                <div class="feature-card">
                  <div class="card-icon">&#128200;</div>
                  <div class="card-title">Real-time Insights</div>
                  <div class="card-desc">
                    Live dashboards, anomaly detection, and AI-powered recommendations.
                  </div>
                </div>
              </div>
            </div>
          </section>
        </canvas>

        <!-- Dark mode panel (back) -->
        <canvas
          id="cap-dark"
          layoutsubtree
          width="1920"
          height="1080"
          style="
            position: absolute;
            top: 0;
            left: 0;
            width: 1920px;
            height: 1080px;
            z-index: -1;
            pointer-events: none;
          "
        >
          <section
            class="panel dark"
            id="panel-dark"
            data-panel-start="0"
            data-panel-duration="10"
            style="width: 1920px; height: 1080px"
          >
            <div class="site-wrap">
              <div class="topbar">
                <div class="logo">HyperFrames</div>
                <div class="nav">
                  <span>Docs</span>
                  <span>Catalog</span>
                  <span>GitHub</span>
                  <button class="nav-cta">Sign In</button>
                </div>
              </div>
              <div class="hero">
                <h1 class="hero-title">Write HTML<br />Render Video</h1>
                <p class="hero-sub">
                  Ship faster with a platform that handles the hard parts. Analytics, infrastructure,
                  and developer tools — all in one place.
                </p>
                <button class="hero-btn">Start Creating</button>
              </div>
              <div class="cards-row">
                <div class="feature-card">
                  <div class="card-icon">&#9889;</div>
                  <div class="card-title">Lightning Fast</div>
                  <div class="card-desc">
                    Sub-millisecond response times with edge deployment across 40+ regions worldwide.
                  </div>
                </div>
                <div class="feature-card">
                  <div class="card-icon">&#128274;</div>
                  <div class="card-title">Secure by Default</div>
                  <div class="card-desc">
                    SOC2 certified. End-to-end encryption. Role-based access out of the box.
                  </div>
                </div>
                <div class="feature-card">
                  <div class="card-icon">&#128268;</div>
                  <div class="card-title">Plug and Play</div>
                  <div class="card-desc">
                    200+ integrations. Connect your existing stack in minutes, not weeks.
                  </div>
                </div>
                <div class="feature-card">
                  <div class="card-icon">&#128200;</div>
                  <div class="card-title">Real-time Insights</div>
                  <div class="card-desc">
                    Live dashboards, anomaly detection, and AI-powered recommendations.
                  </div>
                </div>
              </div>
            </div>
          </section>
        </canvas>

        <!-- Three.js render target -->
        <canvas
          id="theater"
          width="1920"
          height="1080"
          style="position: absolute; top: 0; left: 0; width: 1920px; height: 1080px"
        ></canvas>

        <div id="error-overlay">
          <h2>HTML-in-Canvas Required</h2>
          <p>
            This composition requires the experimental CanvasDrawElement API. Enable it in Chrome or
            Brave:
          </p>
          <p><code>chrome://flags/#canvas-draw-element</code></p>
        </div>

        <!-- Driver clip for HyperFrames timeline sync -->
        <div
          id="driver"
          class="clip"
          data-start="0"
          data-duration="10"
          data-track-index="0"
          style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none"
        ></div>
      </div>

      <script>
        var theaterCanvas = document.getElementById("theater");
        var W = 1920,
          H = 1080;

        // ── Feature Detection ──────────────────────────────────────────────
        function isSupported() {
          if (!("layoutSubtree" in document.createElement("canvas"))) return false;
          var tc = document.createElement("canvas");
          tc.setAttribute("layoutsubtree", "");
          var ctx = tc.getContext("2d");
          return ctx && typeof ctx.drawElementImage === "function";
        }

        if (!isSupported()) {
          document.getElementById("error-overlay").classList.add("visible");
        }

        // ── Seeded PRNG (mulberry32) ───────────────────────────────────────
        function mulberry32(seed) {
          return function () {
            seed |= 0;
            seed = (seed + 0x6d2b79f5) | 0;
            var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
            t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
            return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
          };
        }
        var rng = mulberry32(42);

        // ── Canvas Capture ─────────────────────────────────────────────────
        var capLight = document.getElementById("cap-light");
        var capDark = document.getElementById("cap-dark");
        var ctxLight = capLight.getContext("2d");
        var ctxDark = capDark.getContext("2d");
        var panelLight = document.getElementById("panel-light");
        var panelDark = document.getElementById("panel-dark");

        function capturePanels() {
          ctxLight.clearRect(0, 0, W, H);
          ctxLight.drawElementImage(panelLight, 0, 0, W, H);
          ctxDark.clearRect(0, 0, W, H);
          ctxDark.drawElementImage(panelDark, 0, 0, W, H);
          lightTexture.needsUpdate = true;
          darkTexture.needsUpdate = true;
        }

        // ── Three.js Scene ─────────────────────────────────────────────────
        var renderer = new THREE.WebGLRenderer({
          canvas: theaterCanvas,
          antialias: false,
          alpha: false,
          powerPreference: "high-performance",
          preserveDrawingBuffer: true,
        });
        renderer.setSize(W, H, false);
        renderer.setPixelRatio(1);
        renderer.setClearColor(0x000000, 1);

        var scene = new THREE.Scene();
        var camera = new THREE.PerspectiveCamera(50, W / H, 0.1, 100);
        camera.position.set(0, 0, 2.8);
        camera.lookAt(0, 0, 0);

        // Calculate quad dimensions to fill the camera frustum at z=0
        var vFov = (camera.fov * Math.PI) / 180;
        var quadH = 2 * Math.tan(vFov / 2) * camera.position.z;
        var quadW = quadH * (W / H);

        // ── Textures from Captured Canvases ────────────────────────────────
        var lightTexture = new THREE.CanvasTexture(capLight);
        lightTexture.minFilter = THREE.LinearFilter;
        lightTexture.magFilter = THREE.LinearFilter;
        lightTexture.generateMipmaps = false;

        var darkTexture = new THREE.CanvasTexture(capDark);
        darkTexture.minFilter = THREE.LinearFilter;
        darkTexture.magFilter = THREE.LinearFilter;
        darkTexture.generateMipmaps = false;

        // ── Back Quad: Dark Mode (sits behind) ─────────────────────────────
        var darkGeo = new THREE.PlaneGeometry(quadW, quadH);
        var darkMat = new THREE.MeshBasicMaterial({ map: darkTexture, side: THREE.FrontSide });
        var darkQuad = new THREE.Mesh(darkGeo, darkMat);
        darkQuad.position.z = -0.01;
        scene.add(darkQuad);

        // ── Front Quad: Light Mode with Portal Shader ──────────────────────
        var portalUniforms = {
          tLight: { value: lightTexture },
          portalRadius: { value: 0.0 },
          portalCenter: { value: new THREE.Vector2(0.5, 0.5) },
          edgeWidth: { value: 0.06 },
          glowColor: { value: new THREE.Vector3(0.486, 0.227, 0.929) },
          glowColor2: { value: new THREE.Vector3(0.29, 0.565, 1.0) },
          chromaticStrength: { value: 0.025 },
          distortionStrength: { value: 0.04 },
          time: { value: 0.0 },
          energyIntensity: { value: 1.0 },
        };

        var portalVertShader = [
          "varying vec2 vUv;",
          "void main() {",
          "  vUv = uv;",
          "  gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);",
          "}",
        ].join("\n");

        var portalFragShader = [
          "uniform sampler2D tLight;",
          "uniform float portalRadius;",
          "uniform vec2 portalCenter;",
          "uniform float edgeWidth;",
          "uniform vec3 glowColor;",
          "uniform vec3 glowColor2;",
          "uniform float chromaticStrength;",
          "uniform float distortionStrength;",
          "uniform float time;",
          "uniform float energyIntensity;",
          "varying vec2 vUv;",
          "",
          "float hash(vec2 p) {",
          "  return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);",
          "}",
          "",
          "float noise(vec2 p) {",
          "  vec2 i = floor(p);",
          "  vec2 f = fract(p);",
          "  f = f * f * (3.0 - 2.0 * f);",
          "  float a = hash(i);",
          "  float b = hash(i + vec2(1.0, 0.0));",
          "  float c = hash(i + vec2(0.0, 1.0));",
          "  float d = hash(i + vec2(1.0, 1.0));",
          "  return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);",
          "}",
          "",
          "void main() {",
          "  vec2 uv = vUv;",
          "  vec2 aspect = vec2(" + (W / H).toFixed(4) + ", 1.0);",
          "  vec2 centered = (uv - portalCenter) * aspect;",
          "  float dist = length(centered);",
          "",
          "  // Noise-modulated edge for organic tear look",
          "  float angle = atan(centered.y, centered.x);",
          "  float edgeNoise = noise(vec2(angle * 3.0, time * 2.0)) * 0.03 * energyIntensity;",
          "  float portalEdge = portalRadius + edgeNoise;",
          "",
          "  // Distortion near portal edge: warp UVs radially",
          "  float edgeDist = abs(dist - portalEdge);",
          "  float distortMask = smoothstep(edgeWidth * 1.5, 0.0, edgeDist) * distortionStrength;",
          "  vec2 distortDir = normalize(centered + 0.0001);",
          "  vec2 distortedUv = uv + distortDir * distortMask * sin(angle * 8.0 + time * 5.0);",
          "",
          "  // Chromatic aberration near portal edge",
          "  float chromaMask = smoothstep(edgeWidth * 2.0, 0.0, edgeDist) * chromaticStrength;",
          "  vec2 chromaOffset = distortDir * chromaMask;",
          "  float r = texture2D(tLight, distortedUv + chromaOffset).r;",
          "  float g = texture2D(tLight, distortedUv).g;",
          "  float b = texture2D(tLight, distortedUv - chromaOffset).b;",
          "  vec3 lightColor = vec3(r, g, b);",
          "",
          "  // Portal mask: inside = transparent (show dark behind), outside = light",
          "  float mask = smoothstep(portalEdge - 0.008, portalEdge + 0.008, dist);",
          "",
          "  // Glow ring at portal edge",
          "  float glowInner = smoothstep(edgeWidth, 0.0, edgeDist);",
          "  float glowOuter = smoothstep(edgeWidth * 2.5, edgeWidth * 0.5, edgeDist);",
          "  float glow = glowInner * glowOuter * energyIntensity;",
          "",
          "  // Animated color cycling along the ring",
          "  float colorPhase = fract(angle / 6.2832 + time * 0.4);",
          "  vec3 ringColor = mix(glowColor, glowColor2, sin(colorPhase * 6.2832) * 0.5 + 0.5);",
          "",
          "  // Secondary energy sparks",
          "  float sparks = noise(vec2(angle * 12.0, time * 8.0));",
          "  sparks = pow(sparks, 4.0) * glowInner * energyIntensity * 2.0;",
          "",
          "  vec3 finalColor = lightColor * mask;",
          "  finalColor += ringColor * glow * 2.5;",
          "  finalColor += vec3(1.0, 0.9, 1.0) * sparks;",
          "",
          "  // When portal is large enough, alpha fades out completely",
          "  float alpha = mask + glow * 0.5 + sparks * 0.3;",
          "  alpha = clamp(alpha, 0.0, 1.0);",
          "",
          "  gl_FragColor = vec4(finalColor, alpha);",
          "}",
        ].join("\n");

        var frontGeo = new THREE.PlaneGeometry(quadW, quadH);
        var frontMat = new THREE.ShaderMaterial({
          uniforms: portalUniforms,
          vertexShader: portalVertShader,
          fragmentShader: portalFragShader,
          transparent: true,
          side: THREE.FrontSide,
          depthWrite: false,
        });
        var frontQuad = new THREE.Mesh(frontGeo, frontMat);
        frontQuad.position.z = 0.0;
        scene.add(frontQuad);

        // ── Animation State ────────────────────────────────────────────────
        var animState = {
          portalRadius: 0.0,
          cameraZ: 2.8,
          energyIntensity: 0.0,
          chromaticStrength: 0.0,
          distortionStrength: 0.0,
          edgeWidth: 0.06,
          time: 0.0,
        };

        // ── GSAP Timeline ──────────────────────────────────────────────────
        window.__timelines = window.__timelines || {};
        var tl = gsap.timeline({ paused: true });

        // Phase 1: Hold on light mode (0 - 1.5s)
        // Subtle energy buildup at center before portal opens
        tl.to(
          animState,
          {
            energyIntensity: 0.3,
            duration: 1.2,
            ease: "power2.in",
          },
          0.3,
        );

        // Phase 2: Portal tears open (1.5 - 5.0s)
        tl.to(
          animState,
          {
            portalRadius: 0.85,
            duration: 3.5,
            ease: "power2.inOut",
          },
          1.5,
        );

        tl.to(
          animState,
          {
            energyIntensity: 1.0,
            duration: 0.6,
            ease: "power3.out",
          },
          1.5,
        );

        tl.to(
          animState,
          {
            chromaticStrength: 0.035,
            duration: 2.0,
            ease: "power2.in",
          },
          1.5,
        );

        tl.to(
          animState,
          {
            distortionStrength: 0.06,
            duration: 2.5,
            ease: "power2.inOut",
          },
          1.5,
        );

        tl.to(
          animState,
          {
            edgeWidth: 0.08,
            duration: 2.0,
            ease: "sine.inOut",
          },
          2.0,
        );

        // Phase 3: Camera pushes through portal (3.5 - 6.5s)
        tl.to(
          animState,
          {
            cameraZ: 1.4,
            duration: 3.0,
            ease: "power2.inOut",
          },
          3.5,
        );

        // Phase 4: Portal overshoots to cover full screen (5.0 - 7.0s)
        tl.to(
          animState,
          {
            portalRadius: 1.8,
            duration: 2.0,
            ease: "power3.in",
          },
          5.0,
        );

        // Energy peaks then fades as we emerge
        tl.to(
          animState,
          {
            energyIntensity: 1.5,
            duration: 0.8,
            ease: "power2.in",
          },
          5.0,
        );

        tl.to(
          animState,
          {
            energyIntensity: 0.0,
            duration: 1.2,
            ease: "power2.out",
          },
          6.2,
        );

        // Chromatic aberration peaks during push-through
        tl.to(
          animState,
          {
            chromaticStrength: 0.06,
            duration: 1.0,
            ease: "power3.in",
          },
          5.5,
        );

        tl.to(
          animState,
          {
            chromaticStrength: 0.0,
            duration: 1.0,
            ease: "power2.out",
          },
          6.8,
        );

        // Distortion fades
        tl.to(
          animState,
          {
            distortionStrength: 0.0,
            duration: 1.0,
            ease: "power2.out",
          },
          6.5,
        );

        // Phase 5: Settle in dark mode (7.0 - 10s)
        // Camera eases to final position
        tl.to(
          animState,
          {
            cameraZ: 2.8,
            duration: 2.5,
            ease: "power2.out",
          },
          7.0,
        );

        // ── Render Loop via Timeline ───────────────────────────────────────
        tl.to(
          { v: 0 },
          {
            v: 1,
            duration: 10,
            ease: "none",
            onUpdate: function () {
              var t = tl.time();
              animState.time = t;

              // Update portal shader uniforms
              portalUniforms.portalRadius.value = animState.portalRadius;
              portalUniforms.edgeWidth.value = animState.edgeWidth;
              portalUniforms.chromaticStrength.value = animState.chromaticStrength;
              portalUniforms.distortionStrength.value = animState.distortionStrength;
              portalUniforms.time.value = t;
              portalUniforms.energyIntensity.value = animState.energyIntensity;

              // Update camera Z for push-through effect
              camera.position.z = animState.cameraZ;

              // Recalculate quad scale to always fill the view at current camera Z
              var currentVFov = (camera.fov * Math.PI) / 180;
              var currentH = 2 * Math.tan(currentVFov / 2) * camera.position.z;
              var currentW = currentH * (W / H);
              frontQuad.scale.set(currentW / quadW, currentH / quadH, 1);
              darkQuad.scale.set(currentW / quadW, currentH / quadH, 1);

              renderer.render(scene, camera);
            },
          },
          0,
        );

        window.__timelines["portal-transition"] = tl;

        // ── Deferred Init ──────────────────────────────────────────────────
        setTimeout(function () {
          if (isSupported()) {
            capturePanels();
          }
          tl.seek(0);
        }, 0);
      </script>
    </body>
  </html>
  ```
</Accordion>

## Usage

After installing, add the block to your host composition:

```html theme={null}
<div data-composition-id="vfx-portal" data-composition-src="compositions/vfx-portal.html" data-start="0" data-duration="10" data-track-index="1" data-width="1920" data-height="1080"></div>
```

Tagged `html-in-canvas` `webgl`.

## Related topics

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