wandres.dev
TEXTO EN SVG · text, tspan y textPath

tspan, dx, dy y el texto multilínea

El elemento que permite tramos con estilo propio, la diferencia entre posición absoluta y desplazamiento relativo, el patrón de líneas manuales, y la trampa del espacio en blanco.

⏱ 16 min

tspan es el único mecanismo que tiene SVG para que una parte de un texto sea distinta del resto o esté en otro sitio. Con él se componen las líneas que el formato no ajusta solo, se destacan palabras y se colocan sufijos. Su modelo de posicionamiento tiene dos modos que se mezclan constantemente y producen resultados desconcertantes si no distingues cuál estás usando.

🎯 Al terminar esta lección sabrás
  • Usar tspan para dar estilo a un tramo dentro de un texto.
  • Distinguir el posicionamiento absoluto (x, y) del relativo (dx, dy) y saber cuándo se acumulan.
  • Componer un texto de varias líneas con el patrón correcto.
  • Controlar el espacio en blanco para que el marcado formateado no ensucie el resultado.

Tramos con estilo

El uso más simple: cambiar el aspecto de una parte.

<svg viewBox="0 0 260 50" width="520">
  <text x="10" y="32" font-size="22" fill="#cdd6f4"
        font-family="system-ui, sans-serif">
    Ventas: <tspan font-weight="700" fill="#a6e3a1">+18,4 %</tspan>
  </text>
</svg>

El tspan hereda todo del text salvo lo que declare. El texto fluye de forma continua: no hay salto ni espacio adicional.

Este es el patrón para las etiquetas de un gráfico donde el valor tiene que destacar, y para las unidades en tamaño menor:

<text x="10" y="40" font-size="32" font-weight="700">
  248<tspan font-size="16" font-weight="400" dx="2">ms</tspan>
</text>

El dx="2" añade dos unidades de separación, porque un espacio literal en el marcado sería del tamaño del texto grande.

Absoluto frente a relativo

tspan acepta x e y (posición absoluta) y dx y dy (desplazamiento relativo a donde estaría). Los dos modos coexisten y se aplican en este orden: primero se resuelve la posición absoluta si la hay, después se suma el desplazamiento.

La diferencia crucial está en la acumulación.

x e y reinician la posición. Todo lo que venga después de un tspan con x sigue a partir de ahí.

dx y dy se acumulan. Un tspan con dy="10" desplaza su contenido diez unidades hacia abajo, y todo lo que venga después sigue en esa nueva línea base. No vuelve.

<!-- Los tres tspan bajan en escalera, no vuelven al nivel original -->
<text x="10" y="20" font-size="18">
  A<tspan dy="10">B</tspan><tspan dy="10">C</tspan><tspan dy="10">D</tspan>
</text>

La D está treinta unidades por debajo de la A. Ese comportamiento acumulativo es exactamente lo que se quiere para componer líneas, y exactamente lo que sorprende cuando quieres un subíndice y todo lo demás se queda abajo.

Para un subíndice o superíndice hay que compensar:

<text x="10" y="30" font-size="20">
  H<tspan dy="4" font-size="14">2</tspan><tspan dy="-4">O</tspan>
</text>

Baja cuatro, sube cuatro. Es tedioso y es el precio del modelo acumulativo.

El patrón de líneas manuales

Como no hay ajuste automático, las líneas se componen con un tspan por línea, reiniciando la x y acumulando la y:

<text x="20" y="30" font-size="16" fill="#cdd6f4"
      font-family="system-ui, sans-serif">
  <tspan x="20" dy="0">Primera línea del párrafo</tspan>
  <tspan x="20" dy="1.35em">Segunda línea del párrafo</tspan>
  <tspan x="20" dy="1.35em">Tercera línea del párrafo</tspan>
</text>

Tres decisiones ahí y las tres importan.

La x se repite en cada tspan. Sin ella, cada línea empezaría donde acabó la anterior, porque dy solo mueve en vertical.

El dy está en em. Así el interlineado escala con el tamaño de fuente y no hay que recalcularlo. El valor 1,35 es un interlineado cómodo; entre 1,2 y 1,5 es el rango sensato.

El primer tspan lleva dy="0". No es imprescindible, pero deja explícito que la primera línea va en la y del text y evita que alguien añada un dy al primero por simetría y desplace todo.

Generado desde código:

function textoMultilinea(lineas, x, y, interlineado = 1.35) {
  const tramos = lineas
    .map((l, i) => `<tspan x="${x}" dy="${i === 0 ? 0 : interlineado + 'em'}">${l}</tspan>`)
    .join('');
  return `<text x="${x}" y="${y}">${tramos}</text>`;
}

Para centrar el bloque verticalmente sobre una y dada hay que desplazar la primera línea hacia arriba la mitad del alto total:

const desplazamiento = -((lineas.length - 1) * interlineado) / 2;
// y ese valor va en el dy del primer tspan, en em

El espacio en blanco

Este es el detalle que ensucia todos los textos generados con marcado formateado. Por omisión, SVG colapsa las secuencias de espacios y elimina los del principio y el final de cada tramo, igual que HTML. Pero el marcado indentado introduce saltos de línea y espacios entre los tspan, y esos se convierten en un espacio real.

<!-- Sale con un espacio de mas entre A y B -->
<text x="10" y="20">
  <tspan>A</tspan>
  <tspan>B</tspan>
</text>

<!-- Correcto -->
<text x="10" y="20"><tspan>A</tspan><tspan>B</tspan></text>

En el primero, el salto de línea y la indentación entre los dos tspan producen un espacio. En textos generados por código eso se controla no poniendo separadores; en textos escritos a mano hay que pegar las etiquetas, lo que hace el marcado ilegible.

La propiedad que controla esto es white-space, con los mismos valores que en CSS. white-space: pre conserva todo, incluidos los saltos de línea, pero no los convierte en saltos visuales: siguen apareciendo como espacios, porque el texto de SVG no salta de línea. El atributo antiguo xml:space="preserve" hace algo parecido y está obsoleto en favor de la propiedad.

Un tspan no es una caja, y por eso el fondo de una etiqueta no se puede hacer con CSS

La diferencia estructural entre tspan y span es que el primero no genera ninguna caja. No tiene background, no tiene padding, no tiene border, y no responde a display. Es un contenedor de posicionamiento y estilo de pintura, nada más.

La consecuencia práctica aparece en cuanto quieres una etiqueta con fondo, que es una de las necesidades más frecuentes de un gráfico: un valor sobre un punto, un tooltip fijo, una anotación sobre una zona con contraste variable. En HTML es un span con background y padding. En SVG hay que dibujar el rectángulo tú.

Y para dibujar el rectángulo necesitas saber cuánto mide el texto, lo que exige medirlo después de haberlo insertado en el documento:

const t = svg.querySelector('#etiqueta');
const c = t.getBBox();
const fondo = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
fondo.setAttribute('x', c.x - 4);
fondo.setAttribute('y', c.y - 2);
fondo.setAttribute('width', c.width + 8);
fondo.setAttribute('height', c.height + 4);
fondo.setAttribute('rx', 3);
fondo.setAttribute('fill', '#1e1e2e');
t.parentNode.insertBefore(fondo, t);   // detras del texto

Tres cosas que hacen que esto sea más doloroso de lo que parece. La medición fuerza un cálculo de layout síncrono, así que hacerlo para cien etiquetas en un bucle es un patrón de degradación clásico: mide todas primero, escribe todas después. El rectángulo hay que insertarlo antes del texto en el orden del documento, porque SVG pinta en orden y no hay z-index. Y si la fuente no ha terminado de cargar, getBBox() devuelve la medida de la fuente de reserva y el fondo queda del tamaño equivocado; hay que esperar a document.fonts.ready.

La alternativa que evita todo esto es la del artículo anterior: poner la etiqueta en HTML encima del SVG. Recupera el fondo, el padding, el ajuste de línea y la accesibilidad, y el coste es sincronizar las coordenadas.

⚔️ Reto práctico

Genera un bloque de texto de cuatro líneas centrado vertical y horizontalmente sobre un punto dado, con fondo redondeado ajustado al texto más largo. Hazlo esperando a document.fonts.ready, midiendo todas las líneas antes de escribir ningún atributo, y comprueba en el panel de rendimiento que no hay más de un cálculo de layout forzado.