Volver a las notas

CSS Custom Highlight API: resalta texto sin spans

21 de octubre de 2026·
cssjavascripthighlightwebdev
Demo en vivoAbrir a pantalla completa

Puedes pintar cualquier trozo de texto de una página sin envolverlo en un <span> ni en un <mark>: la CSS Custom Highlight API es Baseline desde marzo de 2026, cuando Firefox 149 añadió la última pieza que faltaba, el pseudoelemento ::highlight().

Escribe algo en la demo de arriba. Cada coincidencia se pone naranja, pero fíjate en el contador del centro: el número de nodos del DOM dentro del texto no se mueve. Activa la lectura guiada o marca las erratas y sigue sin moverse. En un navegador sin la API, la demo recurre a elementos <mark> de verdad y verás cómo ese contador crece y se pone naranja.

Por qué importa

La forma clásica de resaltar resultados de búsqueda es reescribir el HTML: partir nodos de texto, envolver las coincidencias en elementos y deshacerlo todo en la siguiente pulsación. Eso supone elementos extra en el DOM con cada tecla, y se complica enseguida cuando dos resaltados tienen que solaparse.

MDN describe la API como una forma de dar estilo a rangos de texto arbitrarios "usando JavaScript para crear los rangos y CSS para darles estilo". Lleva la idea de los pseudoelementos de resaltado que el navegador ya tenía (::selection, ::spelling-error, ::grammar-error, ::target-text) a rangos que defines tú. Usos típicos: buscar en la página, correctores ortográficos y gramaticales, editores de código, editores colaborativos con un color por persona.

Cómo funciona

Cuatro pasos: crear objetos Range, agruparlos en un Highlight, registrarlo con un nombre en CSS.highlights y darle estilo a ese nombre con ::highlight().

const p = document.querySelector('p');
const text = p.firstChild; // un nodo de texto

// 1. Un Range por cada coincidencia
const ranges = [];
let i = text.textContent.indexOf('ranges');
while (i !== -1) {
  const range = new Range();
  range.setStart(text, i);
  range.setEnd(text, i + 'ranges'.length);
  ranges.push(range);
  i = text.textContent.indexOf('ranges', i + 1);
}

// 2 y 3. Un Highlight los agrupa todos; el registro le pone nombre
CSS.highlights.set('search', new Highlight(...ranges));
/* 4. Lo único que comparten JS y CSS es el nombre */
::highlight(search) {
  background-color: #FF5200;
  color: white;
}

Ese es todo el truco del buscador de la demo: en cada evento input recorre los nodos de texto con un TreeWalker, construye los rangos y vuelve a llamar a CSS.highlights.set(). CSS.highlights es un registro con forma de Map, así que delete(nombre) y clear() quitan resaltados, y un Highlight se comporta como un Set, con add(), delete() y size.

El botón de lectura guiada usa la misma idea con un solo rango que salta a la siguiente palabra cada 280 ms. El de erratas registra un tercer resaltado y le asigna un type:

const typos = new Highlight(...typoRanges);
typos.type = 'spelling-error'; // por defecto es 'highlight'; también existe 'grammar-error'
CSS.highlights.set('typo', typos);

Según MDN, type permite que las tecnologías de apoyo incluyan ese significado al exponer el resaltado, aunque el soporte "puede variar según la plataforma".

Cuando los resaltados se solapan

En la demo, la búsqueda puede coincidir con la palabra que se está leyendo. Qué color gana no lo decide el orden del CSS ni !important: lo decide Highlight.priority. Con la misma prioridad, gana el resaltado registrado más recientemente en las propiedades que chocan. La demo le da a la búsqueda priority = 1, así que el naranja queda encima. Las propiedades que no chocan se aplican todas.

Qué puedes estilizar

::highlight() solo acepta una lista corta de propiedades: color, background-color, text-decoration y sus propiedades asociadas, text-shadow, y -webkit-text-stroke-color, -webkit-text-fill-color y -webkit-text-stroke-width. background-image se ignora, así que nada de degradados. El subrayado rojo ondulado de la demo es un simple text-decoration: underline wavy.

Soporte y trampas

  • Soporte: Chrome y Edge 105, Safari 17.2 y Firefox 149 para la funcionalidad completa. Firefox tenía la parte de JavaScript (Highlight, CSS.highlights) desde la 140, pero ::highlight() no admitía text-decoration antes de la 146 ni text-shadow antes de la 149.
  • Detectarlo: 'highlights' in CSS es la comprobación básica. La demo mira eso y typeof Highlight === 'function', muestra un aviso y recurre a elementos <mark>.
  • Safari y user-select: none: browser-compat-data indica que en Safari el estilo del resaltado se ignora si se combina con user-select: none (bug 278455 de WebKit). No pongas resaltados en texto que hayas hecho no seleccionable.
  • Sin semántica por defecto: MDN deja claro que los resaltados personalizados no añaden significado semántico al documento; type solo cubre errores ortográficos y gramaticales, y su soporte varía. Si el resaltado transmite algo que una persona con lector de pantalla necesita saber, MDN sugiere HTML semántico como <mark> u otra pista accesible.
  • Los rangos apuntan a nodos de texto. Si tu framework vuelve a renderizar y sustituye esos nodos, recalcula los rangos. El plan B de la demo hace justo eso después de cada reinicio.

Qué vigilar

  • Firefox antiguo: hasta que tu público esté en la 149 o superior, prueba allí text-shadow y text-decoration, o limítate a color y background-color.
  • Accesibilidad: usa type cuando encaje y no confíes solo en el color para transmitir significado.
  • Editores y buscadores: la búsqueda en página, la corrección ortográfica y los cursores colaborativos son los sitios obvios donde esto sustituye mucho malabarismo con el DOM.

Fuentes