Agustin Barrientos
All posts

Resizing a Cross-Origin Iframe to Fit Its Content

Want more of these in your Google results?

An iframe doesn’t grow with its content. In fact, unless something sets its size, the page inside it scrolls in a box of 300 by 150 pixels.

A good example is the reviews on a store’s product page. Plenty of stores get those reviews from a reviews service (like Trustpilot), which asks people for a review after they buy something and gives the store a widget to show them. Trustpilot’s script adds an iframe to the store’s page and loads the widget into it from its own site.

The height of the reviews changes as customers open long ones and photos finish loading. And because the frame comes from another site, no script on your page can read that height.

A fixed height leaves the reviews scrolling inside a small box.

In this article I’ll compare three ways to size a frame like that, and what each one does to the page around it.

There are different approaches:

  • a fixed height;
  • a height the widget sends to your page;
  • frame-sizing, a CSS property that lets Chromium-based browsers size the frame themselves.

I’ll use a product page with a reviews widget as the example, and point out where Chrome, Firefox, and Safari behave differently.

Why a frame from another site is different

For a frame from your own site, fitting it to its content is easy.

You reach the frame’s document through the iframe’s contentDocument, remove the body’s margin, and copy the body’s height to the frame. You round that height up with Math.ceil() before setting it, since a frame that’s a fraction of a pixel shorter than its content can scroll on a 2x screen in Chrome. Then, to catch later changes, you set up a ResizeObserver to measure the body again whenever it changes size.

fit-frame.js
// fit-frame.js
const $body = $frame.contentDocument.body;
$body.style.margin = '0';

const fit = () => {
	$frame.style.height = `${
		Math.ceil( $body.getBoundingClientRect().height )
	}px`;
};

new ResizeObserver( fit ).observe( $body );

All of that depends on the frame and your page sharing an origin (the scheme, host, and port of a URL).

A reviews widget comes from the service’s origin, like https://reviews.example. Browsers keep documents from different origins apart, which means your product page gets null from the iframe’s contentDocument and a security error from contentWindow.document.

Inside the frame, the widget’s own script can measure the reviews just fine. It’s just your page that can’t.

The fixed-height embed

The simplest embed gives the iframe a height in CSS and never changes it.

product.html
<!-- product.html -->
<iframe
	class="reviews"
	src="https://reviews.example/embed/trail-backpack"
	title="Customer reviews"
	loading="lazy"
></iframe>
product.css
/* product.css */
.reviews {
  width: 100%;
  height: 480px;
  border: 0;
}

By the way, title is there for screen readers, which announce the frame by it (WCAG technique H64).

Anyway, with loading="lazy", the browser doesn’t fetch the widget until the reader scrolls close to it. And border: 0 removes the 2px border browsers draw around every iframe by default.

Since the frame’s height never changes, nothing under it ever moves, whatever happens inside the widget.

In exchange, once the reviews are taller than 480px, they scroll inside the frame. If you scroll down the product page with a mouse, the wheel switches from the page to the reviews iframe as soon as the frame passes under the pointer.

In Chrome, it’s worse with a keyboard.

Tab jumps right over a frame from another site, or a sandboxed one, if nothing inside it can take focus, even with tabindex="0" on the iframe. The arrow keys move the page instead, which means the reviews below the edge of the box are out of reach.

Only the reviews service can fix that, inside the widget. In my demos, the reviews go in <main tabindex="0" aria-label="Reviews">, so keyboard users can focus and scroll.

Things with a fixed shape (ratio), like a video, a map, or a chart, are fine with a fixed height. YouTube and Google Maps put a width and a height right in their embed code.

I’d also keep the height fixed on pages where nothing may move, like a checkout or the first screen of a landing page.

The widget sends its height

The second option has the widget measure the reviews and send your page the height. Messages like that go through postMessage(), a method that carries data between windows, even across origins. Your page listens for the message event and writes the height to the iframe.

Trustpilot’s widget works like that. Its embed script listens for a resize-height message from the widget and writes the height it carries to the iframe’s style.height.

Plenty of sites size frames this way. The iframe-resizer library alone had almost a million downloads on npm in a single week.

What the widget measures

The widget runs a ResizeObserver on its root element, document.documentElement. On each run, the widget posts the root’s height to your page’s window, window.parent.

postMessage() takes the origin you’re sending to as its second argument. No window with another origin gets the message, unless you pass '*' to send it anywhere. Since every customer sees the same reviews, the widget can send its height to any store’s page with '*'. Anything private, like a token, should only go to the store’s exact origin (HTML Standard).

Chrome can run the observer for the first time before the frame has any width. So the widget ignores any run where the root’s clientWidth is 0. Once the frame gets its width, the observer runs again.

reviews-widget.js
// reviews-widget.js
const $root = document.documentElement;

const report = () => {
	if ( $root.clientWidth === 0 ) {
		return;
	}
	const height = Math.ceil( $root.getBoundingClientRect().height );
	window.parent.postMessage( { type: 'reviews:height', height }, '*' );
};

new ResizeObserver( report ).observe( $root );

I measure the root’s box rather than its scrollHeight. A frame sized from scrollHeight grows when a customer opens more reviews, but never shrinks back after they close them, because scrollHeight is never smaller than the frame’s height.

Two widgets from the same service

Other frames on a product page post messages as well, like ads or a chat window. The page can also have two widgets from the same reviews service, with the star rating under the product name and the full list of reviews further down.

Before your page writes a height, check which frame sent it. MDN recommends checking event.origin, the origin of the sender, in its page on postMessage(). It lists event.source as an extra check you might add.

The star rating and the reviews list share one origin, https://reviews.example. No origin check can separate them.

Compare event.source instead.

That property gives you the window that posted the message. Anything other than your frame’s contentWindow means the message came from somewhere else.

I also check that the height is a finite number and clamp it to a range before it goes on the frame, in case the widget sends something broken.

product.js
// product.js
const $frame = document.querySelector( '.reviews' );

window.addEventListener( 'message', ( event ) => {
	if ( event.source !== $frame.contentWindow ) {
		return;
	}
	const { type, height } = event.data ?? {};
	if ( type !== 'reviews:height' || ! Number.isFinite( height ) ) {
		return;
	}
	$frame.style.height = `${Math.min( 4000, Math.max( 120, Math.ceil( height ) ) )}px`;
} );

In the demo below, the star rating and the reviews list each have their own frame and their own listener on the page. Both widgets post to the same page, so each listener hears both of them. With “Check event.source” off, each listener writes whatever height arrives last to its own frame.

Watch the star rating as the page loads, then open “Rating details” under it. Do the same with “Check event.source” on.

With the switch off, the star rating’s frame jumped to the height of the reviews list as soon as the list loaded, leaving blank space under the stars. That’s because both listeners wrote the height the list posted last.

If you opened “Rating details” after that, both frames shrank to the star rating’s new height, too short for the reviews list.

With the switch on, each frame kept its own height.

Sandboxed widgets

If you don’t trust a widget much, you can sandbox it with sandbox="allow-scripts". Its scripts run, but the browser restricts what else it can do.

I do that in my demos.

Without allow-same-origin, a sandboxed frame gets an opaque origin, a unique origin that browsers write as "null". An event.origin === 'null' check lets every sandboxed frame on the page through, an attacker’s included. The event.source check still works there.

One message channel per widget

You can check event.source on every message like that. I’d rather check it once, and then get the heights through a MessageChannel that no other frame can post to.

new MessageChannel() creates two ports wired to each other. Messages posted on one port come out of the other and nowhere else.

To get one of those ports into the widget, your page and the widget trade a couple of messages. First, the widget posts a hello to window.parent. Your page looks up which iframe that hello came from by its event.source, creates a channel for it, and posts one port back to the widget. After that, the widget posts its heights on the port.

To post the port to a sandboxed widget, you need '*' as the target origin, since no origin matches an opaque one. For a widget with a real origin, use the origin from the hello’s event.origin instead.

My widget accepts a port from window.parent and from nowhere else. Otherwise, any other frame on the page could send it a port and take the heights away from your page.

A widget that reloads says hello again. Your page creates a new channel for every hello and closes the old port.

The listener has to be in place before the frame loads, or the hello gets lost.

I move the type and number checks from the first listener into a setHeight() function, along with the clamp.

product.js
// product.js
let port = null;
let greeted = false;

window.addEventListener( 'message', ( event ) => {
	if ( 
		event.source !== $frame.contentWindow 
		|| event.data?.type !== 'reviews:hello' 
	) {
		return;
	}

	greeted = true;
	port?.close();
	
	const channel = new MessageChannel();
	port = channel.port1;
	
	port.onmessage = ( message ) => setHeight( message.data );

	$frame.contentWindow.postMessage(
		{ type: 'reviews:port' }, 
		'*', 
		[ channel.port2 ] 
	);
} );

On the widget’s side, report() now ends with port?.postMessage( { type: 'reviews:height', height } ). Since the observer’s first run happened before the port existed, the widget calls report() once more as soon as it receives the port.

reviews-widget.js
// reviews-widget.js
let port = null;

addEventListener( 'message', ( event ) => {
	if ( event.source === window.parent && event.data?.type === 'reviews:port' ) {
		port = event.ports[ 0 ];
		report();
	}
} );

window.parent.postMessage( { type: 'reviews:hello' }, '*' );

If the widget never loads

Sometimes no hello comes at all.

And you can’t rely on the iframe’s load event to find out.

Browsers fire load for a 404 page, a 500 page, or a frame the server refused with X-Frame-Options or frame-ancestors, just like they do for a working widget. There’s no error event on iframes (MDN).

You need a timer for that.

When the timer runs out before any hello, your page gives the frame a fallback height to scroll in, like a fixed embed.

Don’t start that timer as the page loads.

Product pages usually have the reviews near the bottom. A lazy frame down there loads once the reader scrolls close, maybe long after a timer that started at page load ran out. The frame would get the fallback height while nothing’s wrong with the widget.

Start the timer as the frame comes near the viewport. I use an IntersectionObserver for that, an observer that runs a callback as an element enters the viewport. Then the timer checks the greeted flag that the hello listener sets.

product.js
// product.js
const watcher = new IntersectionObserver( ( [ entry ] ) => {
	if ( ! entry.isIntersecting ) {
		return;
	}
	watcher.disconnect();

	setTimeout( () => {
		if ( ! greeted ) {
			$frame.style.height = '480px';
		}
	}, 3000 );
}, { rootMargin: '200px' } );

watcher.observe( $frame );

Don’t hide the frame with display: none until your page gets the first height, either. Lazy frames inside display: none never get near the viewport and never load.

The demo below runs the whole handshake, with the page’s half in its code panel as a connectEmbed() function. With “Widget answers” off, the widget loads without its reporting script, leaving your page to give the frame its 480px fallback three seconds later.

embed-handshake.ts
// index.ts
import type { EmbedOptions } from './types';

export type { EmbedOptions } from './types';

/**
 * The message types the widget and the page both use.
 *
 * The widget sends `hello` once per document.
 * The page answers with `port` and one end of a `MessageChannel`.
 * After that, the widget posts every `height` on the port.
 */
export const MESSAGE_TYPES = {
	hello: 'reviews:hello',
	port: 'reviews:port',
	height: 'reviews:height',
} as const;

/**
 * How close the frame has to come to the viewport before the hello timer starts.
 *
 * A `loading="lazy"` frame below the fold isn't fetched until the reader scrolls near it.
 * A timer started at page load could apply the fallback to a widget that works.
 */
const NEAR_VIEWPORT = '200px';

/**
 * Narrows message data to an object whose fields can be read.
 *
 * Message data can be anything another frame chose to send, including `null`, a string, or an array.
 *
 * @param value - The data from a message event.
 * @returns Whether the value is a non-null object.
 */
function isRecord( value: unknown ): value is Record<string, unknown> {
	return typeof value === 'object' && value !== null;
}

/**
 * Returns the height from a message the widget posted on the port.
 *
 * Only the widget holds the other end of the port, but a broken widget can still send `NaN`, `Infinity`, or a string.
 * The page never writes any of those.
 *
 * @param data - The data from the port's message event.
 * @returns The height in CSS pixels, or null when the message isn't a height.
 */
function readHeight( data: unknown ): number | null {
	if ( ! isRecord( data ) || data.type !== MESSAGE_TYPES.height ) {
		return null;
	}
	const { height } = data;
	return typeof height === 'number' && Number.isFinite( height ) ? height : null;
}

/**
 * Sizes a cross-origin frame from the heights its widget reports.
 *
 * Call it before the frame starts loading.
 * If the widget's hello comes before the listener exists, the page never gets it and gives the frame the fallback height after the timeout.
 * The browser keeps the page's scripts out of the widget's document, and the widget's scripts out of the page's.
 * That's why the measuring happens in the widget, with the page writing the frame's height.
 *
 * @param $frame - The iframe that holds the widget.
 * @param options - Origins, height limits, and the fallback, all optional.
 * @returns A function that stops listening, closes the port, and cancels the timer.
 */
export function connectEmbed( $frame: HTMLIFrameElement, options: EmbedOptions = {} ): () => void {
	const {
		allowedOrigins = [],
		minHeight = 120,
		maxHeight = 4000,
		fallbackHeight = 480,
		helloTimeout = 3000,
		onFallback,
	} = options;

	/**
	 * The page's end of the channel to the widget's current document, or null before the first hello.
	 */
	let port: MessagePort | null = null;

	/**
	 * Whether any document in the frame has sent a hello, which cancels the fallback.
	 */
	let greeted = false;

	/**
	 * The pending fallback timer, once the frame has come near the viewport.
	 */
	let timer: number | undefined;

	/**
	 * Writes a reported height to the frame, clamped and rounded up.
	 *
	 * The height rounds up, because a frame that's a fraction of a pixel shorter than its content can scroll on a 2x screen.
	 * A report that repeats the current height doesn't touch the frame.
	 *
	 * @param height - The widget's height in CSS pixels.
	 */
	const setHeight = ( height: number ): void => {
		const next = `${Math.min( maxHeight, Math.max( minHeight, Math.ceil( height ) ) )}px`;
		if ( $frame.style.height !== next ) {
			$frame.style.height = next;
		}
	};

	/**
	 * Applies a height the widget posted on the port.
	 *
	 * Port messages carry no origin and no source.
	 * They don't need one, because only the document the page handed the port to can post on it.
	 *
	 * @param event - The message event from the port.
	 */
	const onPortMessage = ( event: MessageEvent<unknown> ): void => {
		const height = readHeight( event.data );
		if ( height !== null ) {
			setHeight( height );
		}
	};

	/**
	 * Answers a hello from this frame's widget with a new port.
	 *
	 * Any frame on the page can post a message that looks like a hello.
	 * The sender has to be this frame's own window.
	 * An origin check alone can't do that for a sandboxed widget, since the browser reports every opaque origin as the same string `"null"`.
	 * A reloaded widget leaves its old port dead without any close event.
	 * On every hello, the page replaces the port and closes the old one.
	 *
	 * @param event - A message event on the page's window.
	 */
	const onHello = ( event: MessageEvent<unknown> ): void => {
		const $window = $frame.contentWindow;
		if ( ! $window || event.source !== $window ) {
			return;
		}
		if ( ! isRecord( event.data ) || event.data.type !== MESSAGE_TYPES.hello ) {
			return;
		}
		if ( allowedOrigins.length > 0 && ! allowedOrigins.includes( event.origin ) ) {
			return;
		}
		greeted = true;
		window.clearTimeout( timer );
		port?.close();
		const channel = new MessageChannel();
		port = channel.port1;
		port.onmessage = onPortMessage;
		// The browser drops the message without throwing when a specific target origin doesn't match an opaque document.
		const targetOrigin = event.origin === 'null' ? '*' : event.origin;
		$window.postMessage( { type: MESSAGE_TYPES.port }, targetOrigin, [ channel.port2 ] );
	};

	/**
	 * Starts the hello timer once the frame comes near the viewport.
	 *
	 * The frame's `load` event fires for a 404, a 500, and a frame whose server blocks embedding.
	 * It doesn't mean the widget works.
	 */
	const watcher = new IntersectionObserver( ( entries ) => {
		if ( ! entries.some( ( entry ) => entry.isIntersecting ) ) {
			return;
		}
		watcher.disconnect();
		if ( greeted ) {
			return;
		}
		timer = window.setTimeout( () => {
			if ( greeted ) {
				return;
			}
			$frame.style.height = `${fallbackHeight}px`;
			onFallback?.();
		}, helloTimeout );
	}, { rootMargin: NEAR_VIEWPORT } );

	window.addEventListener( 'message', onHello );
	watcher.observe( $frame );

	return () => {
		window.removeEventListener( 'message', onHello );
		watcher.disconnect();
		window.clearTimeout( timer );
		port?.close();
		port = null;
	};
}

frame-sizing in Chromium-based browsers

Chromium-based browsers can size a frame from its content on their own, with no script on your page. Your page and the widget both have to agree to it first, though.

The reason the widget has to agree is privacy.

I mean, if any page could measure any frame, its height could reveal things like whether you’re signed in to the site inside it.

Your page agrees as well, to keep a widget from resizing its frame on pages that never opted in.

The two opt-ins

On the product page, I set frame-sizing: content-height on the iframe to have the browser take the frame’s height from the widget’s content.

The property gives the frame a natural height (the height it has when nothing else sets one). Any explicit height overrides it, the HTML height attribute included.

That’s why I put the property next to height: auto, to clear any other height, inside an @supports block for the browsers that have it. Outside the block, a min-height reserves some space in all browsers.

product.css
/* product.css */
.reviews {
  width: 100%;
  min-height: 240px;
  border: 0;
}

@supports ( frame-sizing: content-height ) {
  .reviews {
    frame-sizing: content-height;
    height: auto;
  }
}

On its embed page, the reviews service opts in with a <meta> in the <head>.

html
<!-- The reviews service's embed page, inside the head -->
<meta
	name="responsive-embedded-sizing"
	content="allow-origins=https://shop.example"
>

allow-origins lists the origins of the pages that can size the frame, separated by spaces. The browser doesn’t accept a list separated by commas.

The CSS Sizing 4 draft and MDN show the meta without any content. Chrome treats a meta like that as no opt-in at all.

Measuring again after a change

The browser measures the widget twice, at DOMContentLoaded and at load (CSS Sizing 4). After that, a change like more reviews or a late photo leaves the list scrolling inside a frame that’s too short.

To make the browser measure again, the widget can call window.requestResize(). My widget calls it whenever you click “Show more reviews” or “Show fewer reviews”.

Firefox and Safari don’t have requestResize() at all. Where the opt-in didn’t work, like with a bare meta or a page that isn’t on the list, the call throws a NotAllowedError. I wrap the call so it fails quietly either way:

reviews-widget.js
// reviews-widget.js
const resize = () => {
	try {
		window.requestResize?.();
	} catch {
		// NotAllowedError means the opt-in didn't match this page.
	}
};

There’s also a ResizeObserver on the root that calls resize() for changes the widget didn’t make itself.

Problems in Chrome

Chrome keeps the width it measured the first time. After a width change, like on a phone that rotates, requestResize() keeps measuring at the old width (Chromium bug), leaving the frame clipped or too tall.

The frame also never gets shorter than the size it started at, 150px tall by default. Widgets shorter than that get blank space under them (Chromium bug).

Chrome measures a cross-site frame at a width of 0 when the widget’s HTML finishes loading before your page lays the frame out. The frame keeps that wrong height for as long as the widget is loaded, even after a requestResize(). Lazy frames avoid it. So do frames your script adds to the page before it sets their src.

Those same frames have another problem. Whenever the min-height reserve sets the frame’s height instead of the reviews, Chrome lays the widget out in a 150px window at the top of the box. That happens with an allow-origins that doesn’t match, with no meta, and with a working opt-in on a widget no taller than the reserve. Without loading="lazy", an iframe with its src right in the markup gets the whole reserve, but it can run into the zero-width measurement instead.

Firefox and Safari don’t support frame-sizing and give the widget the whole reserve.

Browser supportframe-sizing45% of users
Chrome154
Edge154
FirefoxNo
SafariNo
Safari iOSNo
Chrome Android154

Browser data updated October 22, 2026

I sized the reviews with frame-sizing and the widget’s meta in the demo below. In Chrome, set “Allowed origins” to “Another site” to see the 150px window. Then switch it back to “This page”, and click “Narrow” under “Frame width”. The frame then keeps the height it had at the wide width and cuts off the end of the reviews.

Using it with the handshake

You could run the handshake on a frame-sizing frame, but I wouldn’t. When the reviews are exactly as tall as the min-height reserve, Chrome keeps the widget in that 150px window, even after the handshake writes the height again. If you run the handshake, leave frame-sizing off and let the handshake size the frame in every browser.

Layout shifts from a growing frame

Whenever a growing frame pushes the content under it down, Chrome counts that as a layout shift. Those shifts count toward your page’s Cumulative Layout Shift (CLS). It’s one of Google’s Core Web Vitals, and scores the biggest burst of shifts during a visit.

Normally, shifts within 500 ms of a click or a key press don’t count, since the reader caused them. Clicks inside a frame from another site, or inside a sandboxed frame, don’t count as clicks on your page, though.

On desktop, Chrome runs those frames in their own process and records the click there. That means the shift from a customer opening more reviews counts against your page.

Clicks in unsandboxed frames from your own site do excuse the shift, because those frames usually share your page’s process, even from a subdomain or another port.

There’s also a shift on load, as the frame grows from its default size. I’d reserve the widget’s usual first height to avoid it, and no more, since a reserve that shrinks on load is a layout shift too. Choosing that height is kind of the same choice as the placeholder size for contain-intrinsic-size in my article on long lists.

A transition on the height doesn’t fix the shifts. Every step of the animation still counts as a shift.

The last demo adds readouts to the product page that show how far the text under the reviews moves. Its “Sizing” buttons switch between “Fixed”, “postMessage”, and “frame-sizing”. You’ll see a layout shift score in Chromium-based browsers, the only ones that report layout shifts to the page.

For each sizing, click “Show more reviews” in the widget and watch the readouts. Then turn on “Slow images”.

Under “Fixed”, the text below never moved from its spot, for a score of 0.

Under “postMessage”, the text moved as soon as the widget loaded, because the frame grew from its 240px reserve to fit the reviews. It moved further down after a click on “Show more reviews”, raising the score. With “Slow images” on, the photo in Ashley’s review pushed it down a bit more without any click.

In Chrome, frame-sizing moved the text just like postMessage, on load and after a click. Firefox and Safari kept that frame at its 240px reserve and never moved the text.

So why resize the frame at all?

Because the shifts cost less than a fixed height does. The reserve takes care of the one on load. In the demo above, the ones after a click stay far below the 0.1 that web.dev calls good. With a fixed height, the reviews stay stuck in a small box that scrolls on its own.

And if your reviews service can load the widget from a subdomain of your store, without a sandbox, the shifts after a click usually stop counting against your page.

What separates a senior from a junior here

  • Most of the trouble in Chrome doesn’t show up in a test frame from your own domain. Shifts from clicks inside it don’t count against your score. Tab stops on it even if nothing inside can take focus. Test with a frame from another domain, or with a sandboxed one.
  • Fallback timers that start with the page work on a test page where the reviews are near the top. Test with the reviews where they really go.

Want more of these in your Google results?