wandres.dev
REUTILIZAR EN SVG · defs, use, symbol y sprites

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.

⏱ 16 min

El sprite en línea es la técnica que convierte cincuenta iconos en una sola definición reutilizable y cincuenta instancias de dos líneas. Su montaje tiene tres decisiones que parecen triviales y no lo son: dónde va el bloque de símbolos, cómo se oculta sin romper las referencias, y cómo se genera sin escribirlo a mano. Las tres tienen una respuesta correcta y varias que fallan de forma sutil.

🎯 Al terminar esta lección sabrás
  • Montar un sprite en línea con symbol y usarlo con use.
  • Ocultar el bloque de definiciones sin que las instancias desaparezcan.
  • Generar el sprite desde ficheros sueltos con un script reproducible.
  • Decidir dónde colocar el bloque en un sitio con varias páginas.

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.

<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:

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

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

.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:

// build-sprite.mjs
import { 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.

El id del símbolo es una API pública, y renombrarlo rompe el build en silencio

Un sprite crea un acoplamiento invisible entre el nombre de un fichero en un directorio y una cadena de texto escrita en doscientas plantillas. No hay tipos, no hay importaciones, no hay nada que falle en compilación. Renombras buscar.svg a lupa.svg, y el href="#i-buscar" de todas las plantillas apunta a un símbolo que ya no existe. El use no lanza error, no avisa en consola: simplemente no dibuja nada. Y como un icono ausente rara vez rompe el layout, el fallo puede llegar a producción y quedarse ahí semanas.

La defensa que funciona es un paso de verificación en el build: extraer todos los href="#..." del código fuente, extraer todos los id del sprite generado, y fallar si hay alguna referencia huérfana. Son quince líneas y detecta el cien por cien de los casos.

La defensa mejor, si tu stack lo permite, es generar un módulo con los nombres como constantes o como un tipo unión, y prohibir la cadena literal en las plantillas. Entonces el renombrado sí rompe la compilación, que es donde tiene que romperse.

Un detalle de tono relacionado: si el icono ausente deja un hueco de 24 por 24 en la interfaz nadie lo nota, pero si el svg contenedor no tiene tamaño explícito, un símbolo inexistente colapsa la caja a cero y el botón que lo contiene cambia de tamaño. Ese síntoma (un botón más estrecho de lo normal) es a menudo la primera pista de una referencia rota.

⚔️ Reto práctico

Escribe el verificador de referencias huérfanas: un script que recorra tus plantillas buscando href="#i-...", compare con los id del sprite generado, e imprima las referencias que no existen y los símbolos que nadie usa. Los segundos son igual de valiosos: te dicen qué iconos puedes borrar del sprite.