Saltar al contenido

El sprite SVG en línea

Cómo montar un sprite dentro del documento, dónde colocarlo, cómo ocultarlo sin romper las referencias, y el pipeline que lo genera a partir de ficheros sueltos.

La estructura mínima

Un sprite en línea es un elemento svg con un symbol por icono, y el resto del documento lo consume con use.

HTML
<svg class="sprite" aria-hidden="true">  <symbol id="i-buscar" viewBox="0 0 24 24">    <circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2"/>    <path d="M16 16l5 5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>  </symbol>  <symbol id="i-cerrar" viewBox="0 0 24 24">    <path d="M6 6l12 12M18 6L6 18" fill="none" stroke="currentColor"          stroke-width="2" stroke-linecap="round"/>  </symbol></svg><button type="button" aria-label="Buscar">  <svg class="ico" aria-hidden="true"><use href="#i-buscar" /></svg></button>

Dos detalles del marcado de consumo. El use va dentro de un svg propio, porque use no es un elemento de HTML y necesita un contexto SVG. Y ese svg lleva aria-hidden="true" porque el nombre accesible lo pone el botón; el patrón completo se explica en el nivel 24.

Cómo ocultar el bloque

Aquí está la decisión que más veces se hace mal. El bloque de símbolos ocupa espacio en el flujo si no lo tratas, porque un svg sin width ni height toma sus dimensiones por defecto, que son 300 por 150 píxeles.

La opción que funciona y es segura:

CSS
.sprite {  position: absolute;  width: 0;  height: 0;  overflow: hidden;}

La opción que también funciona hoy pero tiene historia:

CSS
.sprite { display: none; }

display: none sobre el contenedor del sprite funciona en los motores actuales, porque el contenido de un symbol no se renderiza de todas formas y lo que el use instancia es una copia independiente. Fue durante años una fuente de fallos en navegadores antiguos, y esa es la razón de que la variante con width: 0 siga siendo la que aparece en todas las guías. Si tu matriz de soporte es moderna, display: none es correcto y más simple; si no lo es, la versión con position: absolute no cuesta nada.

Lo que no funciona: poner los símbolos dentro de un contenedor HTML oculto con hidden o con display: none aplicado a un ancestro si el sprite se inyecta por JavaScript en un contenedor que aún no está en el documento. La referencia se resuelve contra el documento, así que el sprite tiene que estar conectado al DOM. Un sprite guardado en una variable de JavaScript y nunca insertado no lo ve nadie.

Dónde colocar el bloque

Tres opciones y un criterio claro.

En el body, al principio, en todas las páginas. Es lo que hacen la mayoría de los sitios renderizados en servidor. Ventaja: siempre está disponible, sin condiciones de carrera. Inconveniente: el HTML de cada página crece con el sprite entero, y ese peso no se cachea entre páginas porque va dentro del documento.

Inyectado por JavaScript al arrancar. Se pide el sprite una vez, se cachea como fichero, y se inserta al principio del body. Ventaja: el sprite sí se cachea. Inconveniente: hay una ventana entre el primer pintado y la inserción en la que los iconos no aparecen, y si el script falla no aparecen nunca.

Solo los iconos que la página usa. Un paso de compilación analiza la página y emite únicamente los símbolos referenciados. Es la mejor opción en cuanto a peso y la que exige más maquinaria.

El criterio: si el sitio tiene menos de treinta iconos y se renderiza en servidor, el sprite completo en línea es la opción correcta y las otras dos son optimización prematura. Treinta iconos de trazo comprimen a unos pocos kilobytes. Si el sitio tiene doscientos iconos y solo usa diez por página, el subconjunto por página es la única opción sensata.

Generar el sprite

Escribirlo a mano no escala. El paso de generación toma un directorio de ficheros SVG y emite el bloque:

JS
// build-sprite.mjsimport { readdir, readFile, writeFile } from 'node:fs/promises';import { basename, join } from 'node:path';const DIR = 'src/iconos';const SALIDA = 'src/sprite.html';const ficheros = (await readdir(DIR)).filter(f => f.endsWith('.svg'));const simbolos = await Promise.all(ficheros.map(async f => {  const bruto = await readFile(join(DIR, f), 'utf8');  const viewBox = bruto.match(/viewBox="([^"]+)"/)?.[1] ?? '0 0 24 24';  const cuerpo = bruto    .replace(/<\?xml[^>]*\?>/g, '')    .replace(/<!--[\s\S]*?-->/g, '')    .replace(/^[\s\S]*?<svg[^>]*>/, '')    .replace(/<\/svg>\s*$/, '')    .trim();  const id = 'i-' + basename(f, '.svg');  return `  <symbol id="${id}" viewBox="${viewBox}">${cuerpo}</symbol>`;}));await writeFile(SALIDA,  `<svg class="sprite" aria-hidden="true">\n${simbolos.join('\n')}\n</svg>\n`);console.log(`${simbolos.length} simbolos en ${SALIDA}`);

Tres cosas que este script hace bien y conviene no perder al adaptarlo. Conserva el viewBox de cada fichero, que es lo que permite mezclar rejillas. Elimina el prólogo XML y los comentarios, que son basura de exportación. Y deriva el id del nombre del fichero, que es la única forma de que el sprite y el código que lo consume no se desincronicen.

Lo que no hace y hay que añadir en un proyecto real: normalizar los colores a currentColor, y prefijar los identificadores internos de cada icono. Ambos se explican en el nivel 25.