Pretext: medición de texto sin reflow, y cómo integrarlo en tus Web Components


4 de abril de 2026

El problema que nadie quiere admitir

Hay un bug silencioso en casi todas las UIs web que hacen algo no trivial con texto: el reflow.

Cuando necesitas saber cuánto espacio ocupa un párrafo —para virtualizar una lista, para calcular la altura de una burbuja de chat, para hacer masonry— el navegador te obliga a preguntar al DOM. Y el DOM solo sabe responder después de haber pintado. Así que JavaScript fuerza un layout reflow: el motor recalcula posiciones y dimensiones de toda la página, solo para darte un número.

getBoundingClientRect, offsetHeight, scrollHeight… cada una de estas llamadas puede ser una bomba de rendimiento en el hot path.

La solución habitual es hackear: cachear alturas, estimar con fórmulas de aproximación, usar IntersectionObserver para diferir… Parches sobre parches.

Pretext al rescate

Pretext es una librería TypeScript/JavaScript de Cheng Lou (el mismo de react-motion) que salió esta semana y ya tiene más de 2.1k estrellas. Su premisa es simple pero potente: medir texto sin tocar el DOM, usando el motor de fuentes del navegador a través de Canvas como fuente de verdad.

La arquitectura se divide en dos fases muy bien separadas:

prepare() — el trabajo pesado, una sola vez

import { prepare } from '@chenglou/pretext'

const prepared = prepare('Hola mundo 🌍', '16px Inter')

Esta función hace todo el trabajo pesado de una vez: normaliza whitespace, segmenta el texto (incluyendo emojis, texto bidi árabe/hebreo, CJK…), aplica reglas de pegado tipográfico y mide cada segmento con canvas.measureText. El resultado es un handle opaco que puedes reutilizar indefinidamente.

Rendimiento en el benchmark del repo: ~19ms para un batch de 500 textos.

layout() — el hot path, aritmética pura

import { layout } from '@chenglou/pretext'

const { height, lineCount } = layout(prepared, 320, 24)
// 320px de ancho máximo, 24px de line-height
// → sin DOM, sin reflow, pura aritmética sobre anchos cacheados

~0.09ms para ese mismo batch de 500. La diferencia de órdenes de magnitud es lo que lo hace usable en el scroll handler, en el resize observer, en cualquier sitio que antes era territorio prohibido.

API avanzada: control línea a línea

Para casos donde necesitas más control —Canvas, SVG, WebGL, o layout complejo— existe prepareWithSegments + layoutWithLines:

import { prepareWithSegments, layoutWithLines, walkLineRanges, layoutNextLine } from '@chenglou/pretext'

const prepared = prepareWithSegments('Texto largo...', '18px "Helvetica Neue"')

// Obtener todas las líneas a un ancho fijo
const { lines } = layoutWithLines(prepared, 320, 26)
lines.forEach((line, i) => ctx.fillText(line.text, 0, i * 26))

// "Shrink wrap": ancho mínimo que cabe el texto
let maxW = 0
walkLineRanges(prepared, 9999, line => { if (line.width > maxW) maxW = line.width })

// Layout con ancho variable por línea (texto que fluye alrededor de imágenes)
let cursor = { segmentIndex: 0, graphemeIndex: 0 }
let y = 0
while (true) {
  const width = y < image.bottom ? columnWidth - image.width : columnWidth
  const line = layoutNextLine(prepared, cursor, width)
  if (line === null) break
  ctx.fillText(line.text, 0, y)
  cursor = line.end
  y += 26
}

layoutNextLine es especialmente interesante: permite cambiar el ancho disponible línea a línea, algo imposible con CSS puro.

Integración con Web Components

Aquí es donde se pone interesante. Los Web Components tienen un problema estructural con el texto: el Shadow DOM los aísla del layout exterior, pero internamente siguen sufriendo los mismos reflows que cualquier otro elemento. Veamos cómo Pretext resuelve patrones concretos.

Patrón 1: Auto-height text element

El caso más simple: un componente que necesita conocer su propia altura antes de que el navegador la calcule, para evitar layout shift (CLS).

class AutoHeightText extends HTMLElement {
  static observedAttributes = ['text', 'font', 'width', 'line-height']

  #prepared = null
  #shadow = this.attachShadow({ mode: 'open' })

  connectedCallback() {
    this.#render()
  }

  attributeChangedCallback() {
    this.#render()
  }

  #render() {
    const text = this.getAttribute('text') ?? ''
    const font = this.getAttribute('font') ?? '16px system-ui'
    const lineHeight = parseFloat(this.getAttribute('line-height') ?? '24')
    const width = parseFloat(this.getAttribute('width') ?? '300')

    this.#prepared = prepare(text, font)
    const { height, lineCount } = layout(this.#prepared, width, lineHeight)

    this.style.height = `${height}px`
    this.style.display = 'block'

    this.#shadow.innerHTML = `
      <style>
        :host { display: block; overflow: hidden; }
        p { margin: 0; font: ${font}; line-height: ${lineHeight}px; }
      </style>
      <p>${text}</p>
    `
  }
}

customElements.define('auto-height-text', AutoHeightText)

Patrón 2: Virtual list Web Component

El caso estrella de Pretext. Una lista virtual sin DOM reads en el scroll:

import { prepare, layout } from '@chenglou/pretext'

class VirtualList extends HTMLElement {
  #items = []
  #font = '15px Inter'
  #lineHeight = 22
  #itemWidth = 0
  #preparedCache = new Map()
  #shadow = this.attachShadow({ mode: 'open' })
  #scrollTop = 0

  set items(data) {
    this.#items = data
    this.#preparedCache.clear()
    for (const item of data) {
      this.#preparedCache.set(item.id, prepare(item.text, this.#font))
    }
    this.#render()
  }

  #getHeight(item) {
    const prepared = this.#preparedCache.get(item.id)
    const { height } = layout(prepared, this.#itemWidth, this.#lineHeight)
    return height
  }

  #getTotalHeight() {
    return this.#items.reduce((acc, item) => acc + this.#getHeight(item), 0)
  }

  #render() {
    // layout() ~0.09ms por item: viable en scroll handler
    const visibleHTML = this.#items.map((item, i) => {
      const h = this.#getHeight(item)
      const top = this.#items.slice(0, i).reduce((a, x) => a + this.#getHeight(x), 0)
      return `<div style="position:absolute;top:${top}px;height:${h}px;width:100%;padding:8px;box-sizing:border-box">
        ${item.text}
      </div>`
    }).join('')

    this.#shadow.innerHTML = `
      <style>
        :host { display: block; overflow-y: auto; }
        #viewport { position: relative; height: ${this.#getTotalHeight()}px; }
      </style>
      <div id="viewport">${visibleHTML}</div>
    `
  }
}

customElements.define('virtual-list', VirtualList)

Clave: layout() es tan barato que llamarlo en el scroll handler es viable. Sin getBoundingClientRect, sin reflow, sin frame drops.

Patrón 3: Chat bubbles con shrink-wrap real

Burbujas de chat con el ancho mínimo que realmente necesita el texto, sin espacio vacío a la derecha:

import { prepareWithSegments, walkLineRanges, layoutWithLines } from '@chenglou/pretext'

class ChatBubble extends HTMLElement {
  #shadow = this.attachShadow({ mode: 'open' })

  connectedCallback() {
    const text = this.textContent ?? ''
    const font = '15px Inter'
    const lineHeight = 22
    const maxWidth = 280

    const prepared = prepareWithSegments(text, font)

    // Shrink-wrap: ancho mínimo real
    let minWidth = 0
    walkLineRanges(prepared, maxWidth, line => {
      if (line.width > minWidth) minWidth = line.width
    })

    const { height } = layoutWithLines(prepared, minWidth, lineHeight)

    this.#shadow.innerHTML = `
      <style>
        :host { display: inline-block; }
        .bubble {
          background: #0b93f6;
          color: white;
          border-radius: 18px;
          padding: 8px 14px;
          width: ${minWidth}px;
          height: ${height}px;
          font: ${font};
          line-height: ${lineHeight}px;
          box-sizing: border-box;
        }
      </style>
      <div class="bubble">${text}</div>
    `
  }
}

customElements.define('chat-bubble', ChatBubble)

Patrón 4: Rich text con inline-flow

Para contenido mixto —texto, badges, menciones, chips— existe el sidecar experimental @chenglou/pretext/inline-flow:

import { prepareInlineFlow, walkInlineFlowLines } from '@chenglou/pretext/inline-flow'

class RichNote extends HTMLElement {
  #shadow = this.attachShadow({ mode: 'open' })

  set content(runs) {
    // runs = [{ text, font, break?, extraWidth? }, ...]
    const prepared = prepareInlineFlow(runs)
    const containerWidth = this.offsetWidth || 400

    const lineGroups = []
    walkInlineFlowLines(prepared, containerWidth, line => {
      lineGroups.push(line)
    })

    this.#shadow.innerHTML = this.#buildHTML(lineGroups, runs)
  }
}

ResizeObserver + Pretext: el patrón definitivo

La combinación correcta para componentes robustos y reactivos al resize:

class ResponsiveText extends HTMLElement {
  #prepared = null
  #ro = new ResizeObserver(entries => {
    // Solo layout(), nunca prepare() aquí
    for (const entry of entries) {
      const width = entry.contentRect.width
      const { height } = layout(this.#prepared, width, 24)
      this.style.height = `${height}px`
    }
  })

  connectedCallback() {
    const text = this.textContent ?? ''
    // prepare() una sola vez al montar
    this.#prepared = prepare(text, '16px Inter')
    this.#ro.observe(this)
  }

  disconnectedCallback() {
    this.#ro.disconnect()
  }
}

La separación prepare / layout brilla aquí: el trabajo pesado ocurre una vez, y el resize solo ejecuta aritmética pura.

Cuándo usar Pretext (y cuándo no)

Úsalo cuando:

No lo necesitas si:

Caveats

Conclusión

Pretext resuelve un problema que parecía inherente a la plataforma web: medir texto siempre ha significado forzar un reflow. La respuesta de Cheng Lou es desviar toda la medición a Canvas y separar el trabajo pesado del hot path.

Para Web Components encaja especialmente bien: componentes autocontenidos que necesitan conocer sus propias dimensiones sin depender del exterior. El patrón connectedCallback → prepare() + ResizeObserver → layout() te da componentes performantes, reactivos y sin reflows.

Comparte este post:

Es tu post

Estas seguro? Esto no se puede deshacer.

Comentarios (0)

Sin comentarios todavia. Se el primero!

Deja un comentario