wandres.dev
SVG: EL DOCUMENTO · Un lenguaje, no un formato

img, object o en línea: qué permite cada forma de incluir un SVG

La decisión que determina qué CSS se aplica, qué scripts corren y qué recursos se cargan, y por qué tu hoja de estilos no afecta al SVG que acabas de insertar.

⏱ 20 min

La pregunta más repetida sobre SVG es por qué el CSS no le afecta, y la respuesta nunca está en el CSS: está en cómo se incluyó el fichero. Las tres formas de meter un SVG en una página producen tres situaciones radicalmente distintas en cuanto a estilos, scripts, recursos externos y accesibilidad, y elegir mal condena todo el trabajo posterior. La diferencia no es un capricho de implementación, es una frontera de seguridad, y entenderla como tal hace que todas las consecuencias se vuelvan predecibles.

🎯 Al terminar esta lección sabrás
  • Enumerar qué permite y qué prohíbe cada una de las tres formas.
  • Explicar por qué el CSS de la página no alcanza a un SVG cargado como imagen.
  • Elegir la forma correcta según lo que el SVG tenga que hacer.
  • Reconocer los dos problemas propios del SVG en línea y sus soluciones.

Las tres formas

Como imagen. El SVG es el origen de una etiqueta de imagen, o el valor de una propiedad de CSS como background-image o mask-image.

<img src="/iconos/mapa.svg" alt="Mapa de la zona" width="48" height="48">

El navegador lo trata como una imagen de tamaño arbitrario: lo descarga, lo dibuja en su caja y ahí acaba la relación. Se cachea como cualquier imagen, admite loading="lazy", y su contenido está completamente aislado de la página.

Como documento embebido. Con object, iframe o el heredado embed.

<object data="/graficos/ventas.svg" type="image/svg+xml"
        width="600" height="400" aria-label="Grafico de ventas trimestrales">
  <img src="/graficos/ventas.png" alt="Grafico de ventas trimestrales">
</object>

Aquí sí hay un documento de verdad, con su propio árbol, sus propios estilos y sus propios scripts, dentro de la página pero separado de ella. Lo que va entre las etiquetas de object es contenido alternativo que se muestra si el recurso no carga.

En línea. El marcado del SVG vive dentro del HTML.

<svg viewBox="0 0 24 24" width="24" height="24" fill="none"
     stroke="currentColor" stroke-width="2" aria-hidden="true">
  <path d="M3 12h18M12 3v18"/>
</svg>

No hay fichero, no hay petición y no hay frontera: esos elementos son nodos del mismo documento que todo lo demás, con las capacidades completas descritas en SVG es XML en el DOM.

Por qué el CSS de la página no llega

Un SVG cargado como imagen se dibuja en un contexto aparte al que la cascada de tu página no entra. No es que el selector no lo alcance: es que hay dos documentos, y las hojas de estilo de uno no se aplican al otro. Por eso este código, que todo el mundo escribe alguna vez, no hace absolutamente nada:

/* No funciona: el path esta en otro documento */
img[src$=".svg"] path { fill: red; }

La misma frontera explica las otras tres sorpresas.

currentColor no funciona. Dentro de la imagen no hay ningún color heredado de tu página, así que currentColor se resuelve contra el valor por defecto del propio documento SVG, que es negro salvo que el fichero diga otra cosa.

Las fuentes externas no se cargan. Un SVG en modo imagen no puede pedir recursos externos, así que la fuente declarada con @font-face no llega y el texto se dibuja con la de reserva, normalmente con otra anchura y descolocando el diseño. Es la causa clásica de un SVG exportado que se ve perfecto al abrirlo directamente y roto dentro de la página. Las salidas son dos: incrustar la fuente en el propio fichero como URI de datos, o convertir el texto a trazados al exportar, con lo que deja de ser texto.

Los scripts no se ejecutan. Nada de JavaScript dentro de un SVG cargado como imagen. Y esto no es una limitación molesta sino la característica que lo hace utilizable: es lo que permite mostrar un SVG subido por un usuario sin abrir un agujero de seguridad.

El aislamiento no es arbitrario: es una frontera de seguridad, y con ella vienen dos regalos que casi nadie usa

Conviene entender por qué existe esa frontera, porque de ahí se deduce todo lo demás sin memorizar nada. Un SVG es un documento con capacidad de ejecutar scripts, cargar recursos y definir estilos. Si el CSS y el JavaScript de tu página pudieran entrar en un SVG remoto, o si el SVG pudiera salir hacia tu página, cualquier imagen sería un vector de ejecución de código en el origen del sitio. La plataforma lo resuelve igual que con las imágenes normales: cuando un SVG entra como imagen, se le retiran las capacidades de documento. De ahí, en orden, el aislamiento del CSS, el bloqueo de recursos externos y la desactivación de los scripts. La consecuencia de seguridad que hay que tener grabada es la inversa: si insertas en línea un SVG que no controlas —subido por un usuario, traído de una API, pegado desde un editor— estás metiendo marcado ejecutable en tu documento. Un SVG puede contener un elemento script, manejadores de eventos en atributos y referencias externas, y al ponerlo en línea todo eso corre con los permisos de tu origen: es exactamente una inyección de código. La forma segura de mostrar un SVG ajeno es como imagen, que lo neutraliza por construcción; y si de verdad necesitas insertarlo en línea, hay que sanearlo con una biblioteca que entienda SVG, nunca con un reemplazo de cadenas. Ahora los dos regalos, que compensan parte de lo que se pierde y son poco conocidos. El primero: dentro de un SVG cargado como imagen, las animaciones declarativas sí funcionan. Las animaciones de CSS escritas dentro del fichero se ejecutan con normalidad, porque no requieren scripts. Un indicador de carga, un logotipo que se dibuja solo o un icono que late pueden vivir enteros en un fichero cacheado y funcionar dentro de una etiqueta de imagen sin una línea de JavaScript. El segundo resuelve un problema real: las consultas de medios dentro del fichero también funcionan y responden a las preferencias del usuario. Un bloque @media (prefers-color-scheme: dark) dentro del SVG cambia sus colores cuando el sistema está en modo oscuro, aunque el fichero esté cargado como imagen y no tengas ninguna forma de estilarlo desde fuera. Es la solución estándar para un logotipo o un favicon que deben adaptarse al tema, y evita servir dos ficheros y elegir con picture. Y las consultas de dimensiones dentro del fichero se evalúan contra el viewport del propio SVG, no contra el de la página, lo que permite escribir un gráfico que muestra más o menos detalle según el tamaño al que se le pinte, sin saber nada del sitio que lo usa.

La tabla de decisión

Capacidad Como imagen Con object En línea
El CSS de la página lo estila No No
currentColor sigue al texto No No
Las variables CSS de la página llegan No No
El CSS dentro del fichero funciona Sí, y se mezcla con el de la página
Animaciones declarativas del fichero
Scripts dentro del fichero No Sí, con los permisos de la página
Recursos externos: fuentes, imágenes No
Accesible desde el JavaScript de la página No Sí, con contentDocument Sí, directamente
Se cachea por separado No: viaja en el HTML
Petición adicional No
Seguro para contenido ajeno Con aislamiento No
Texto alternativo alt Contenido de reserva y aria-label title y desc internos

Traducido a recomendaciones, sin ambigüedad:

Iconos que deben tomar el color del texto: en línea. Es el único caso en el que currentColor funciona, y es la razón principal por la que los sistemas de iconos modernos son en línea.

Ilustraciones, logotipos y fondos decorativos: como imagen. Se cachean, no engordan el HTML, se pueden cargar de forma diferida y no necesitan estilado.

Gráficos con interacción propia y autocontenida, sobre todo si vienen de un tercero o de otro equipo: con object. Corren sus scripts, no contaminan tu página y no la contaminan con sus identificadores.

Gráficos que la aplicación tiene que manipular: en línea, generados o modificados desde tu código.

SVG subido por usuarios: como imagen, siempre.

Un apunte sobre object que se pasa por alto: desde la página se puede llegar a su interior, si el fichero es del mismo origen, y eso permite estilarlo o manipularlo después de cargado.

const obj = document.querySelector('object');
obj.addEventListener('load', () => {
  const doc = obj.contentDocument;            // tambien obj.getSVGDocument()
  if (!doc) return;                            // otro origen: no hay acceso
  const estilo = doc.createElementNS('http://www.w3.org/2000/svg', 'style');
  estilo.textContent = '.barra { fill: #a6e3a1 }';
  doc.documentElement.appendChild(estilo);
  doc.querySelectorAll('.barra').forEach(b => {
    b.addEventListener('click', () => console.log(b.dataset.valor));
  });
});

Es la vía intermedia entre el aislamiento total de la imagen y la ausencia de aislamiento del SVG en línea, y está infrautilizada.

Los dos problemas del SVG en línea

El SVG en línea lo permite todo, y a cambio tiene dos inconvenientes propios que hay que gestionar.

No se cachea y se repite. El marcado viaja dentro del HTML, así que se descarga con cada página y ocupa sitio en el documento. Con un icono de diez líneas da igual; con un mapa de cuatrocientos kilobytes repetido en cada página es un problema de peso y de tiempo de análisis. Y si el mismo icono aparece treinta veces en una lista, el marcado se duplica treinta veces, con su coste en nodos del DOM. La solución dentro del propio documento es definir el dibujo una vez y referenciarlo desde cada aparición, que es todo un tema en sí mismo.

Los identificadores son globales. Esto sorprende y rompe cosas de verdad. Los id de un SVG en línea comparten espacio con los de todo el documento HTML, y las referencias internas —un gradiente, una máscara, un recorte, un marcador— se resuelven por id. Si insertas dos veces el mismo SVG con un gradiente llamado degradado, el segundo tiene un identificador duplicado y ambos elementos usarán el primero, porque la búsqueda devuelve el primero que encuentra. El resultado es una figura que aparece con el color de otra, y es de esos fallos que uno tarda en creerse.

<!-- Los dos usaran el gradiente del PRIMERO -->
<svg viewBox="0 0 100 100"><defs><linearGradient id="g"></linearGradient></defs>
  <rect width="100" height="100" fill="url(#g)"/></svg>
<svg viewBox="0 0 100 100"><defs><linearGradient id="g"></linearGradient></defs>
  <rect width="100" height="100" fill="url(#g)"/></svg>

La solución es prefijar los identificadores con algo único por instancia en el momento de insertar:

let contador = 0;

function insertarSvg(contenedor, marcado) {
  const sufijo = '-i' + (++contador);
  const doc = new DOMParser().parseFromString(marcado, 'image/svg+xml');
  const raiz = doc.documentElement;
  // Renombrar los id y las referencias url(#...) y href="#..."
  raiz.querySelectorAll('[id]').forEach(el => {
    const viejo = el.id;
    const nuevo = viejo + sufijo;
    el.id = nuevo;
    raiz.querySelectorAll('*').forEach(otro => {
      for (const atributo of otro.attributes) {
        if (atributo.value.includes('#' + viejo)) {
          atributo.value = atributo.value.replaceAll('#' + viejo, '#' + nuevo);
        }
      }
    });
  });
  contenedor.appendChild(document.importNode(raiz, true));
}

Ese recorrido es cuadrático y solo vale para insertar unos pocos SVG; para un sistema de iconos completo, lo que se hace es generar los identificadores únicos en el momento de construir la página, no en el navegador. Y la observación general que conviene llevarse: cualquier cosa que dentro de un SVG se refiera a otra por id deja de ser autocontenida en cuanto el SVG se pone en línea, y eso incluye gradientes, patrones, máscaras, recortes, filtros y marcadores.