Snippets de inspección del DOM
Seis herramientas que se pegan tal cual y responden a las preguntas más frecuentes sobre la estructura de una página: desbordes, apilamiento, foco y accesibilidad.
Las preguntas que uno se hace sobre una página tienen casi siempre forma de consulta sobre un conjunto: qué elementos desbordan, cuáles crean contexto de apilamiento, en qué orden se recorren con el tabulador, cuáles se han quedado sin nombre accesible. Respondiendo a mano se tarda mucho y se falla; respondiendo con una expresión se tarda un segundo y el resultado es exhaustivo. Estos seis snippets funcionan pegados tal cual en cualquier página.
- Localizar el elemento responsable de un desbordamiento horizontal.
- Enumerar los contextos de apilamiento de una página y entender por qué el
z-indexno funciona. - Auditar el orden de tabulación y detectar trampas de foco.
- Detectar los fallos de accesibilidad más comunes en una sola ejecución.
Encontrar el desbordamiento horizontal
El bug más frecuente del desarrollo responsive: una barra de scroll horizontal aparece en móvil y nadie sabe qué la provoca. La causa es un elemento más ancho que el viewport, y encontrarlo a ojo es desesperante porque puede estar a quince niveles de profundidad.
// Elementos que sobresalen del ancho del documento
(() => {
const limite = document.documentElement.clientWidth;
const culpables = [...document.querySelectorAll('*')]
.map(el => ({ el, r: el.getBoundingClientRect() }))
.filter(({ r }) => r.width > 0 && (r.right > limite + 1 || r.left < -1))
.map(({ el, r }) => ({
elemento: el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') +
(el.className && typeof el.className === 'string' ? '.' + el.className.trim().split(/\s+/)[0] : ''),
izquierda: Math.round(r.left),
derecha: Math.round(r.right),
ancho: Math.round(r.width),
nodo: el
}));
console.table(culpables.slice(0, 25));
console.log('viewport:', limite, 'candidatos:', culpables.length);
if (culpables.length) inspect(culpables[0].nodo);
return culpables;
})();
La clave está en el orden: los elementos se recorren en orden de documento, así que el primero de la lista suele ser el ancestro más externo que desborda, y ese es el que hay que arreglar; los demás son sus descendientes arrastrados. El inspect final salta directamente a él en el panel de Elements.
Los contextos de apilamiento
El z-index que no funciona tiene una única explicación en el noventa por ciento de los casos: el elemento está dentro de un contexto de apilamiento cuyo propio z-index es menor que el del elemento que lo tapa. Ningún valor, por grande que sea, saca a un descendiente de su contexto.
Lo difícil es saber qué elementos crean contexto, porque hay más de una docena de propiedades que lo hacen.
// Todos los contextos de apilamiento de la pagina
(() => {
const crea = el => {
const s = getComputedStyle(el);
if (el === document.documentElement) return 'raiz';
if (s.position !== 'static' && s.zIndex !== 'auto') return 'position + z-index';
if (s.position === 'fixed' || s.position === 'sticky') return 'position ' + s.position;
if (s.transform !== 'none') return 'transform';
if (s.filter !== 'none') return 'filter';
if (s.backdropFilter && s.backdropFilter !== 'none') return 'backdrop-filter';
if (s.perspective !== 'none') return 'perspective';
if (parseFloat(s.opacity) < 1) return 'opacity';
if (s.mixBlendMode !== 'normal') return 'mix-blend-mode';
if (s.isolation === 'isolate') return 'isolation';
if (s.willChange.split(',').some(p => ['transform', 'opacity', 'filter'].includes(p.trim()))) return 'will-change';
if (s.contain.includes('paint') || s.contain.includes('layout') || s.contain === 'strict' || s.contain === 'content') return 'contain';
return null;
};
const filas = [];
for (const el of document.querySelectorAll('*')) {
const motivo = crea(el);
if (motivo) {
let profundidad = 0;
for (let n = el; n.parentElement; n = n.parentElement) profundidad++;
filas.push({
elemento: el.tagName.toLowerCase() + (el.id ? '#' + el.id : ''),
motivo,
zIndex: getComputedStyle(el).zIndex,
profundidad,
nodo: el
});
}
}
console.table(filas);
return filas;
})();
Ejecutarlo sobre una aplicación real suele ser revelador: aparecen entre diez y cincuenta contextos, y varios los crean propiedades que nadie puso pensando en el apilamiento. transform, opacity menor que uno, filter y will-change son los cuatro sospechosos habituales.
Fíjate en quién no está en la lista de comprobaciones: container-type. Muchos fragmentos parecidos que encontrarás por ahí todavía lo incluyen, porque hasta 2024 declarar un contenedor de consultas aplicaba containment de layout y con él llegaba el contexto de apilamiento. El grupo de trabajo quitó ese requisito y Chrome 129, Firefox 133 y Safari 18.4 lo aplicaron, así que incluirlo hoy solo produce falsos positivos: te haría culpar al contenedor de un encierro que no existe. Es un buen recordatorio de que un fragmento de auditoría es código con fecha de caducidad, y conviene revisarlo contra la especificación de vez en cuando.
Auditar la accesibilidad estructural
El orden de tabulación
Los fallos de navegación por teclado son invisibles hasta que alguien intenta usar la página sin ratón. Este snippet numera visualmente el orden real.
// Numera los elementos enfocables en su orden de tabulacion
(() => {
const CLAVE = '__ordenTab';
if (globalThis[CLAVE]) { globalThis[CLAVE](); delete globalThis[CLAVE]; return; }
const sel = 'a[href], area[href], button, input, select, textarea, iframe, [tabindex], [contenteditable="true"]';
const visibles = [...document.querySelectorAll(sel)]
.filter(el => !el.disabled && el.tabIndex >= 0 && el.getClientRects().length > 0);
// Orden real: primero los tabindex positivos por valor, luego los cero en orden de documento
const positivos = visibles.filter(e => e.tabIndex > 0).sort((a, b) => a.tabIndex - b.tabIndex);
const ceros = visibles.filter(e => e.tabIndex === 0);
const orden = [...positivos, ...ceros];
const marcas = orden.map((el, i) => {
const r = el.getBoundingClientRect();
const d = document.createElement('div');
d.textContent = String(i + 1) + (el.tabIndex > 0 ? ' (!' + el.tabIndex + ')' : '');
d.style.cssText = `position:fixed;z-index:2147483647;left:${r.left}px;top:${r.top}px;background:${el.tabIndex > 0 ? '#f38ba8' : '#a6e3a1'};color:#11111b;font:11px/1.4 monospace;padding:0 3px;pointer-events:none;border-radius:2px`;
document.body.appendChild(d);
return d;
});
console.table(orden.map((el, i) => ({ n: i + 1, etiqueta: el.tagName.toLowerCase(), tabindex: el.tabIndex, texto: el.textContent.trim().slice(0, 30) })));
if (positivos.length) console.warn('hay tabindex positivos, que rompen el orden natural:', positivos.length);
globalThis[CLAVE] = () => marcas.forEach(m => m.remove());
})();
Los marcadores rojos indican tabindex positivos, que son casi siempre un error: rompen el orden del documento y crean secuencias imposibles de mantener. Volver a ejecutar el snippet quita las marcas.
El orden que calcula ese snippet es una aproximación buena y no exacta. El orden real de tabulación lo determina el navegador teniendo en cuenta el orden del árbol plano, que incluye las raíces de sombra y el contenido distribuido en ranuras. En páginas con muchos componentes web, la lista puede diferir del recorrido real.
Auditoría rápida de accesibilidad
Los cinco fallos que más se repiten, en una sola pasada.
// Auditoria rapida de los fallos de accesibilidad mas comunes
(async () => {
const problemas = [];
const anota = (tipo, els) => els.forEach(el => problemas.push({ tipo, html: el.outerHTML.slice(0, 90), nodo: el }));
anota('img sin alt', [...document.querySelectorAll('img:not([alt])')]);
anota('enlace sin destino', [...document.querySelectorAll('a:not([href])')].filter(a => !a.getAttribute('role')));
anota('campo sin etiqueta', [...document.querySelectorAll('input:not([type=hidden]), select, textarea')]
.filter(el => !el.labels?.length && !el.getAttribute('aria-label') && !el.getAttribute('aria-labelledby')));
anota('rol interactivo sin foco', [...document.querySelectorAll('[role=button],[role=link],[role=checkbox],[role=tab]')]
.filter(el => el.tabIndex < 0 && !el.matches('a[href], button, input, select, textarea')));
anota('aria-hidden con contenido enfocable', [...document.querySelectorAll('[aria-hidden="true"]')]
.filter(el => el.querySelector('a[href], button, input, select, textarea, [tabindex]:not([tabindex="-1"])')));
const enc = [...document.querySelectorAll('h1,h2,h3,h4,h5,h6')].map(h => +h.tagName[1]);
for (let i = 1; i < enc.length; i++) if (enc[i] - enc[i - 1] > 1) { console.warn('salto de nivel de encabezado en la posicion', i, enc.slice(Math.max(0, i - 2), i + 2)); break; }
if (document.querySelectorAll('h1').length !== 1) console.warn('numero de h1:', document.querySelectorAll('h1').length);
if (!document.querySelector('main')) console.warn('no hay landmark main');
if (!document.documentElement.lang) console.warn('el elemento html no declara lang');
console.table(problemas);
return problemas;
})();
La propiedad labels de un control de formulario devuelve la lista de elementos label asociados a él, contando tanto la asociación por for como la envolvente, lo cual ahorra escribir esa lógica a mano.
Buscar texto en todo el árbol, incluidos los shadow DOM
La búsqueda del panel no entra en las raíces de sombra. Esta sí.
// Busca texto en todo el arbol plano, atravesando shadow DOM abiertos
((texto) => {
const encontrados = [];
const recorrer = raiz => {
const it = document.createTreeWalker(raiz, NodeFilter.SHOW_ELEMENT);
let n = it.currentNode;
while (n) {
if (n.shadowRoot) recorrer(n.shadowRoot);
const propio = [...n.childNodes].filter(c => c.nodeType === 3).map(c => c.nodeValue).join('');
if (propio.toLowerCase().includes(texto.toLowerCase())) encontrados.push(n);
n = it.nextNode();
}
};
recorrer(document);
console.log(`"${texto}":`, encontrados.length, 'coincidencias');
encontrados.forEach(e => console.log(e));
return encontrados;
})('guardar');
Cambiando la cadena del final se busca otra cosa. El truco está en filtrar por los nodos de texto propios del elemento, para no obtener todos sus ancestros como coincidencia.
Vale la pena detenerse en un caso donde el primer snippet de esta lección falla, porque enseña algo sobre el problema que resuelve. A veces la barra de scroll horizontal aparece y ningún elemento sale del viewport. El snippet devuelve una lista vacía y la barra sigue ahí. Hay cuatro causas y ninguna es un elemento ancho. La primera es un margen negativo en un elemento que técnicamente cabe pero cuya caja de margen no: getBoundingClientRect devuelve la caja de borde, no la de margen, así que un elemento con margin-right grande empuja el área de scroll sin que su rectángulo lo delate. La segunda son las transformaciones: un elemento rotado o desplazado con transform ocupa un área visual distinta de su caja de layout, y el desbordamiento lo produce la primera mientras el snippet mide la segunda; en este caso concreto getBoundingClientRect sí incluye la transformación, pero la caja de layout que causa el scroll puede ser otra. La tercera es un pseudo-elemento, que no es un nodo y por tanto no aparece en ningún querySelectorAll; un ::after con posicionamiento absoluto y un desplazamiento grande es una causa clásica y completamente invisible a cualquier consulta del DOM. Y la cuarta, la más sutil, es que el desbordamiento no está en el documento sino en un contenedor de scroll interno, y la barra que ves pertenece a un div que ocupa toda la pantalla. La forma de cubrir las cuatro es cambiar de estrategia: en vez de buscar el elemento ancho, bisecciona ocultando. Aplica display: none a la mitad de los hijos del cuerpo, mira si la barra desaparece, y repite con la mitad que la conserva. Cinco o seis pasos localizan la rama culpable sin importar cuál de las cuatro causas sea, porque el criterio no es una medida sino el propio síntoma. Es la misma lección que atraviesa toda esta guía: cuando la medida no encuentra la causa, la bisección sobre el síntoma sí, y casi siempre es más rápida de lo que parece.