Responsive iframes

Responsive iframes
10 min read

Que un <iframe> se ajuste al alto de su contenido es uno de los pedidos más viejos de la plataforma: hay issue en WHATWG desde 2016 y uno en el CSS WG desde 2017. Diez años de pulgares para arriba abajo del issue.

Chrome 154, estable desde el 22 de septiembre, lo implementa.

Por qué el iframe nunca creció solo

Un <iframe> participa del layout del documento padre como cualquier otro elemento, pero a diferencia de un <div>, no se dimensiona según su contenido. Tiene un tamaño intrínseco fijo y arbitrario —300×150, el mismo default de siempre— y si el contenido no entra, el navegador le pone scrollbars.

Y no es un olvido: es a propósito. Si el iframe se ajustara automáticamente al contenido, el documento padre podría medir ese contenido. Para un iframe cross-origin eso es una fuga de información: embebés el sitio de un banco, mirás cuánto mide el iframe y deducís si el usuario está logueado, cuántos ítems tiene el carrito, si esa búsqueda dio resultados. El mismo aislamiento que hace útil al iframe es el que impide que se autodimensione.

Entonces durante años escribimos el mismo parche. Del lado del hijo:

const send = () => {
  parent.postMessage(
    { type: 'resize', height: document.documentElement.scrollHeight },
    'https://parent.example'
  );
};

new ResizeObserver(send).observe(document.documentElement);
send();

Y del lado del padre:

window.addEventListener('message', event => {
  if (event.origin !== 'https://widget.example') return;
  if (event.data?.type !== 'resize') return;
  iframe.style.height = `${event.data.height}px`;
});

Eso, más validación de origen, más el iframe-resizer de turno cuando el caso se complica. Los problemas de este patrón son conocidos:

  • Necesitás código en los dos lados. Si embebés contenido de un tercero que no incluye tu script, no hay nada que hacer.
  • Es una carrera contra el layout. El mensaje llega después del primer paint, entonces el usuario ve el iframe de 150px y después el salto. CLS puro.
  • Los loops son fáciles. El hijo mide, el padre agranda, el hijo vuelve a medir y da un poquito más grande, y así. Cualquiera que haya debuggeado un ResizeObserver en un iframe conoce ese bucle.
  • Es JavaScript para resolver un problema de layout, corriendo en el hilo principal de dos documentos.

El doble opt-in

La solución que llega a Chrome 154 tiene dos piezas, una de cada lado, y las dos son obligatorias.

El documento que embebe opta con una propiedad CSS nueva sobre el <iframe>:

iframe.widget {
  frame-sizing: content-height;
  width: 100%;
  border: 1px solid #ddd;
}

El documento embebido opta con un <meta> en el <head>:

<!doctype html>
<html>
  <head>
    <meta name="responsive-embedded-sizing" content="allow-origins=*">
    <title>Widget de comentarios</title>
  </head>
  <body>
    ...
  </body>
</html>

El content no es decorativo: allow-origins es la lista de orígenes autorizados a medirte. El * de arriba es "cualquiera"; para un widget de terceros conviene acotarlo:

<meta
  name="responsive-embedded-sizing"
  content="allow-origins=https://publisher1.example https://publisher2.example">

Y un detalle que arruina tardes enteras: el meta no puede agregarse dinámicamente después de que el documento embebido cargó.

Con las dos partes en su lugar, el <iframe> toma la altura del layout del documento embebido en vez del default de 150px, y desaparece el scroll interno.

Por qué doble: el opt-in del padre preserva la compatibilidad hacia atrás (ningún iframe existente cambia de comportamiento), y el opt-in del hijo preserva el modelo de seguridad. El contenido embebido es el único que puede decidir si su tamaño es información que se puede exponer hacia afuera. Esto complementa a Content-Security-Policy: frame-ancestors, que controla quién puede embeberte: uno acota quién te muestra, el otro quién te mide. Los <fencedframe> quedan excluidos de la feature.

Los valores de frame-sizing

La propiedad está definida en CSS Sizing 4 y acepta:

auto | content-width | content-height | content-block-size | content-inline-size

auto es el valor inicial: se ignora el tamaño interno, o sea el comportamiento de siempre. Los otros cuatro toman una dimensión del contenido y dejan que la otra se resuelva normalmente. content-block-size y content-inline-size son los equivalentes lógicos, y se resuelven según el writing mode del elemento <iframe>, no del documento embebido.

En la práctica el caso interesante es content-height: el ancho lo controla el layout del padre (width: 100% y listo) y el alto lo pone el contenido. Que es exactamente lo que hacían todos los scripts que veníamos escribiendo.

Un detalle de la spec que vale conocer: la propiedad aplica a elementos reemplazados en general, pero en HTML solo los <iframe> pueden tener un tamaño intrínseco interno. El resto queda para otros lenguajes de documento.

El sizing es "one-shot"

El tamaño intrínseco interno se calcula en dos momentos: en el primer layout después de DOMContentLoaded, y de nuevo cuando se dispara load en el Window del documento embebido. Después de eso, los cambios de contenido, estilo o layout del hijo no actualizan el tamaño del iframe.

Si tu widget agrega tres comentarios más cuando el usuario toca "ver más", el iframe no crece solo. Vuelve el scroll interno.

Para eso hay una extensión mínima del Window, invocada desde el documento embebido:

// Dentro del iframe, después de agregar contenido
async function loadMore() {
  const items = await fetchMoreItems();
  renderItems(items);
  window.requestResize();
}

requestResize() fuerza el layout pendiente y setea el tamaño intrínseco interno al área de scroll actual. Tira NotAllowedError si lo llamás desde un documento top-level, desde algo que no sea un <iframe>, o desde un documento que no puso el meta. La recomendación de Chrome es llamarlo una sola vez, después de hacer todos los cambios y antes del layout: ese modelo explícito es justamente lo que evita el bucle de resize.

Sí, es JavaScript de nuevo. Pero es una llamada del lado del hijo y nada más: no hay protocolo de mensajes, ni validación de orígenes, ni un listener del otro lado. Y para el caso más común —contenido estático que se mide una vez— no hace falta nada.

Que el default sea one-shot es una decisión de diseño explícita: evita CLS continuo y te saca de encima los loops de layout del ResizeObserver.

El ICB congelado

Hay un mecanismo acá que te puede volver loco debuggeando.

Cuando un documento embebido tiene el flag de responsive sizing activo y hace su primer layout, graba el tamaño de su initial containing block y lo congela. En los layouts siguientes usa ese ICB grabado en vez de recalcularlo.

La razón es la prevención de loops: si el ICB del hijo siguiera al tamaño del iframe, y el tamaño del iframe siguiera al contenido del hijo, tenés la receta del bucle infinito (el hijo mide un poquito más, el padre crece, el hijo vuelve a medir un poquito más...).

La consecuencia práctica: adentro del iframe, las unidades de viewport y las media queries basadas en el ICB quedan clavadas al valor del primer layout. El documento embebido "olvida" ese ICB congelado al navegar, no al cambiar de tamaño. Si tu widget hace responsive design interno agresivo con vh o con media queries de altura, testealo.

La trampa del parser

El <meta> tiene que aparecer antes de que se abra el <body>. El flag se decide durante el parseo inicial y toma el primero de estos dos eventos: si aparece el meta, queda en true; si se abre el body —explícita o implícitamente—, queda en false para siempre.

"Implícitamente" es la palabra peligrosa. Casi cualquier elemento de contenido abre un <body> implícito. Si tu template mete un <div> antes del meta, o si un framework inyecta markup arriba de todo, el flag queda en false y la feature no funciona, sin error ni warning.

La regla práctica es simple: el meta va primero, o al menos bien arriba del <head>.

Un detalle más: como el opt-in es un <meta> de HTML, los documentos SVG embebidos en un <iframe> no pueden optar. Para SVG ya tenías sizing responsive vía <object> y <embed>; esta feature no lo cambia.

Qué hacer con el CLS

El iframe arranca con la altura por defecto y se actualiza cuando carga el documento embebido. O sea: hay un salto de layout, igual que con el truco de postMessage, solo que más temprano y sin depender de que corra JavaScript.

El consejo es el mismo que damos para imágenes y para anuncios: reservá espacio. Si tenés idea de la altura típica del contenido embebido, ponela como punto de partida y dejá que el contenido ajuste desde ahí:

iframe.widget {
  frame-sizing: content-height;
  width: 100%;
  min-height: 24rem; /* la altura típica del widget */
  max-height: 80vh; /* que un tercero no te empuje media página */
}

frame-sizing alimenta el tamaño intrínseco del elemento, así que se combina con el resto de las restricciones de CSS como en cualquier elemento reemplazado: min-height, max-height y el layout que lo contiene siguen mandando. Eso te deja poner un techo razonable para que un widget de un tercero no te empuje media página hacia abajo.

Detección y fallback

Como es una propiedad CSS, el fallback se arma con una feature query y no necesita JavaScript:

.widget {
  width: 100%;
  height: 500px; /* el alto fijo de toda la vida */
}

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

Y si tu documento embebido actualiza contenido, detectás el método antes de usarlo:

if ('requestResize' in window) {
  window.requestResize();
}

Y en navegadores sin soporte la degradación es silenciosa: la propiedad se ignora, el meta se ignora, y el iframe se comporta como siempre (con scroll). Así que podés agregar las dos piezas hoy sin romper nada y sacar el script viejo recién cuando el soporte lo justifique.

Estado

Por ahora es solo Chrome, que lo documenta en Responsive iframes in Chrome 154. La spec está en CSS Sizing 4, que es un working draft, y las posiciones de Mozilla y WebKit todavía figuran sin señal.

Dicho eso, la demanda está documentada hasta el cansancio y la superficie de API es chiquita: una propiedad CSS, un meta y un método. Si la implementan los otros dos, borran un montón de código pegote.

Hay algo más, igual, que me parece más interesante que el ahorro de líneas. Hoy, para embeber contenido de un tercero sin scrollbars, necesitás que ese tercero cargue tu script, o vos el de él. Es una relación de confianza bastante fuerte para resolver un problema de layout. Con el doble opt-in el acuerdo pasa a ser declarativo: el que embebe dice que quiere el tamaño, el embebido dice que lo puede dar, y nadie ejecuta código del otro.

Si mantenés un widget embebible, el meta es una línea y no rompe nada donde no haya soporte. Agregalo ahora y dejá que los que te embeben decidan cuándo usarlo.

Comments

Share your thoughts and join the discussion

Loading comments…


Related Posts

El atributo focusgroup

El atributo focusgroup

10 min read

Cada design system reimplementa el roving tabindex y cada uno lo rompe distinto. Desde Chrome 150 es un atributo HTML: tokens, roles mínimos, qué sigue siendo tu responsabilidad, y el modo tabs de los carruseles CSS que usa el mismo modelo.

Permisos declarativos con Capability Elements

Permisos declarativos con Capability Elements

11 min read

Un prompt de permisos que aparece fuera de contexto se bloquea por reflejo, y volver atrás implica bucear en la configuración del navegador. La apuesta de Chrome son cuatro elementos HTML que el navegador controla y que, además del permiso, te entregan el dato.