Skip to content

Fullscreen Creatives in Safe Frame Environments

Status (April 2026): The $sf.ext.geom() viewport correction described in the "Potential Fixes" section was implemented and later reverted (ad407f44) because it broke responsive scaling for non-safe-frame environments (Amedia/Adnami). Fullscreen banners currently use CSS percentage-based dimensions without JavaScript correction. See safe-frame-pr.md for the full attempt history.

Problem Summary

Fullscreen creatives (type: banner, format: fullscreen) don't scale responsively when served through a publisher's safe frame chain (e.g., Schibsted/Xandr + CM360). The creative renders at fixed dimensions (e.g., 1920x1080) regardless of the actual device screen size.

The Iframe Chain

When a fullscreen creative is delivered through a DSP (like CM360) to a publisher using Xandr safe frames, the following iframe nesting occurs:

Publisher page (e.g., E24 — actual viewport: 375px mobile / 1440px desktop)
  └─ Xandr safe frame <iframe width="1920px" height="1080px">
       └─ CM360 <ins class="dcmads" style="width: 1920px; height: 1080px">
            └─ CM360 <iframe style="width: 100%; height: 100%">  (= 1920x1080)
                 └─ Cavai <iframe width="100%" height="100vh">   (= 1920x1080)
                      └─ #creative-XXXXX { width: 100vw; height: 100vh }  (= 1920x1080)

Key insight: CSS viewport units (100vw, 100vh) inside nested iframes resolve to the iframe's own viewport, not the device screen. The iframe viewport is determined by the safe frame dimensions (1920x1080), which are set by the creative's registered size in CM360.

Why It Works in Preview / Test Pages

In Cavai's preview system or on a standalone test page, the creative tag is embedded directly on the page (or with only one CM360 iframe layer). There's no fixed-dimension safe frame wrapping the content. Therefore 100vw/100vh = actual device viewport, and the creative scales correctly.

Why MRAID Doesn't Have This Problem

When MRAID is detected (mobile in-app environments), the stub calls createWithoutIframe() instead of createIframe(). The creative content is injected directly into the DOM without any iframe wrapper. CSS viewport units then reference the actual device viewport.

Relevant code: Creative-Engine/templates/tagstub/Banner.ts:14-18

typescript
create() {
  const { embedTag, mraidDetected, vpaidDetected, isTeadsPreviewInSafari } = this.stubConf
  if (mraidDetected || vpaidDetected || isTeadsPreviewInSafari) {
    return this.createWithoutIframe()  // No iframe → viewport units work correctly
  }
  // ...
}

Root Causes in the Product Code

1. Hardcoded viewport units in srcdoc

File: Creative-Engine/templates/tagstub/Banner.ts:47-51

css
#creative-${creativeId} {
  position: relative;
  width: 100vw;
  height: 100vh;
}

These viewport units resolve to the iframe viewport (safe frame dimensions), not the device screen.

2. SafeFrame integration disabled for banners

File: Creative-Engine/src/style-engine/dimensionmanager/dimensionManager.ts:442-444

typescript
isSafeFrameEnabled(): boolean {
  return !this.isBanner && DataStore.enableSF.value  // Always false for banner type!
}

Even when a safe frame is detected (sfDetected = true), the SafeFrame API ($sf.ext.geom(), $sf.ext.expand()) is never used for banner creatives. This means fullscreen banners cannot query the actual viewport geometry or request expansion through the safe frame.

3. 100vh override for CM3/DV3 DSPs

File: Creative-Engine/templates/tagstub/Banner.ts:80-98

typescript
if (isDV360 || ['CM3', 'DV3', 'GAM'].includes(this.stubConf.dsp)) {
  iframe.style.position = 'absolute'
  iframe.style.height = '100vh'  // Resolves to safe frame viewport height
}

4. $sf API may not be available in nested iframes

The $sf variable (SafeFrame client API) is typically only available in the direct child of the safe frame. When Cavai's stub runs inside CM360's iframe (which is itself inside the safe frame), $sf is likely undefined. This means sfDetected is false, and safe frame functionality can't be used even if we enabled it for banners.

Testing with a Custom Script (HTML Block)

A custom script can be injected via an HTML block in the creative builder to test scaling fixes without requiring a product deployment. The script runs inside the Cavai iframe and has access to window, document, screen, and the full creative DOM.

How HTML Block Scripts Execute

File: Creative-Engine/src/components/creative/VisualElements/CreativeHtmlBlock.vue:134-151

Scripts in HTML blocks are:

  1. Extracted from the HTML content using DOMParser
  2. Appended to document.head (inside the Cavai iframe)
  3. Executed immediately
  4. Then removed from the DOM

They have full access to the iframe's JavaScript context: window, document, screen, navigator, etc.

Diagnostic + Scaling Script

Paste this into an HTML block in the creative:

html
<script>
(function() {
  // =============================================
  // Cavai Fullscreen Safe Frame Scaling Fix
  // =============================================
  // Detects when the iframe viewport (from safe frame chain)
  // is larger than the actual device screen, and resizes
  // the creative root element to match the device screen.
  //
  // Uses screen.width/height which returns device screen
  // dimensions in CSS pixels regardless of iframe nesting.
  // =============================================

  function fix() {
    var iW = window.innerWidth;
    var iH = window.innerHeight;
    var sW = screen.width;
    var sH = screen.height;

    console.log('[Cavai Responsive] Iframe viewport: ' + iW + 'x' + iH);
    console.log('[Cavai Responsive] Device screen: ' + sW + 'x' + sH);
    console.log('[Cavai Responsive] Mismatch ratio: ' + (iW / sW).toFixed(2) + 'x');

    // Only apply if iframe is significantly larger than screen
    if (iW <= sW * 1.2) {
      console.log('[Cavai Responsive] No fix needed — dimensions match');
      return;
    }

    console.log('[Cavai Responsive] Safe frame mismatch detected!');

    // Find creative root element (the div with id="creative-XXXXX")
    var root = document.querySelector('.creative-outer-container');
    if (!root) {
      console.warn('[Cavai Responsive] Could not find .creative-outer-container');
      return;
    }

    // APPROACH 1: Resize to device screen dimensions
    // This changes the creative's layout to match the device,
    // similar to how it would render without the safe frame.
    // Content inside (video, text) adapts because they use
    // percentage-based sizing relative to the container.
    root.style.width = sW + 'px';
    root.style.height = sH + 'px';
    root.style.overflow = 'hidden';

    console.log('[Cavai Responsive] Resized from ' + iW + 'x' + iH + ' to ' + sW + 'x' + sH);

    // APPROACH 2 (alternative): CSS transform scale
    // Uncomment below and comment out Approach 1 to test.
    // This scales the creative uniformly by width, preserving
    // the original 16:9 aspect ratio (creative becomes smaller).
    //
    // var scale = sW / iW;
    // root.style.transform = 'scale(' + scale + ')';
    // root.style.transformOrigin = 'top left';
    // console.log('[Cavai Responsive] Scaled by ' + scale.toFixed(4));
  }

  // Delay to ensure creative is mounted
  setTimeout(fix, 500);

  // Re-apply on orientation change (mobile)
  window.addEventListener('orientationchange', function() {
    setTimeout(fix, 300);
  });
})();
</script>

What the Script Does

Approach 1 (default — Resize):

  • Changes the creative root element from 100vw x 100vh (= 1920x1080 from safe frame) to screen.width x screen.height (= actual device dimensions, e.g., 375x812 on mobile)
  • Content inside (video, text overlays) adapts because they use percentage-based sizing
  • The creative re-flows as if it were directly embedded on a mobile page
  • Trade-off: Aspect ratio changes (from 16:9 to ~9:19 on mobile), content needs to handle this

Approach 2 (commented out — Scale):

  • Applies CSS transform: scale() to shrink the entire creative uniformly by width ratio
  • Preserves the original 16:9 aspect ratio
  • The creative appears smaller but proportionally correct
  • Trade-off: Creative appears as a horizontal band, doesn't fill the mobile viewport height

Important Caveats

  1. screen.width vs actual viewport: screen.width returns the device screen width in CSS pixels. On mobile this is accurate (browsers are fullscreen). On desktop, it returns the monitor resolution, not the browser window size. This means on a non-maximized desktop browser, the fix might over- or under-scale slightly.

  2. screen.height includes browser chrome: On mobile, screen.height includes the address bar area. The actual viewport height (what 100vh would be without safe frame) is slightly smaller. This is usually acceptable.

  3. Aspect ratio: If the creative was designed for 16:9 (1920x1080), resizing to a portrait device (9:19) changes the layout significantly. The video block should handle this via object-fit, but text overlays might need adjustment.

  4. The iframe itself is still large: Even after resizing the creative content, the Cavai iframe element remains at its original size (1920x1080) in the parent iframe chain. The publisher page's ad container determines whether the oversized iframe is clipped, scrolled, or displayed as-is.

Potential Product-Level Fixes

Replace hardcoded viewport units in the srcdoc with JavaScript-based sizing:

typescript
// In Banner.ts srcdoc, replace the static CSS with:
<script>
  (function() {
    var el = document.getElementById('creative-${creativeId}');
    var w = Math.min(window.innerWidth, screen.width);
    var h = Math.min(window.innerHeight, screen.height);
    el.style.width = w + 'px';
    el.style.height = h + 'px';
  })();
</script>

This runs before the creative engine loads, setting the correct dimensions from the start.

Fix 2: Enable SafeFrame for fullscreen banners

Modify isSafeFrameEnabled() in dimensionManager.ts:

typescript
isSafeFrameEnabled(): boolean {
  const isFullscreenBanner = this.isBanner
    && typeof DataStore.creativeFormat.value === 'string'
    && DataStore.creativeFormat.value === 'fullscreen'
  return (!this.isBanner || isFullscreenBanner) && DataStore.enableSF.value
}

Caveat: The $sf API might not be available in the Cavai execution context (nested inside CM360's iframe), so this may not work in all ad chains.

Fix 3: CSS transform: scale() in Banner.ts (similar to Teads Safari hack)

Apply scaling when a mismatch is detected, similar to the existing teadsSafariHackStyles in Creative.vue:253-274:

typescript
// In Creative.vue, add a computed style for safe frame scaling
safeFrameScaleStyles() {
  if (this.isBanner && typeof DataStore.creativeFormat.value === 'string') {
    const iframeW = window.innerWidth;
    const screenW = screen.width;
    if (iframeW > screenW * 1.2) {
      const scale = screenW / iframeW;
      return {
        transform: `scale(${scale})`,
        transformOrigin: 'top left',
      }
    }
  }
  return {}
}

Key File References

FilePurpose
Creative-Engine/templates/tagstub/Banner.tsCreates the Cavai iframe with srcdoc containing 100vw/100vh CSS
Creative-Engine/templates/tagstub/stub.tsDetects SafeFrame, MRAID, VPAID; sets bannerWidth/bannerHeight
Creative-Engine/src/style-engine/safeframe/safeframe.tsSafeFrame API integration ($sf.ext.expand/collapse/geom)
Creative-Engine/src/style-engine/dimensionmanager/dimensionManager.tsisSafeFrameEnabled() — currently excludes banners
Creative-Engine/src/components/creative/Creative.vueContainer sizing, teadsSafariHackStyles precedent
Creative-Engine/src/components/creative/VisualElements/CreativeHtmlBlock.vueHTML block script execution

API Reference: screen vs window Inside Iframes

PropertyReturnsCross-origin safe?Notes
window.innerWidthIframe viewport widthYes= safe frame width in nested chain
window.innerHeightIframe viewport heightYes= safe frame height in nested chain
screen.widthDevice screen width (CSS px)YesReliable on mobile; = monitor resolution on desktop
screen.heightDevice screen height (CSS px)YesIncludes browser chrome on mobile
window.outerWidthBrowser window outer widthVariesMay be restricted in cross-origin iframes
window.top.innerWidthTop page viewportNoBlocked by cross-origin policy in safe frames
$sf.ext.geom()Safe frame geometryN/AOnly available in direct safe frame child

Internal documentation