wandres.dev
GRADIENTES Y PATRONES · Rellenos complejos

gradientUnits: los dos sistemas de coordenadas

objectBoundingBox frente a userSpaceOnUse, por qué un gradiente desaparece sobre una línea horizontal, por qué un degradado a 45 grados se deforma, y cuándo usar cada sistema.

⏱ 20 min

Casi todos los problemas de gradientes en SVG (el que no se ve, el que sale deformado, el que no coincide entre dos formas) tienen la misma causa: gradientUnits. Hay dos sistemas de coordenadas posibles, el valor por omisión no es el que la mayoría espera, y sus dos efectos secundarios (la deformación por proporción y la desaparición con caja degenerada) no están escritos en ningún sitio evidente. Este es el nivel que hay que entender de verdad; el resto de los gradientes son detalles.

🎯 Al terminar esta lección sabrás
  • Explicar qué significan las coordenadas de un gradiente en cada uno de los dos sistemas.
  • Diagnosticar en diez segundos un gradiente que no se ve.
  • Corregir la deformación de un degradado diagonal sobre un elemento no cuadrado.
  • Elegir el sistema correcto según si el gradiente se comparte entre formas o no.

Los dos sistemas

gradientUnits acepta dos valores y su valor por omisión es objectBoundingBox.

Con objectBoundingBox, las coordenadas del gradiente (x1, y1, x2, y2 en el lineal; cx, cy, r, fx, fy en el radial) se interpretan como fracciones de la caja envolvente del elemento que lo usa. El 0 es el borde izquierdo o superior de esa caja; el 1 es el derecho o el inferior. Da igual dónde esté la forma y cuánto mida: el gradiente se adapta.

Con userSpaceOnUse, las coordenadas se interpretan en el sistema de coordenadas del usuario vigente en el elemento que referencia el gradiente. Son unidades absolutas del viewBox, igual que las de cualquier forma.

<svg viewBox="0 0 220 100" width="440">
  <defs>
    <!-- Se adapta a cada forma -->
    <linearGradient id="caja">
      <stop offset="0" stop-color="#89b4fa" /><stop offset="1" stop-color="#f38ba8" />
    </linearGradient>
    <!-- Coordenadas absolutas del lienzo -->
    <linearGradient id="lienzo" gradientUnits="userSpaceOnUse" x1="0" y1="0" x2="220" y2="0">
      <stop offset="0" stop-color="#89b4fa" /><stop offset="1" stop-color="#f38ba8" />
    </linearGradient>
  </defs>

  <g fill="url(#caja)">
    <rect x="10" y="10" width="40" height="30" />
    <rect x="10" y="55" width="90" height="30" />
  </g>
  <g fill="url(#lienzo)">
    <rect x="120" y="10" width="40" height="30" />
    <rect x="120" y="55" width="90" height="30" />
  </g>
</svg>

La diferencia es inmediata al mirarlo. En el grupo de la izquierda, cada rectángulo tiene el degradado completo: el pequeño va de azul a rojo en 40 unidades, el grande en 90. En el de la derecha, los dos comparten un único degradado que recorre todo el lienzo, así que el rectángulo pequeño solo muestra el trozo que le toca por su posición.

Ninguno de los dos es «el correcto»: responden a intenciones distintas. Si cada forma debe tener su propio degradado completo, objectBoundingBox. Si varias formas deben compartir un degradado continuo, como las barras de un gráfico teñidas por altura, userSpaceOnUse.

El fallo número uno: el gradiente que no se ve

La especificación dice, sin adornos, que cuando gradientUnits es objectBoundingBox y la caja envolvente del elemento tiene anchura o altura cero, el gradiente no se renderiza. El elemento se pinta como si tuviera fill: none.

¿Qué elementos tienen una caja envolvente con una dimensión nula? Más de los que parece:

  • Un line horizontal o vertical.
  • Un path que solo contiene un segmento horizontal o vertical.
  • Un rect con height="0", que aparece cuando un dato vale cero.
  • Una polyline cuyos puntos están todos en la misma fila.

Y el caso que más veces se sufre: un trazo. Aplicar stroke="url(#g)" a un line horizontal. La caja envolvente sigue siendo la geométrica, sin trazo, así que tiene altura cero y el gradiente desaparece. La línea sale invisible y nadie entiende por qué, porque el mismo gradiente funciona perfectamente en el rectángulo de al lado.

<svg viewBox="0 0 200 60" width="400">
  <defs>
    <linearGradient id="g1">
      <stop offset="0" stop-color="#89b4fa" /><stop offset="1" stop-color="#f38ba8" />
    </linearGradient>
    <linearGradient id="g2" gradientUnits="userSpaceOnUse" x1="20" y1="0" x2="180" y2="0">
      <stop offset="0" stop-color="#89b4fa" /><stop offset="1" stop-color="#f38ba8" />
    </linearGradient>
  </defs>

  <!-- Invisible: caja de altura cero -->
  <line x1="20" y1="20" x2="180" y2="20" stroke="url(#g1)" stroke-width="6" />
  <!-- Visible -->
  <line x1="20" y1="45" x2="180" y2="45" stroke="url(#g2)" stroke-width="6" />
</svg>

El diagnóstico es de diez segundos: si un gradiente no se ve, cambia gradientUnits a userSpaceOnUse y pon coordenadas absolutas. Si aparece, la causa era la caja degenerada y ya sabes por qué.

Y una vez sabido, la regla preventiva: toda forma que pueda ser plana usa userSpaceOnUse. Líneas, ejes, separadores, barras que pueden valer cero.

Y sí, esto también le pasa a los patrones, las máscaras, los filtros y los recortes

La regla de la caja degenerada no es exclusiva de los gradientes. Todo elemento que usa objectBoundingBox la sufre, y la lista es larga: pattern con patternUnits="objectBoundingBox", mask con maskUnits="objectBoundingBox", clipPath con clipPathUnits="objectBoundingBox", y filter con filterUnits="objectBoundingBox", que es el valor por omisión de todos ellos salvo clipPathUnits.

Es decir: aplicar un filtro a una línea horizontal produce, por la misma razón, nada. Ni la línea filtrada ni la línea sin filtrar: nada en absoluto, porque la región del filtro tiene área cero. Ese es el motivo real de que un feDropShadow sobre un separador horizontal haga desaparecer el separador, y es un fallo que la gente atribuye al filtro cuando el filtro no ha llegado a ejecutarse.

La misma trampa tiene una variante que aparece antes de tiempo: un elemento cuyo contenido aún no se ha medido. Si aplicas un gradiente en unidades de caja a un g que todavía está vacío porque los datos no han llegado, la caja es nula y el gradiente no se pinta. Cuando los datos llegan y el grupo crece, el gradiente se recalcula y aparece. El resultado es un gradiente que funciona en producción y no en el test, o al revés, según el momento en que se mire.

La defensa general: cuando algo que usa objectBoundingBox no aparece, lo primero que hay que preguntarse no es qué está mal en la definición, sino si la caja tiene área.

El fallo número dos: el ángulo que se deforma

Con objectBoundingBox, el gradiente se define en un cuadrado unitario que después se escala para encajar en la caja del elemento. Ese escalado es independiente en cada eje, así que si la caja no es cuadrada, el espacio del gradiente se deforma.

Un vector de (0,0) a (1,1) es una diagonal a 45 grados en el cuadrado unitario. Aplicado a un rectángulo de 200 por 50, el escalado multiplica por 200 en x y por 50 en y, y la diagonal resultante forma un ángulo de unos 14 grados, no 45. Además, las bandas de color no son perpendiculares al vector: también se han inclinado.

<svg viewBox="0 0 220 80" width="440">
  <defs>
    <linearGradient id="deforme" x1="0" y1="0" x2="1" y2="1">
      <stop offset="0" stop-color="#a6e3a1" /><stop offset="1" stop-color="#cba6f7" />
    </linearGradient>
    <linearGradient id="recto" gradientUnits="userSpaceOnUse"
                    x1="10" y1="10" x2="70" y2="70">
      <stop offset="0" stop-color="#a6e3a1" /><stop offset="1" stop-color="#cba6f7" />
    </linearGradient>
  </defs>
  <rect x="10"  y="10" width="90" height="60" fill="url(#deforme)" />
  <rect x="120" y="10" width="90" height="60" fill="url(#recto)" />
</svg>

El de la izquierda tiene el degradado inclinado y aplastado; el de la derecha, a 45 grados de verdad.

Este comportamiento no es un fallo: es la definición de un sistema de coordenadas relativo, y es lo que hace que el gradiente se adapte a cualquier tamaño. Solo hay que saber que el ángulo no se conserva, y que si el ángulo importa, hace falta userSpaceOnUse.

Hay una tercera vía para el caso concreto de querer 45 grados reales en unidades de caja: gradientTransform con una rotación, combinado con un vector horizontal. La rotación se aplica en el espacio del gradiente antes del escalado, así que sigue deformándose. No hay atajo: si quieres control angular, usa el espacio de usuario.

El criterio

Situación Sistema Motivo
Cada forma con su degradado completo objectBoundingBox Se adapta al tamaño sin recalcular
Varias formas con un degradado continuo userSpaceOnUse Un solo espacio compartido
Trazo de una línea o de una forma plana userSpaceOnUse Evita la caja degenerada
El ángulo debe ser exacto userSpaceOnUse El otro sistema deforma
El elemento cambia de tamaño y el degradado debe seguir objectBoundingBox Es relativo por definición
Barras de un gráfico teñidas por altura global userSpaceOnUse El color debe depender del valor, no de la barra

Y una advertencia sobre userSpaceOnUse que se paga cuando el gráfico es responsivo: las coordenadas son absolutas en el viewBox, así que si el viewBox cambia, hay que recalcularlas. Si tu gráfico se redimensiona cambiando el viewBox en lugar de la caja CSS, el gradiente hay que regenerarlo. Es el precio del control.

⚔️ Reto práctico

Coge un gráfico de barras y tíñelo con un gradiente vertical que vaya de un color en el valor cero a otro en el valor máximo del conjunto, de modo que dos barras de la misma altura tengan exactamente el mismo color aunque estén en posiciones distintas. Hazlo primero con objectBoundingBox y comprueba que falla (cada barra tiene su gradiente completo). Después con userSpaceOnUse y las coordenadas correctas. El ejercicio real es darse cuenta de que la pregunta «qué color debe tener esta barra» solo tiene respuesta en un espacio compartido.