wandres.dev
OPTIMIZAR CLS · El layout que no salta

Por qué el intercambio de fuente desplaza el texto

Las métricas que lleva dentro un fichero de fuente, cómo determinan la altura de una línea y la anchura de un glifo, y por qué cambiar de fuente a mitad de carga mueve bloques enteros y no solo unos píxeles.

⏱ 19 min

La causa de CLS que menos se entiende es también la más universal: todo sitio con una fuente propia la sufre, y casi nadie la mide. El navegador pinta el texto con una fuente de reserva, la fuente real llega, y todo el texto de la página se vuelve a componer con métricas distintas. El resultado no es un ajuste de un par de píxeles, sino párrafos que cambian de número de líneas y bloques que se mueven cien píxeles. Para arreglarlo hay que entender primero qué información geométrica lleva dentro un fichero de fuente.

🎯 Al terminar esta lección sabrás
  • Enumerar las métricas verticales de un fichero de fuente y qué controla cada una.
  • Calcular la altura de una caja de línea a partir de esas métricas.
  • Explicar por qué el cambio de anchura de glifo produce el desplazamiento grande.
  • Medir en tu propia página cuántos píxeles mueve exactamente el intercambio.

Lo que lleva dentro un fichero de fuente

Una fuente no describe solo formas. Describe formas en un sistema de coordenadas propio, y ese sistema es la primera cifra que hay que conocer.

Unidades por em. El tamaño del cuadrado de diseño en el que están dibujados los glifos. Vale 2048 en la mayoría de fuentes de contornos cuadráticos y 1000 en las de contornos cúbicos. Todas las demás métricas se expresan en esas unidades, y todas se convierten a píxeles dividiendo por este número y multiplicando por el font-size.

Ascenso. Cuánto sube el contorno por encima de la línea base. Es la métrica que determina cuánto espacio deja el navegador por encima del texto.

Descenso. Cuánto baja por debajo de la línea base. Suele expresarse como número negativo en el fichero y como positivo en las APIs.

Hueco de línea. Espacio adicional que el diseñador de la fuente recomienda dejar entre líneas consecutivas. Muchas fuentes lo dejan a cero y meten el aire dentro del ascenso y el descenso; otras lo usan generosamente.

Con esas cuatro cifras salen las dos que importan para el layout:

altura del area de contenido = (ascenso + descenso) / unidadesPorEm * font-size

line-height normal = (ascenso + descenso + huecoDeLinea) / unidadesPorEm

La segunda es la razón de que line-height: normal valga cosas distintas en fuentes distintas: en unas ronda 1,15 y en otras pasa de 1,5. No es una constante del navegador: sale del fichero. Por eso una hoja de estilos que no fija line-height explícitamente está delegando la altura de todas sus líneas en un dato que cambia cuando cambia la fuente.

Hay una complicación que conviene conocer para no volverse loco comparando números: las métricas verticales están por duplicado en el fichero, en dos tablas distintas, con valores que a menudo no coinciden. Los motores no eligen la misma en todas las plataformas, y de ahí vienen las diferencias de altura de línea de una misma fuente entre sistemas operativos. Es una fuente de sorpresas legítima, y la razón de que las cifras que midas en tu máquina no sean necesariamente las de tus usuarios.

Los dos efectos del intercambio

Cuando la fuente real sustituye a la de reserva, el layout cambia por dos vías independientes, y una es mucho peor que la otra.

El efecto vertical: cambia la altura de las líneas. Si el line-height es normal, la altura de cada línea pasa a ser la de la fuente nueva. Con una diferencia del 10% en un font-size de 16 píxeles, cada línea cambia unos 1,8 píxeles. En un párrafo de siete líneas son 12 píxeles; en un artículo de sesenta líneas, más de cien. Todo lo que esté por debajo se mueve.

Este efecto desaparece por completo si fijas line-height con un número, porque entonces la altura de la línea sale del font-size, no del fichero. Es la mitigación más barata que existe contra el CLS de fuentes y consiste en una declaración:

body {
  line-height: 1.5;   /* numero sin unidad: independiente de la fuente */
}

El efecto horizontal: cambia la anchura de los glifos. Y este no se arregla con ninguna declaración, porque es intrínseco al diseño de la fuente. Si la fuente real es un 6% más ancha por carácter que la de reserva, una línea que cabía justa deja de caber, la última palabra salta a la línea siguiente, y el párrafo pasa de siete líneas a ocho.

Esa línea de más es un salto de una altura de línea completa: con line-height: 1.5 sobre 16 píxeles, 24 píxeles de golpe. Y en un texto largo no ocurre una vez, sino cada vez que un párrafo cruza el umbral. Un artículo con diez párrafos donde tres de ellos ganan una línea se alarga 72 píxeles.

La comparación entre los dos efectos es la conclusión que hay que llevarse: el vertical mueve píxeles sueltos y el horizontal mueve líneas enteras. Fijar line-height elimina el primero; el segundo exige igualar la anchura media de carácter entre la fuente de reserva y la real, que es de lo que trata la lección siguiente.

Los titulares merecen atención aparte porque concentran el problema: un titular de tres líneas que pasa a cuatro con un font-size de 40 píxeles mueve 60 píxeles de una tacada, y está arriba del todo, donde la fracción de impacto vale casi uno.

Medir tus propias cifras

Nada de esto sirve como generalidad. Las cifras que importan son las de tu fuente contra tu fuente de reserva, y se sacan en el navegador con el contexto de dibujo, que expone las métricas de la fuente cargada.

async function metricas(familia, tamano = 128) {
  await document.fonts.load(`${tamano}px "${familia}"`);   // obligatorio: si no, cae a la de reserva
  const ctx = document.createElement('canvas').getContext('2d');
  ctx.font = `${tamano}px "${familia}"`;

  const alfabeto = 'AaBbCcDdEeFfGgHhIiJjKkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz';
  const m = ctx.measureText(alfabeto);
  const v = ctx.measureText('Hxg');

  return {
    familia,
    ascenso: +(v.fontBoundingBoxAscent / tamano).toFixed(4),
    descenso: +(v.fontBoundingBoxDescent / tamano).toFixed(4),
    anchoMedio: +(m.width / alfabeto.length / tamano).toFixed(4),
  };
}

console.table([
  await metricas('MiFuente'),
  await metricas('Arial'),
  await metricas('Helvetica'),
]);

Las tres cifras salen normalizadas al font-size, que es como hacen falta. El document.fonts.load de la primera línea no es opcional: el contexto de dibujo cae silenciosamente a la fuente de reserva si la pides antes de que esté disponible, y medirías la de reserva creyendo medir la tuya.

Con esa tabla delante, la cuenta que predice el daño es directa. Si anchoMedio de tu fuente es un 6% mayor que el de la de reserva, cada línea de texto va a necesitar un 6% más de espacio, y aproximadamente uno de cada dieciséis saltos de línea va a cambiar de sitio.

Y la medición del daño real, que es la cifra que hay que llevar a una discusión de prioridades, no necesita ninguna teoría:

const bloques = [...document.querySelectorAll('main, article, .tarjeta, h1, h2')];
const antes = bloques.map((b) => b.getBoundingClientRect().height);

await document.fonts.ready;                 // se resuelve cuando el intercambio ha terminado

bloques.forEach((b, i) => {
  const delta = b.getBoundingClientRect().height - antes[i];
  if (Math.abs(delta) > 0.5) {
    console.log(b.tagName, b.className.slice(0, 24), `${delta > 0 ? '+' : ''}${delta.toFixed(1)} px`);
  }
});

document.fonts.ready se resuelve cuando el navegador ha terminado de cargar las fuentes pendientes y de rehacer el layout con ellas, así que la diferencia entre las dos mediciones es exactamente lo que movió el intercambio. Un total agregado por debajo de cinco píxeles significa que tu problema de fuentes no existe. Uno de sesenta o noventa píxeles significa que es tu principal fuente de CLS y que ninguna cantidad de aspect-ratio en las imágenes lo va a tocar.

La fuente de reserva no es la que tú escribiste, y por eso tus medidas locales no se parecen a las de tus usuarios

Aquí está el error de método que arruina la mitad de los intentos de arreglar el CLS de fuentes, y que explica por qué el arreglo funciona en el portátil del desarrollador y no en campo.

Cuando escribes font-family: MiFuente, Arial, sans-serif, das por hecho que mientras MiFuente carga se ve Arial. Eso solo es cierto en los sistemas donde Arial existe. Arial viene con Windows y con macOS, y no está en la inmensa mayoría de dispositivos Android ni en las distribuciones de Linux de escritorio. En Android, esa declaración cae a sans-serif, que resuelve a la fuente de sistema del dispositivo, que en la mayoría de los casos es Roboto o una variante del fabricante.

Las métricas de Roboto y las de Arial no coinciden. Ni el ascenso, ni el descenso, ni la anchura media. De modo que el desplazamiento que produce tu intercambio es distinto en cada plataforma, y el ajuste que calcules midiendo contra Arial en tu escritorio está mal calibrado para más de la mitad de tu tráfico.

Tres consecuencias operativas.

Uno: mide la fuente de reserva efectiva, no la que declaraste. La forma fiable es preguntarle al navegador qué está usando de verdad, midiendo cada candidata de la pila por separado con la función de arriba y comparando la anchura media con la de sans-serif a secas. Si Arial y sans-serif dan la misma cifra, Arial existe. Si dan cifras distintas, estás en un sistema donde no existe, y la reserva real es la segunda.

const a = await metricas('Arial');
const s = await metricas('sans-serif');
console.log(a.anchoMedio === s.anchoMedio ? 'Arial existe aqui' : 'Arial NO existe: la reserva es otra');

Ese truco funciona porque una familia inexistente resuelve a la genérica, y entonces las dos medidas coinciden exactamente. Es la única detección de fuente instalada que no depende de APIs con permisos.

Dos: no intentes cubrir todas las plataformas con una sola pila. El enfoque que funciona es declarar una familia de reserva propia, definida por ti con @font-face y local(), con los descriptores de ajuste calculados para la fuente concreta de cada plataforma, y dejar que el navegador elija la primera que exista. Cada entrada trae su propio ajuste, así que la corrección es correcta en cada sistema en lugar de ser un promedio malo para todos.

Tres: si vas a medir el desplazamiento del intercambio, hazlo en el dispositivo de referencia y no en el tuyo. La depuración remota por cable sobre un Android real, con el bloque de medición de arriba pegado en la consola, da la cifra verdadera en dos minutos. La misma cifra en tu escritorio es otra cosa que se le parece.

Y el corolario que ahorra tiempo a mucha gente: si tu sitio usa la fuente del sistema, este problema entero no existe. No hay intercambio, no hay métricas que igualar, no hay ficheros que precargar y no hay desplazamiento. Es una opción de diseño legítima, no una rendición, y en productos de herramienta —paneles, editores, aplicaciones internas— suele ser además la que mejor se ve, porque el texto se compone con la fuente que el sistema ha optimizado para su propio renderizador.

El caso de font-size-adjust

Hay una propiedad de CSS que ataca este problema desde el lado del elemento en lugar del lado de la fuente: font-size-adjust. Fija la relación entre la altura de la equis y el tamaño de fuente, de modo que cualquier fuente que se use acabe renderizándose con la misma altura de equis aparente.

article {
  font-size-adjust: 0.52;   /* la relacion que quieres mantener */
}

Como escala el tamaño usado de la fuente, iguala también las anchuras de forma proporcional, así que reduce el reflujo además de igualar la percepción de tamaño. Es útil, está disponible en los tres motores, y tiene dos límites que hay que conocer: se aplica al elemento, así que hay que ponerla donde toque y no interfiere con la cascada de forma cómoda, y la igualación es proporcional, no exacta, porque dos fuentes con la misma altura de equis pueden tener anchuras relativas distintas.

Para el problema concreto de que el intercambio no mueva nada, el instrumento preciso son los descriptores del @font-face, que se aplican a la fuente en lugar de al elemento y permiten ajustar ancho y altura por separado. Es lo que viene ahora.

⚔️ Reto práctico

Mide las tres métricas de tu fuente y de tu reserva efectiva, y calcula la diferencia porcentual de la anchura media. Después ejecuta el bloque de medición de bloques con la red estrangulada, para que el intercambio ocurra tarde y sea observable, y anota el desplazamiento total en píxeles. Con esas dos cifras sabrás si el ajuste de la reserva es tu siguiente tarea o una pérdida de tiempo.