wandres.dev
RUTAS Y RECORRIDOS · offset-path y motion path

url() a una forma SVG: una sola fuente de verdad

Apuntar offset-path a un elemento SVG del documento, entender el sistema de coordenadas que impone y usar el mismo trazado para el recorrido, para la línea dibujada y para las mediciones en JavaScript.

⏱ 18 min

Un path() escrito en CSS es una cadena que nadie más puede leer: no se ve, no se mide, no se dibuja. En cuanto el diseño pide que el recorrido sea visible —una línea punteada por la que viaja un icono, un mapa con un avión que sigue la ruta— acabas con la misma curva escrita dos veces, una en la hoja de estilos y otra en el atributo d de un <path>, y con la certeza de que en algún momento alguien tocará una y no la otra. url() elimina esa duplicación apuntando directamente al elemento SVG, con la consecuencia menos obvia de que cambia el sistema de coordenadas bajo tus pies.

🎯 Al terminar esta lección sabrás
  • Referenciar una forma SVG desde offset-path y saber qué elementos son válidos.
  • Explicar en qué sistema de coordenadas se interpreta el trazado referenciado y dónde queda su origen.
  • Detectar el fallo silencioso de una referencia rota y por qué no se ve como un error.
  • Combinar el mismo <path> como recorrido, como trazo dibujado y como fuente de medidas en JavaScript.

Qué se puede referenciar y qué pasa si no

El argumento de url() es el identificador de un elemento de forma SVG del mismo documento. La lista es cerrada: <circle>, <ellipse>, <line>, <path>, <polygon>, <polyline> y <rect>. El trazado es el contorno de esa forma. No vale referenciar un <g>, ni un <use>, ni un <text>, ni un SVG externo con url("otro.svg#id").

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
  <path id="ruta" fill="none"
        d="M 20,140 C 90,20 200,20 270,140 S 430,260 480,140" />
</svg>

<div class="avion"></div>
.avion {
  width: 28px;
  height: 28px;
  background: #89b4fa;
  offset-path: url("#ruta");
  offset-rotate: auto;
  animation: volar 5s ease-in-out infinite alternate;
}

@keyframes volar {
  from { offset-distance: 0%; }
  to   { offset-distance: 100%; }
}

Ese SVG de tamaño cero existe solo para alojar el trazado; el elemento que se mueve es un div normal fuera de él. Funciona porque la referencia es al elemento, no a su renderizado.

Cuando la referencia no resuelve —identificador equivocado, elemento de un tipo no admitido, SVG que aún no está en el DOM— la especificación dice que el valor computado del trazado es path("M0,0"). Eso es un trazado de longitud cero en el origen, no un error. El elemento no desaparece ni se queda donde estaba: salta a la esquina superior izquierda y se queda ahí, inmóvil, con la animación corriendo. Es el síntoma más característico del módulo y merece la pena reconocerlo de un vistazo, porque no genera ninguna advertencia en la consola y en el panel de estilos la declaración aparece perfectamente válida.

⚠️
El orden de aparición importa con SVG inyectado

Si el SVG lo inserta JavaScript después del primer render, la referencia se resuelve como rota en ese primer render y el elemento aparece un instante en la esquina. Lo mismo pasa con SVG cargado por un componente perezoso. La solución no es esperar: es tener el trazado en el HTML inicial, o mantener el elemento con opacity: 0 hasta que el trazado exista.

El sistema de coordenadas que impone url()

Aquí está lo que hace que este valor sea distinto de todos los demás. Con una forma básica, los porcentajes se resuelven contra el bloque contenedor y las longitudes son píxeles CSS medidos desde su esquina. Con url(), la especificación dice que el <coord-box> proporciona el viewport y el sistema de coordenadas de usuario de la forma, con el origen en la esquina superior izquierda y con la unidad de usuario valiendo un píxel.

Traducido: las coordenadas del atributo d se interpretan como píxeles CSS medidos desde la esquina superior izquierda de la caja de referencia del elemento que establece el bloque contenedor. El viewBox del SVG donde vive el trazado no se aplica, y por tanto ninguna escala que ese viewBox implicara se aplica tampoco. Un trazado dibujado para un viewBox="0 0 100 100" en un SVG renderizado a 500 píxeles de ancho seguirá midiendo cien píxeles como recorrido.

Esa es la fuente de la confusión más común con url(): la línea se ve grande y el elemento la recorre en pequeño, en una esquina. No es un bug, es que el recorrido está en unidades de usuario sin escalar. Hay dos formas de arreglarlo y las dos son legítimas.

La primera es hacer coincidir las escalas: dibujar el SVG sin escalado, con width y height en píxeles iguales a los del viewBox, y colocarlo en la misma posición que la caja de referencia. Es la más simple y la que uso salvo que haya un motivo para no hacerlo.

<div class="escena">
  <svg width="500" height="280" viewBox="0 0 500 280">
    <path id="pista" fill="none" stroke="#45475a" stroke-width="2"
          stroke-dasharray="6 6"
          d="M 20,240 C 120,40 260,40 360,180 S 460,60 480,40" />
  </svg>
  <span class="marcador"></span>
</div>
.escena { position: relative; width: 500px; height: 280px; }

.escena svg { position: absolute; inset: 0; }

.marcador {
  position: absolute;
  width: 14px; height: 14px;
  border-radius: 50%;
  background: #a6e3a1;
  offset-path: url("#pista");
  offset-rotate: auto;
  animation: correr 4s linear infinite;
}

@keyframes correr {
  from { offset-distance: 0%; }
  to   { offset-distance: 100%; }
}

La segunda es compensar con scale, útil cuando el SVG sí tiene que escalar de forma fluida. Como la propiedad individual scale se aplica antes que la transformación de desplazamiento, escalar el elemento que recorre no escala su recorrido; hay que escalar el contenedor entero. Lo cual funciona, pero también escala el grosor del elemento que viaja, así que casi siempre acabas volviendo a la primera opción.

Una fuente, tres consumidores

La ventaja real de url() no es estética sino de arquitectura: el mismo <path> alimenta a la vez el recorrido en CSS, el trazo visible y las medidas en JavaScript. Cambiar la curva es cambiar un atributo.

// El mismo elemento que gobierna offset-path se puede medir.
const pista = document.querySelector('#pista');
const total = pista.getTotalLength();

// Preparar el trazo para dibujarse: la tecnica del dasharray.
pista.style.strokeDasharray = total;
pista.style.strokeDashoffset = total;

// Punto y tangente al 60% del recorrido, en unidades de usuario.
const t = 0.6;
const p = pista.getPointAtLength(total * t);
const q = pista.getPointAtLength(Math.min(total, total * t + 1));
const anguloGrados = Math.atan2(q.y - p.y, q.x - p.x) * 180 / Math.PI;

console.log(p.x, p.y, anguloGrados);

getTotalLength() y getPointAtLength() son métodos de SVGGeometryElement, así que están disponibles en las siete formas que url() admite, no solo en <path>. La tangente no tiene método propio: se aproxima con dos puntos muy próximos, que es exactamente lo que hace el motor internamente para offset-rotate: auto.

Con esas tres piezas puedes montar un patrón que ninguna de ellas resuelve sola: la línea se dibuja progresivamente con stroke-dashoffset, el marcador la recorre con offset-distance, y ambas animaciones comparten duración y easing porque están escritas contra el mismo trazado. Si además las dos son animaciones CSS sobre la misma animation-name compuesta, el navegador las mantiene sincronizadas sin que tú lleves ninguna cuenta.

#pista {
  animation: dibujar 4s linear infinite;
}

@keyframes dibujar {
  from { stroke-dashoffset: var(--largo); }
  to   { stroke-dashoffset: 0; }
}
La longitud que CSS usa y la que getTotalLength devuelve no son la misma cifra

Ambas miden longitud de arco, pero cada una la aproxima por su cuenta y con su propia tolerancia. Para trazados con curvatura suave la discrepancia es de decimales y no la vas a notar. Para arcos elípticos (A) y para Béziers muy cerradas, la diferencia puede llegar a varios píxeles, y se manifiesta como un marcador que se separa del trazo dibujado justo en las curvas más cerradas. Cuando necesites correspondencia exacta entre lo que se dibuja y lo que se mueve —un indicador de progreso sobre un arco, por ejemplo— no mezcles los dos mundos: o lo haces todo con offset-distance y colocas el trazo con stroke-dasharray calculado desde getTotalLength() del mismo elemento, o lo haces todo en JavaScript. Y si el trazado tiene arcos, convierte los A a Béziers cúbicas antes: es lo que hacen internamente casi todas las herramientas de exportación, y elimina la fuente de discrepancia de raíz.

⚔️ Reto práctico

Monta la escena completa: un <path> punteado que se dibuja de izquierda a derecha, un marcador que lo recorre girando con la tangente, y un <output> que muestre en texto las coordenadas del marcador leídas con getPointAtLength() sobre el mismo elemento. Comprueba que al editar el atributo d desde las DevTools las tres cosas cambian a la vez.