Skip to content

SafeFrame Responsive Scaling — Research & Implementation Plan

Date: 2026-04-10 Status: Research complete, test infrastructure on branch, implementation not started Branch: explore-safeframe-responsive-scaling (Creative-Engine) Issue: CE #725Test infra PR: CE #727 — draft, test harness + unit tests Related PR: CE #723 — reverted the broken fix (merged as 6.8.3)


Table of Contents

  1. The Problem
  2. Timeline of Changes
  3. Why the Fix Broke Things
  4. Our Codebase: SafeFrame Integration Points
  5. IAB SafeFrame API Deep Dive
  6. Research: What Works and What Doesn't
  7. The Correct Solution
  8. Implementation Plan
  9. Sources

The Problem

Two conflicting requirements:

  1. Schibsted/Xandr + CM360 bug: Inside publisher SafeFrame iframes, CSS viewport units (100vw/100vh) resolve to the SafeFrame's fixed internal dimensions (e.g. 1920x1080), NOT the actual device screen. Fullscreen creatives render at wrong sizes.

  2. Amedia/Adnami midscroll/parallax: The wrapper stretches the SafeFrame to 100vw/100lvh via CSS — the creative MUST be responsive (width:100%/height:100%) to follow the wrapper. Hardcoded pixel values kill this.

The core tension: $sf.ext.geom() gives us real viewport dimensions, but setting them as pixel values on the iframe once locks it. The iframe never responds to resize, orientation change, or wrapper-driven layout changes.

Publisher & Ad Server Details (from Kai Lu, 2026-04-10)

PublisherAd ServerBuy TypeFormat
SchibstedXandrDirektekjøp (ikke via DSP)Mobil fullskjerm
AmediaGAM (Google Ad Manager)Via Adnami (midscroll/parallax)Mobil fullskjerm

Key facts from Slack:

  • Schibsted bruker Xandr som ad server, med direktekjøp (ikke programmatisk DSP)
  • Amedia bruker GAM som ad server
  • Alt har fungert normalt på Schibsted — de kjører kun mobil fullskjerm der
  • Problemet med PR #723-reverten var på Amedia/Adnami midscroll/parallax
  • Kontakt for Schibsted-previews: traffic@schibsted.no — be om previews for mobil fullskjermene på Sparebank 1-kampanjen

Direktekjøp vs DSP-kjøp — hva det betyr for SafeFrame

  • Direktekjøp (Schibsted): Tagen plasseres direkte i Xandr ad server uten å gå gjennom en DSP. Xandr sin SafeFrame-implementasjon er da den eneste iframe-wrapperen. Ingen ekstra DSP-iframe utenpå.
  • DSP-kjøp (programmatisk): Tagen sendes gjennom en DSP (f.eks. DV360, The Trade Desk) som legger sin egen iframe rundt, og ad serveren (Xandr/GAM) legger sin SafeFrame rundt det igjen. Dobbel nesting = viewport units enda mer feil.
  • Adnami er en kreativ wrapper/format-leverandør — de legger sin egen JavaScript wrapper på publishersiden som styrer iframe-størrelsen via CSS (100vw/100lvh). Denne wrapperen er det som gjør at 100% er riktig for Amedia.

Implikasjoner for løsningen:

  1. Schibsted/Xandr direktekjøp: SafeFrame er den eneste wrapperen → $sf.ext.geom() bør gi korrekte verdier
  2. Amedia/GAM med Adnami: Adnami wrapper styrer størrelsen → 100% må beholdes
  3. Fremtidige DSP-kampanjer: Kan ha dobbel iframe-nesting → viktig at løsningen ikke gjør antagelser om nesting-nivå

Timeline of Changes

Commit 1: 6f76dc1e (2026-02-23)

"Fix fullscreen banners not scaling to device screen in safe frames"

Added a script inside the srcdoc that:

  • Checks if window.innerWidth > screen.width
  • If so, sets el.style.width = screen.width + 'px' / el.style.height = screen.height + 'px'
  • Listens to resize events to re-correct

Problem: Triggered even outside SafeFrames (no sfDetected guard).

Commit 2: 5f326aea

Added sfDetected guard to the srcdoc script — only runs when stubConf.sfDetected === true.

Commit 3: 96b7de21

Moved the correction OUT of the srcdoc, INTO createIframe():

  • Uses $sf.ext.geom().win to get actual viewport dimensions
  • Sets bannerWidth/bannerHeight to pixel values BEFORE creating the iframe
  • Removed the srcdoc-internal script

This was the version that shipped and broke Amedia.

Commit 4: ad407f44 (PR #723, 2026-04-09)

Reverted everything. Removed the $sf.ext.geom() block, left a NOTE comment explaining the problem. Merged as 6.8.3.

Current state (live): No SafeFrame viewport correction at all. Live campaigns on Amedia work. The Schibsted/Xandr viewport-unit bug may reappear.


Why the Fix Broke Things

The critical mistake was setting static pixel values on the iframe's width/height attributes with no way to update them:

typescript
// BROKEN: Locks iframe to initial pixel values, never updates
bannerWidth = win.w + 'px'   // e.g. "414px"
bannerHeight = win.h + 'px'  // e.g. "896px"
iframe.setAttribute('width', bannerWidth)
iframe.setAttribute('height', bannerHeight)

This breaks because:

  1. No resize listener$sf.ext.geom() is called once at creation, never updated
  2. Orientation change — phone rotates, iframe stays at portrait dimensions
  3. Adnami midscroll/parallax — the wrapper CSS dynamically sizes the SafeFrame to viewport, but our pixel-locked iframe doesn't follow

Our Codebase: SafeFrame Integration Points

Detection: stub.ts:148

typescript
const sfDetected = typeof $sf !== 'undefined'

Set once at stub initialization, passed through stubConf.

Creates the iframe. Currently uses 100% for fullscreen banners, no SafeFrame correction.

The iframe's srcdoc uses 100vw/100vh for the creative div:

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

Iframe positioning: Banner.ts:94-122

When inside an iframe (SafeFrame or otherwise):

  • DV360/GAM/CM3: position: absolute, height: 100vh
  • Others: position: absolute

SafeFrame expand/collapse: safeframe.ts

Used for expandable creatives (bubbles), NOT for fullscreen banners. Already uses $sf.ext.register() with callbacks for expand/collapse lifecycle. Already handles geom-update implicitly (via the status callback).

DimensionManager: dimensionManager.ts:442-444

typescript
isSafeFrameEnabled(): boolean {
  return !this.isBanner && DataStore.enableSF.value
}

SafeFrame integration is explicitly disabled for banners. No conflict with adding $sf.ext.register() in Banner.ts.


IAB SafeFrame API Deep Dive

$sf.ext.geom() — VERIFIED from IAB source code

Source: IAB SafeFrame host.js — winRect() function

The win object is constructed by the winRect() function in the IAB reference implementation:

javascript
// From IAB SafeFrame host.js — actual source code
function winRect(el) {
    var wi = (el && _view(el)) || win,
        h = wi.innerHeight || 0,
        w = wi.innerWidth || 0,
        t = wi.screenY || wi.screenTop || 0,
        b = h+t,
        l = wi.screenX || wi.screenLeft || 0,
        r = w+l;
    return {t:t, l:l, b:b, r:r, w:w, h:h};
}

Confirmed properties:

PropertyValueSource
win.wwindow.innerWidthActual browser viewport width
win.hwindow.innerHeightActual browser viewport height
win.twindow.screenYScreen position top
win.lwindow.screenXScreen position left
win.bh + tBottom edge
win.rw + lRight edge

win.w and win.h ARE part of the IAB reference implementation. Our existing code using win.w/win.h is correct. You can also calculate them as win.r - win.l and win.b - win.t for safety.

geom-update — VERIFIED from IAB source code

Source: IAB SafeFrame host.js

From the host.js source code:

  • NOTIFY_GEOM_UPDATE = "geom-update" — defined as a constant
  • Triggered by two events: window scroll and window resize
  • Both go through _set_geom_update_timer() with a 750ms debounce
  • Then calls _update_geom() which sends the notification to all rendered iframes via _send_response()

$sf.ext.register() — VERIFIED

Source: SafeFrame API Reference | Microsoft Learn

javascript
$sf.ext.register(width, height, function(status, data) {
  if (status === 'geom-update') {
    // Fires on: window resize, scroll, orientation change
    // Debounced at 750ms by the host
    var g = $sf.ext.geom()
    // g.win now has updated viewport dimensions
  }
})

Status values: "geom-update", "expanded", "expanding", "collapsed", "collapsing", "ready"

SafeFrame responsiveness — IAB community confirms limitations

Source: IAB SafeFrame Issue #20

The SafeFrame spec only supports fixed pixel dimensions for initial size. Yahoo developed percentage-based sizing extensions but these were never merged into the reference implementation. The community workaround is collapse/expand.

Source: SafeFrame Dynamic Resize Discussion

The working group recognized responsive sizing as a priority but no spec update was published. The recommended workaround is the collapse/expand cycle, which our safeframe.ts already implements for expandables.


Research: What Works and What Doesn't

CSS viewport units (vw/vh/dvw/dvh) inside iframes

Does NOT work. Per CSS spec, viewport units inside an iframe resolve to the iframe's own dimensions, not the parent viewport. This applies to ALL viewport units including dvw, dvh, svw, svh.

"Media queries and viewport units are relative to the viewport, which is the window in the main document but is the intrinsic size of the element's parent in a nested browsing context like objects, iframes and SVG."

Sources: MDN Viewport Concepts, W3C CSSWG Issue #5218

window.visualViewport inside SafeFrame

Does NOT work.

"Only the top-level window has a visual viewport that's distinct from the layout viewport. For an <iframe>, VisualViewport metrics correspond to layout viewport metrics like document.documentElement.clientWidth."

Source: MDN VisualViewport

window.parent.innerWidth

Blocked. Same-Origin Policy prevents cross-origin iframe from accessing parent properties. SafeFrames are always cross-origin.

Source: MDN Same-Origin Policy

screen.width / screen.height

Unreliable. Returns physical screen resolution, not CSS viewport. Does not account for browser chrome, split-screen, or device pixel ratio differences.

$sf.ext.geom().win with geom-update callback

WORKS. This is the correct approach. The win object gives real viewport dimensions (from window.innerWidth/innerHeight on the host page), and geom-update fires on resize with 750ms debounce.

ResizeObserver inside the iframe

Works as a supplementary signal. If the host page resizes the iframe externally, a ResizeObserver on document.documentElement inside the iframe will fire.

Percentage-based sizing (width: 100%)

Works when the SafeFrame matches viewport (Adnami). Does NOT work when SafeFrame is oversized (Schibsted).


The Correct Solution

Key insight: Two different SafeFrame behaviors

EnvironmentSafeFrame sizeWhat we need
Schibsted/Xandr + CM360Fixed oversized (e.g. 1920x1080 on mobile)Real viewport from geom() + continuous updates
Adnami midscroll/parallaxMatches viewport (correctly sized)Keep 100%, stay responsive
Non-SafeFrameN/AKeep 100%, works fine

Strategy: Detect oversized SafeFrame, only correct in that case, and keep updating

typescript
createIframe(): HTMLIFrameElement {
  const { creativeId, creativeScripts } = this.stubConf
  let { bannerWidth, bannerHeight } = this.stubConf
  let isSfOversized = false

  // SafeFrame viewport correction for fullscreen banners.
  // Only applies when SF container is significantly larger than the
  // actual browser viewport (e.g. Schibsted/Xandr + CM360 where SF
  // is 1920x1080 on a 414px mobile device).
  // When SF matches viewport (e.g. Adnami midscroll), we keep 100%
  // to preserve responsiveness.
  if (this.stubConf.sfDetected && bannerWidth === '100%' && bannerHeight === '100%') {
    try {
      const geom = $sf.ext.geom()
      const vpW = geom.win.w ?? (geom.win.r - geom.win.l)
      const vpH = geom.win.h ?? (geom.win.b - geom.win.t)
      const sfW = geom.self.w ?? (geom.self.r - geom.self.l)
      const sfH = geom.self.h ?? (geom.self.b - geom.self.t)

      // 10% tolerance — only correct if SF is significantly oversized
      isSfOversized = (sfW > vpW * 1.1 || sfH > vpH * 1.1) && vpW > 0 && vpH > 0

      if (isSfOversized) {
        bannerWidth = vpW + 'px'
        bannerHeight = vpH + 'px'
      }
    } catch { /* $sf unavailable, keep 100% */ }
  }

  const iframe = document.createElement('iframe')
  // ... (create iframe as before) ...

  // Register for continuous viewport updates when SF is oversized
  if (isSfOversized) {
    try {
      $sf.ext.register(
        parseInt(bannerWidth) || 300,
        parseInt(bannerHeight) || 250,
        (status: string) => {
          if (status === 'geom-update') {
            try {
              const g = $sf.ext.geom()
              const w = g.win.w ?? (g.win.r - g.win.l)
              const h = g.win.h ?? (g.win.b - g.win.t)
              if (w > 0 && h > 0) {
                iframe.style.width = w + 'px'
                iframe.style.height = h + 'px'
              }
            } catch { /* ignore update failures */ }
          }
        }
      )
    } catch { /* $sf.ext.register unavailable */ }
  }

  return iframe
}

Why this is safe:

  1. Adnami (SF matches viewport): sfW ≈ vpWisSfOversized = false → 100% preserved → fully responsive
  2. Schibsted (SF oversized): sfW >> vpWisSfOversized = true → corrects to real viewport → geom-update keeps it updated on resize/rotation
  3. Non-SafeFrame: sfDetected = false → entire block skipped → 100% as before
  4. No conflict: $sf.ext.register() for banners won't conflict with safeframe.ts because that module is disabled for banners (isSafeFrameEnabled() returns false)
  5. Failsafe: Every $sf call is in try/catch → if anything fails, falls back to 100%

Implementation Plan

Phase 1: Implement in Banner.ts

  1. Add oversized detection comparing geom().self vs geom().win
  2. Only apply pixel correction when oversized
  3. Register for geom-update to keep dimensions current

Phase 2: Testing

  1. Test with Schibsted/Xandr SafeFrame (oversized SF) — original bug should be fixed
  2. Test with Amedia/Adnami midscroll/parallax — must remain responsive
  3. Test standalone (no SF) — no change
  4. Test orientation change on mobile — geom-update should fire
  5. Test window resize on desktop — geom-update should fire

Phase 3: Edge cases to verify

  1. What if $sf.ext.geom() returns zero/undefined values?
  2. What if $sf.ext.register() throws? (Some broken SF implementations)
  3. What if SF starts oversized but Adnami wrapper resizes it later?
  4. What about the DV360 positioning code at line 100-118?

Risk Assessment

RiskLikelihoodImpactMitigation
geom() returns garbage valuesLowMediumValidation: vpW > 0 && vpH > 0 before applying
register() not availableLowNonetry/catch fallback to 100%
10% threshold wrongMediumMediumMay need tuning — start conservative
geom-update never firesLowMediumInitial correction still works, just won't update on resize
Some SF has self matching win but is still oversizedLowMediumWould fall through to 100%, same as current behavior

Worst case: If anything fails, we fall back to current behavior (100% with no correction). That's what's live now and works for Amedia.


Sources

Primary: IAB SafeFrame Source Code (verified)

  • IAB SafeFrame GitHub repo
    • host.jswinRect() function confirms win has {t, l, b, r, w, h} from window.innerWidth/innerHeight
    • host.js_set_geom_update_timer() confirms geom-update fires on scroll AND resize with 750ms debounce
    • ext.jsNOTIFY_GEOM_UPDATE = "geom-update" constant and _fire_sandbox_callback() handler

Primary: Xandr/Microsoft Official Docs

Primary: IAB Specification

Community / Forums

CSS / Browser APIs (confirmed dead ends)

Google AMP SafeFrame

Ad Tech Industry

Internal documentation