El sistema offset: mover un elemento sin tocar el layout
Qué es la transformación de desplazamiento que define el módulo Motion Path, cómo se relacionan offset-path y offset-distance, y por qué recorrer un trazado es una animación de una sola propiedad numérica.
Animar un elemento a lo largo de una curva parece un problema de geometría y durante años se resolvió como tal: se muestreaba la curva en JavaScript, se calculaba el punto correspondiente a cada instante y se escribía un translate() en el estilo inline cincuenta veces por segundo. El módulo Motion Path invierte el planteamiento. En vez de darte herramientas para calcular puntos, declara el trazado como parte del estilo del elemento y te deja una sola propiedad numérica que decir cuánto has avanzado por él. Toda la trigonometría desaparece del código de aplicación y se convierte en algo que el motor ya sabe hacer al construir la matriz de transformación.
- Explicar qué es la transformación de desplazamiento y qué la distingue de
transform. - Componer
offset-pathyoffset-distancepara colocar un elemento en un punto arbitrario de un trazado. - Animar un recorrido completo con
@keyframessin una línea de JavaScript. - Escribir el recorrido con la propiedad atajo
offsety saber en qué orden van sus partes.
Una transformación que el motor calcula por ti
La especificación llama a lo que construye este módulo offset transform, transformación de desplazamiento. Es una matriz que el navegador añade al elemento igual que añadiría la de transform, con todas sus consecuencias: crea contexto de apilamiento, no participa en el layout, no dispara reflow al cambiar y es candidata a vivir en el compositor. La diferencia está en cómo se especifica. Con transform tú das la matriz. Con el sistema offset das un trazado y una distancia recorrida, y el motor deriva la matriz.
Esa diferencia importa más de lo que parece. Un translate() es una coordenada rectangular: dos números independientes que tú tienes que mantener coherentes entre sí. Una posición sobre un trazado es un único escalar entre el principio y el final de una curva; la relación entre ese escalar y las dos coordenadas resultantes la mantiene el motor. Interpolar un escalar es trivialmente correcto: no hay forma de que la interpolación se salga de la curva. Interpolar dos coordenadas a lo largo de una curva no lineal es exactamente el error clásico de animar una parábola con dos transiciones independientes y obtener una recta.
El sistema completo son cinco propiedades longhand. offset-path define la geometría. offset-distance dice cuánto se ha avanzado por ella. offset-rotate controla si el elemento gira siguiendo la tangente. offset-anchor decide qué punto del elemento es el que se pega al trazado. offset-position fija el punto de partida cuando la forma lo necesita. Y offset es el atajo que las agrupa.
.satelite {
/* El trazado: una circunferencia de 120px de radio centrada
en el centro de la caja que establece el bloque contenedor. */
offset-path: circle(120px at 50% 50%);
/* La posicion sobre el trazado: 0% es el punto inicial de la forma. */
offset-distance: 0%;
/* El punto del elemento que se pega al trazado. */
offset-anchor: center;
}
Ese bloque no anima nada todavía: coloca el elemento en el punto inicial de la circunferencia. Fíjate en que no hay position: absolute, ni top, ni left. La transformación de desplazamiento se aplica sobre la posición que el layout ya le dio al elemento; es un desplazamiento visual, no una recolocación.
Las primeras versiones de la especificación llamaban a esta propiedad motion-path, y todavía encontrarás artículos y polyfills con ese nombre. Se renombró a offset-path justamente porque la propiedad describe posiciones estáticas, no movimiento: colocar algo en el 37% de una curva es un uso perfectamente legítimo y no tiene nada que ver con animar. El movimiento aparece solo cuando alguien anima offset-distance.
Una propiedad, una animación
Como offset-distance acepta un <length-percentage> y se interpola por tipo de valor computado, animarla es exactamente igual de aburrido que animar una opacidad. Todo lo que hace interesante al recorrido —la forma de la curva, el punto de partida, el sentido de giro— está declarado fuera de los keyframes.
@keyframes recorrer {
from { offset-distance: 0%; }
to { offset-distance: 100%; }
}
.cometa {
width: 24px;
height: 24px;
border-radius: 50%;
background: #f9e2af;
offset-path: path("M 20,120 C 20,20 220,20 220,120 S 420,220 420,120");
offset-rotate: auto;
animation: recorrer 4s linear infinite;
}
Este ejemplo funciona pegado tal cual dentro de cualquier contenedor con altura suficiente. Cuatro segundos de recorrido, lineal, en bucle, sobre una curva de Bézier cúbica encadenada. La única propiedad que cambia frame a frame es un porcentaje.
El valor 100% significa cosas ligeramente distintas según la forma. En un trazado cerrado —una circunferencia, un polígono, un path() que termina en z— el 100% es la vuelta completa y coincide visualmente con el 0%. En un trazado abierto el 100% es el extremo final y el elemento se queda ahí. Y offset-distance no está limitado al rango cero-cien: valores mayores que 100% o negativos se envuelven en trazados cerrados y se extrapolan a lo largo de la tangente en los abiertos, lo cual es una forma barata de encadenar varias vueltas sin tocar los keyframes.
/* Tres vueltas en el mismo tiempo, sin duplicar keyframes. */
@keyframes tres-vueltas {
from { offset-distance: 0%; }
to { offset-distance: 300%; }
}
Que la animación sea de una sola propiedad numérica tiene una consecuencia práctica: el easing que apliques actúa sobre el avance por el trazado, no sobre las coordenadas. Con ease-out el elemento no frena “en el eje x”: frena a lo largo de la curva, que es lo que la intuición espera y lo que resulta muy difícil de conseguir a mano.
El atajo offset y el orden de sus partes
La propiedad atajo existe porque el caso habitual mezcla cuatro longhands y escribirlas por separado se vuelve ruidoso. Su gramática tiene una parte pensada para confundir, así que conviene memorizarla:
/* posicion-inicial trazado distancia || rotacion / anclaje */
.nave {
offset: 10% 60% path("M0,0 L300,0") 25% auto / center;
}
La posición inicial va antes del trazado. La distancia y la rotación van después, en cualquier orden entre sí. Y el anclaje va detrás de una barra, porque es lo único que se refiere al elemento y no al recorrido. Lo importante del atajo no es la brevedad sino que, como todo atajo de CSS, resetea a su valor inicial todo lo que no menciones. Escribir offset: path(...) borra silenciosamente un offset-rotate que hubieras declarado antes en la cascada. En hojas de estilo grandes esto es una fuente de bugs desagradable; mi consejo es usar el atajo solo cuando declaras el recorrido entero de una vez y quedarte en las longhands en cualquier otro caso.
Valor de offset-path |
Qué describe | Trazado cerrado |
|---|---|---|
none |
Sin recorrido; solo actúan offset-position y offset-anchor |
— |
path("...") |
Datos de trazado SVG literales | Solo si termina en z |
ray(45deg) |
Semirrecta polar desde un punto | No |
circle(), ellipse(), polygon(), inset(), rect(), xywh() |
Contorno de una forma básica | Sí |
url("#id") |
Contorno de una forma SVG del documento | Depende de la forma |
border-box, padding-box, content-box, fill-box, stroke-box, view-box |
Borde de una caja de referencia | Sí |
Casi toda la documentación dice que las formas de offset-path se resuelven “contra el contenedor”. La especificación es más precisa y la diferencia te morderá: se resuelven contra el elemento que establece el bloque contenedor del elemento posicionado. Para un elemento en flujo normal ese es su padre en el bloque, pero para un position: absolute es el antepasado posicionado más cercano, que puede estar cinco niveles más arriba, y para un position: fixed es el viewport. Un circle(50%) que se veía perfecto se convierte en una órbita del tamaño de la pantalla en cuanto alguien quita el position: relative del padre. Cuando un recorrido tenga que ser estable, no uses porcentajes en la forma: usa longitudes absolutas o ray() con contain, que no dependen de a quién le toque ser bloque contenedor esa semana.
Qué gana y qué pierde frente a hacerlo en JavaScript
Lo que gana es el hilo. Como offset-distance produce una transformación y nada más, una animación de recorrido es candidata a ejecutarse fuera del hilo principal igual que lo sería un transform. Un bucle de requestAnimationFrame que escribe style.transform cada frame compite con tu propio JavaScript; una animación declarativa de offset-distance no. Con una pestaña haciendo trabajo pesado, la primera se atasca y la segunda sigue fluida.
Lo que pierde es introspección. Desde CSS no tienes acceso a las coordenadas del punto ni al ángulo de la tangente: sabes que el elemento está donde tiene que estar, pero no dónde es eso. Si necesitas ese dato —para dibujar una estela, para detectar colisiones, para colocar un segundo elemento a distancia constante del primero— tienes que salir al DOM y usar SVGGeometryElement.getPointAtLength() sobre un <path> equivalente, que es la API que existe justamente para esto y que da el punto en el sistema de coordenadas del SVG.
// El path debe existir en el documento; puede estar oculto.
const trazado = document.querySelector('#ruta');
const largo = trazado.getTotalLength();
// Punto al 37% del recorrido, en coordenadas del SVG.
const p = trazado.getPointAtLength(largo * 0.37);
console.log(p.x, p.y);
Mantener sincronizados un path() en CSS y un <path> en el DOM es duplicar la fuente de verdad, y por eso url() existe: permite que ambos apunten al mismo elemento. Es el tema de una lección posterior de este nivel.
Coloca cuatro puntos en una órbita elíptica con un solo bloque de reglas y sin @keyframes distintos para cada uno: usa el mismo offset-path y la misma animación en los cuatro, y desplázalos entre sí con animation-delay negativos calculados con calc() a partir de una variable --i de cada elemento. El truco de los delays negativos vale exactamente para esto: arrancar una animación ya empezada.