Appearance
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
- The Problem
- Timeline of Changes
- Why the Fix Broke Things
- Our Codebase: SafeFrame Integration Points
- IAB SafeFrame API Deep Dive
- Research: What Works and What Doesn't
- The Correct Solution
- Implementation Plan
- Sources
The Problem
Two conflicting requirements:
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.Amedia/Adnami midscroll/parallax: The wrapper stretches the SafeFrame to
100vw/100lvhvia 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)
| Publisher | Ad Server | Buy Type | Format |
|---|---|---|---|
| Schibsted | Xandr | Direktekjøp (ikke via DSP) | Mobil fullskjerm |
| Amedia | GAM (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 at100%er riktig for Amedia.
Implikasjoner for løsningen:
- Schibsted/Xandr direktekjøp: SafeFrame er den eneste wrapperen →
$sf.ext.geom()bør gi korrekte verdier - Amedia/GAM med Adnami: Adnami wrapper styrer størrelsen →
100%må beholdes - 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
resizeevents 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().winto get actual viewport dimensions - Sets
bannerWidth/bannerHeightto 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:
- No resize listener —
$sf.ext.geom()is called once at creation, never updated - Orientation change — phone rotates, iframe stays at portrait dimensions
- 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.
Banner iframe: Banner.ts:38-56 (current, post-revert)
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:
| Property | Value | Source |
|---|---|---|
win.w | window.innerWidth | Actual browser viewport width |
win.h | window.innerHeight | Actual browser viewport height |
win.t | window.screenY | Screen position top |
win.l | window.screenX | Screen position left |
win.b | h + t | Bottom edge |
win.r | w + l | Right 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 scrollandwindow 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>,VisualViewportmetrics correspond to layout viewport metrics likedocument.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
| Environment | SafeFrame size | What we need |
|---|---|---|
| Schibsted/Xandr + CM360 | Fixed oversized (e.g. 1920x1080 on mobile) | Real viewport from geom() + continuous updates |
| Adnami midscroll/parallax | Matches viewport (correctly sized) | Keep 100%, stay responsive |
| Non-SafeFrame | N/A | Keep 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:
- Adnami (SF matches viewport):
sfW ≈ vpW→isSfOversized = false→ 100% preserved → fully responsive - Schibsted (SF oversized):
sfW >> vpW→isSfOversized = true→ corrects to real viewport →geom-updatekeeps it updated on resize/rotation - Non-SafeFrame:
sfDetected = false→ entire block skipped → 100% as before - No conflict:
$sf.ext.register()for banners won't conflict withsafeframe.tsbecause that module is disabled for banners (isSafeFrameEnabled()returns false) - Failsafe: Every
$sfcall is in try/catch → if anything fails, falls back to100%
Implementation Plan
Phase 1: Implement in Banner.ts
- Add oversized detection comparing
geom().selfvsgeom().win - Only apply pixel correction when oversized
- Register for
geom-updateto keep dimensions current
Phase 2: Testing
- Test with Schibsted/Xandr SafeFrame (oversized SF) — original bug should be fixed
- Test with Amedia/Adnami midscroll/parallax — must remain responsive
- Test standalone (no SF) — no change
- Test orientation change on mobile — geom-update should fire
- Test window resize on desktop — geom-update should fire
Phase 3: Edge cases to verify
- What if
$sf.ext.geom()returns zero/undefined values? - What if
$sf.ext.register()throws? (Some broken SF implementations) - What if SF starts oversized but Adnami wrapper resizes it later?
- What about the DV360 positioning code at line 100-118?
Risk Assessment
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
geom() returns garbage values | Low | Medium | Validation: vpW > 0 && vpH > 0 before applying |
register() not available | Low | None | try/catch fallback to 100% |
| 10% threshold wrong | Medium | Medium | May need tuning — start conservative |
geom-update never fires | Low | Medium | Initial correction still works, just won't update on resize |
Some SF has self matching win but is still oversized | Low | Medium | Would 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.js—winRect()function confirmswinhas{t, l, b, r, w, h}fromwindow.innerWidth/innerHeighthost.js—_set_geom_update_timer()confirms geom-update fires on scroll AND resize with 750ms debounceext.js—NOTIFY_GEOM_UPDATE = "geom-update"constant and_fire_sandbox_callback()handler
Primary: Xandr/Microsoft Official Docs
- SafeFrame API Reference | Microsoft Learn
- Complete docs for
$sf.ext.register(),$sf.ext.geom(),$sf.ext.expand(), status callbacks - Confirms Xandr adds
geom.anx.scrollTop/scrollLeftas proprietary extensions
- Complete docs for
Primary: IAB Specification
Community / Forums
- IAB SafeFrame Issue #20: "How can I make a safeFrame responsive?" — Confirmed no native responsive support; Yahoo had extensions never merged
- SourceForge: SafeFrame responsive to dynamic resize — Working group acknowledged as priority, recommended collapse/expand workaround
- SourceForge: $sf.ext.geom properties — Confirmed
paris undocumented reference implementation detail
CSS / Browser APIs (confirmed dead ends)
- MDN Viewport Concepts — viewport units = iframe dimensions in nested contexts
- MDN VisualViewport — returns iframe dimensions, not parent
- MDN Same-Origin Policy — blocks
window.parent.innerWidth - W3C CSSWG Issue #5218 — no spec change planned for viewport units in iframes
- Can I Use: dvw/dvh — irrelevant inside iframes
Google AMP SafeFrame
- AMP SafeFrame docs — Confirms
winuses{t, b, l, r}, geometry updated max once/second
Ad Tech Industry
- Google Ad Manager SafeFrame
- Adnami Creative Height and Viewport — Adnami recommends responsive HTML that fills container
- Prebid Universal Creative #66 — SafeFrame resize issues in Prebid