Orden de pintado y caja envolvente
Por qué SVG no tiene z-index, cómo se reordena la pila sin mover nodos, qué mide getBBox y qué no, y por qué el trazo siempre se sale de la caja.
Dos preguntas cierran el catálogo de formas y las dos tienen respuestas que sorprenden. La primera es en qué orden se pintan: SVG usa el orden del documento y z-index no funciona, lo que obliga a reordenar nodos o a inventar otra cosa. La segunda es cuánto ocupa realmente una forma: getBBox mide la geometría pura e ignora el trazo, el filtro y el recorte, lo que produce cajas más pequeñas de lo que ves y desbordamientos que parecen mágicos.
- Explicar el modelo de pintado de SVG y por qué
z-indexno aplica. - Reordenar la pila de pintado sin mover elementos en el DOM.
- Distinguir
getBBoxdegetBoundingClientRecty saber qué incluye cada uno. - Compensar el desbordamiento del trazo al calcular un
viewBoxque encaje.
El orden del documento es la pila
SVG define el pintado como un recorrido en profundidad del árbol, en orden del documento: lo que aparece después se pinta encima. No hay contextos de apilamiento, no hay z-index y no hay order. La propiedad z-index de CSS no se aplica a los elementos SVG y los motores la ignoran; SVG 2 llegó a especificar z-index para SVG y esa parte no se implementó en ningún navegador.
<svg viewBox="0 0 120 80" width="240">
<circle cx="45" cy="40" r="30" fill="#89b4fa" />
<circle cx="75" cy="40" r="30" fill="#f38ba8" /> <!-- este va encima -->
</svg>
La consecuencia es incómoda cuando el orden depende de datos: en un diagrama de dispersión donde el punto bajo el cursor debe pintarse encima, no basta con cambiar una propiedad. Hay tres salidas reales.
La primera y más usada es mover el nodo al final de su padre. En SVG, insertar un nodo que ya está en el árbol lo mueve; no hace falta quitarlo antes:
function alFrente(el) {
el.parentNode.appendChild(el); // lo mueve, no lo duplica
}
punto.addEventListener('pointerenter', () => alFrente(punto));
Es barato, pero mueve el foco del teclado de sitio y, si el elemento estaba enfocado, algunos motores lo pierden. Además rompe cualquier animación CSS en curso en algunos casos, porque el elemento se desconecta y reconecta.
La segunda es duplicar en una capa superior: dejas el original donde está y pintas una copia (con use) en un grupo que va al final del documento. Es lo que hacen casi todas las bibliotecas de gráficos para el estado de hover, y evita tocar el orden real.
La tercera es ordenar los datos antes de pintar. Si el criterio de apilamiento es una propiedad del dato (los círculos grandes detrás, los pequeños delante), ordena el array por esa clave y el DOM sale ya en el orden correcto. Es la solución que no cuesta nada en tiempo de ejecución y la que casi nadie prueba primero.
Dentro de una misma forma sí hay una propiedad de orden, y es paint-order. Controla en qué secuencia se pintan relleno, trazo y marcadores de un mismo elemento:
<text x="10" y="30" font-size="24"
fill="#cdd6f4" stroke="#11111b" stroke-width="6"
paint-order="stroke fill">Contorno legible</text>
Sin paint-order, el trazo se pinta sobre el relleno y un contorno grueso se come la mitad de la letra. Con stroke fill, el trazo va debajo y solo asoma la parte exterior. Es la forma correcta de hacer texto legible sobre un fondo variable, y funciona igual sobre cualquier forma.
getBBox: la geometría desnuda
getBBox() devuelve un DOMRect con la caja envolvente del elemento en el sistema de coordenadas del usuario local, y calculada sobre la geometría pura. Es decir, ignora:
- el
stroke, aunque tenga 20 unidades de ancho; - los
marker, aunque sobresalgan; - el
filter, aunque desplace o desenfoque; - el
clip-pathy lamask; - cualquier
transformaplicado al propio elemento (sí incluye los de sus hijos, si es un grupo).
const r = document.querySelector('#barra');
r.setAttribute('stroke-width', '20');
console.log(r.getBBox()); // el ancho no ha cambiado ni un pixel
Esa definición no es un descuido: getBBox es la caja que usan objectBoundingBox en gradientes, patrones, máscaras y recortes, y todas ellas necesitan una referencia estable que no dependa del estilo. Si la caja creciera con el trazo, cambiar el grosor de un borde movería el gradiente. Es una decisión correcta con una ergonomía mala.
SVG 2 añadió un argumento de opciones a getBBox para pedir explícitamente que incluya trazo, marcadores o el recorte. La implementación no es universal, así que no construyas nada crítico sobre ella sin comprobarlo en tus objetivos.
Cuando lo que necesitas es la caja tal y como se ve en pantalla, la respuesta es getBoundingClientRect(), que sí es la misma API que en HTML: devuelve píxeles CSS relativos al viewport, con todas las transformaciones aplicadas, e incluye el trazo. Las dos son útiles y responden a preguntas distintas:
| Pregunta | Método | Unidades | Incluye trazo |
|---|---|---|---|
| Dónde está la geometría en el espacio del SVG | getBBox() |
usuario | No |
| Dónde está el píxel en la pantalla | getBoundingClientRect() |
CSS | Sí |
El trazo siempre se sale
El trazo se pinta centrado sobre la geometría. La mitad cae dentro y la mitad fuera. SVG no tiene el equivalente a outline ni a un trazo interior o exterior: la especificación solo define el centrado, y las propiedades stroke-alignment que se propusieron para SVG 2 no se implementaron.
De ahí sale un bug que todo el mundo ha sufrido: un rectángulo pegado al borde del viewBox aparece con los bordes cortados.
<!-- Mal: la mitad del trazo cae fuera del viewBox -->
<svg viewBox="0 0 100 100" width="200">
<rect x="0" y="0" width="100" height="100"
fill="none" stroke="#f38ba8" stroke-width="4" />
</svg>
<!-- Bien: se compensa medio trazo en cada lado -->
<svg viewBox="0 0 100 100" width="200">
<rect x="2" y="2" width="96" height="96"
fill="none" stroke="#f38ba8" stroke-width="4" />
</svg>
La regla general para calcular un viewBox que encaje una forma con trazo es: caja geométrica más la mitad del stroke-width en cada lado. Si hay uniones en punta hay que sumar más, porque un stroke-linejoin="miter" en un ángulo agudo puede sobresalir bastante más que medio grosor; el límite lo pone stroke-miterlimit, cuyo valor por omisión es 4, lo que significa que la punta puede llegar a extenderse hasta cuatro veces la mitad del grosor antes de que el motor la convierta en un bisel.
Hay una propiedad más que conviene conocer aquí: vector-effect="non-scaling-stroke". Con ella, el grosor del trazo se calcula después de aplicar las transformaciones, así que una línea de 1 unidad se ve de 1 píxel independientemente del zoom o del escalado del viewBox. Es exactamente lo que quieres para las líneas de rejilla de un gráfico que se redimensiona, y lo contrario de lo que quieres para un icono, donde el grosor debe escalar con la forma.
El área que responde a los eventos de puntero no es la caja envolvente: es la geometría pintada, controlada por pointer-events. Con el valor por omisión visiblePainted, un elemento con fill="none" no recibe eventos en su interior, solo sobre el trazo. Eso convierte una línea de datos de 2 unidades de grosor en un objetivo de puntero de 2 píxeles, imposible de acertar. Las dos soluciones son duplicar la ruta con un trazo grueso transparente y pointer-events="stroke", o poner stroke-width grande con stroke="transparent" en una copia detrás. La primera es la que usan las bibliotecas serias, y explica por qué en el DOM de un gráfico profesional hay el doble de rutas de las que se ven. El otro valor útil es pointer-events="all", que hace que el elemento responda tanto en el relleno como en el trazo aunque sean none o transparentes: es la forma de convertir un rect invisible en una zona de captura.
Escribe una función que, dado un elemento SVG, calcule el viewBox mínimo que lo contiene incluyendo el trazo. Parte de getBBox(), súmale la mitad del stroke-width computado (léelo con getComputedStyle), y compáralo con lo que devuelve getBoundingClientRect() convertido al espacio de usuario. Cuando los dos números no coincidan, averigua cuál de las causas de la lista (marcadores, filtro, unión en punta) es la responsable.