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

path() y las formas básicas como trazado

Escribir un recorrido con datos de trazado SVG, usar circle, ellipse, polygon, inset, rect y xywh como caminos cerrados, y saber dónde empieza y en qué sentido avanza cada forma.

⏱ 20 min

offset-path acepta cualquier <basic-shape>, lo cual suena a detalle de gramática y en realidad es la decisión de diseño más útil del módulo: reutiliza un vocabulario de formas que ya existía para recortes y para flotados en vez de inventar uno nuevo. La consecuencia es que sabes escribir recorridos desde el primer día. La trampa está en que cada forma define su propio punto de partida y su propio sentido de avance, y esos dos datos no aparecen en ningún sitio del código que escribes; si no los conoces, el elemento empieza donde no esperabas y no hay propiedad que lo arregle.

🎯 Al terminar esta lección sabrás
  • Escribir un path() con los comandos de trazado que la especificación admite y saber cuáles no.
  • Predecir el punto inicial y el sentido de recorrido de cada forma básica.
  • Elegir entre inset(), rect() y xywh() para describir un rectángulo según el dato de partida.
  • Reconocer cuándo un trazado es cerrado y qué implica eso para offset-distance.

path(): los datos de trazado, con restricciones

path() toma una cadena con la misma sintaxis del atributo d de un <path> de SVG, opcionalmente precedida de una regla de relleno. Para un recorrido la regla de relleno es irrelevante —no se rellena nada— así que en la práctica siempre escribes la variante de un argumento.

.trazador {
  offset-path: path("M 10,80 C 40,10 65,10 95,80 S 150,150 180,80");
}

La restricción que sorprende a quien viene de SVG es que la cadena tiene que estar en coordenadas absolutas de usuario y no admite unidades. Los números son números; no puedes escribir M 10px,80px ni M 10%,80%. Eso significa que un path() describe un recorrido de tamaño fijo en píxeles CSS, y que un diseño responsive con un recorrido escrito así se rompe: la curva no se estira con el contenedor. Hay dos salidas honestas. Una es declarar el trazado en una variable personalizada y reescribirlo dentro de una @media, con lo que pasas de una curva a otra en el breakpoint. La otra es usar una forma paramétrica —circle(), ellipse(), polygon() con porcentajes— que sí escala. La tercera, url() a un SVG con viewBox, se ve en la lección de este nivel dedicada a ello.

Los comandos disponibles son los de SVG: M y m para mover el lápiz, L, H, V y sus minúsculas para rectas, C y S para Béziers cúbicas, Q y T para cuadráticas, A para arcos elípticos, y Z para cerrar. Las mayúsculas son absolutas y las minúsculas relativas al punto anterior. El punto inicial del recorrido es el primer M, y el sentido de avance es el orden en que escribiste los comandos: es la única forma en la que controlas la dirección de manera explícita.

/* El mismo triangulo, recorrido en sentidos opuestos. */
.horario      { offset-path: path("M 100,20 L 180,160 L 20,160 Z"); }
.antihorario  { offset-path: path("M 100,20 L 20,160 L 180,160 Z"); }

Un path() que termina en Z es un trazado cerrado: offset-distance: 100% cae exactamente sobre el punto inicial y los valores fuera del rango envuelven. Sin Z el trazado es abierto y el 100% es el último punto escrito, aunque coincida geométricamente con el primero. La diferencia es real: en un trazado abierto que vuelve a su origen, un offset-distance: 110% extrapola en línea recta hacia fuera en vez de continuar la vuelta.

⚠️
Un subtrazado que se salta el hueco

Una cadena con varios M define subtrazados. Como recorrido eso es legal, y el elemento va del final de un subtrazado al principio del siguiente en un salto instantáneo: el hueco entre ellos tiene longitud cero a efectos de offset-distance. Es útil si lo buscas —un recorrido troceado que salta— y desconcertante si el M de más se te coló al pegar un icono de Figma. Cuando un recorrido dé un tirón inexplicable, cuenta los M de la cadena.

Dónde empieza cada forma y hacia dónde va

Aquí está el conocimiento que ahorra media hora de prueba y error. Las formas básicas no llevan un punto inicial explícito; la especificación lo define para cada una, y salvo excepción el recorrido avanza en sentido horario.

Forma Punto de offset-distance: 0% Sentido
circle(r at p) Punto más a la derecha del círculo Horario
ellipse(rx ry at p) Punto más a la derecha de la elipse Horario
polygon(...) Primer par de coordenadas que escribes El orden en que los escribes
inset(), rect(), xywh() Esquina superior izquierda del rectángulo Horario
path("...") El primer comando M El orden de los comandos
ray(...) El punto de origen del rayo Alejándose del origen

Que circle() y ellipse() empiecen a la derecha y no arriba es el detalle que más veces se olvida. En coordenadas de pantalla, con la y creciendo hacia abajo, “horario desde la derecha” significa que el primer cuarto del recorrido baja. Si querías que una órbita empezara arriba, o giras la forma con at, o desplazas el arranque con un offset-distance inicial del 75%, o giras el contenedor. La forma más limpia suele ser la segunda:

.luna {
  offset-path: circle(140px at 50% 50%);
  /* 75% es el punto mas alto: tres cuartos de vuelta horaria
     desde el punto mas a la derecha. */
  animation: orbita 6s linear infinite;
}

@keyframes orbita {
  from { offset-distance: 75%; }
  to   { offset-distance: 175%; }
}

Ese bloque funciona porque offset-distance admite valores por encima del 100% y el trazado es cerrado: la vuelta completa sigue durando exactamente los seis segundos, solo que empieza y acaba arriba.

polygon() es la forma en la que tienes control total sin renunciar a los porcentajes. Cada vértice es un par <length-percentage> y el recorrido los visita en el orden escrito, cerrando del último al primero.

.rombo {
  /* Escala con el contenedor, a diferencia de path(). */
  offset-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%);
  offset-rotate: auto;
}

Tres maneras de decir rectángulo

inset(), rect() y xywh() describen todas un rectángulo y las tres empiezan en la esquina superior izquierda avanzando en sentido horario. Existen las tres porque los datos de partida son distintos y convertir a mano entre ellos es exactamente el tipo de aritmética que produce errores de un píxel.

inset() toma márgenes hacia dentro desde los cuatro lados de la caja de referencia, con el mismo orden arriba-derecha-abajo-izquierda de margin y las mismas reglas de omisión. Es lo que quieres cuando piensas en términos de “un rectángulo veinte píxeles por dentro del borde”.

rect() toma las posiciones de los cuatro lados medidas desde el borde superior e izquierdo, en el orden arriba-derecha-abajo-izquierda, y acepta auto en cualquiera de ellos para decir “hasta el borde”. Es la traducción directa de la vieja función rect() de clip.

xywh() toma origen y tamaño: x, y, ancho y alto. Es lo que sale de un getBoundingClientRect() y de casi cualquier herramienta de diseño.

/* Los tres describen el mismo rectangulo en una caja de 400x300. */
.a { offset-path: inset(20px 30px 40px 50px); }
.b { offset-path: rect(20px 370px 260px 50px); }
.c { offset-path: xywh(50px 20px 320px 240px); }

Las tres aceptan además un sufijo round con un radio, y ahí la elección deja de ser cosmética: inset(0 round 24px) es un rectángulo redondeado, y recorrerlo produce un movimiento con esquinas suaves que ninguna otra forma te da tan barato. De hecho es el valor por defecto: si escribes solo una caja de referencia en offset-path, sin forma, la especificación resuelve el trazado como inset(0 round X) donde X es el border-radius del elemento que establece el bloque contenedor.

.tarjeta { border-radius: 28px; position: relative; }

/* El punto recorre el borde de la tarjeta respetando sus esquinas. */
.tarjeta .brillo {
  position: absolute;
  offset-path: border-box;
  offset-rotate: auto;
  animation: recorrer 3s linear infinite;
}

@keyframes recorrer {
  from { offset-distance: 0%; }
  to   { offset-distance: 100%; }
}
Los porcentajes de offset-distance no son porcentajes de recta

offset-distance: 25% significa “un cuarto de la longitud de arco”, no “un cuarto de los vértices” ni “un cuarto del ancho”. En un polygon() con lados desiguales, el 25% no cae en el segundo vértice: cae donde llevas recorrida la cuarta parte del perímetro. Y en una Bézier con curvatura fuerte, la longitud de arco no es proporcional al parámetro de la curva: el navegador la calcula por aproximación numérica, igual que hace getTotalLength(). Esto tiene una consecuencia que rompe intuiciones: un movimiento lineal en offset-distance es un movimiento de velocidad constante sobre la curva, que es justo lo que un animador quiere y justo lo que no obtienes si interpolas el parámetro de la Bézier a mano. Casi todos los recorridos escritos con requestAnimationFrame y getPointAtLength(t) con t lineal en el parámetro se aceleran en las rectas y frenan en las curvas por no haber reparametrizado por longitud de arco. El motor te lo da gratis.

⚔️ Reto práctico

Coge un polygon() de cinco vértices claramente desiguales y comprueba empíricamente lo del párrafo anterior: coloca cinco marcas con offset-distance en 0%, 20%, 40%, 60% y 80% y verifica que ninguna cae sobre un vértice salvo la primera. Después reescribe el mismo pentágono como path() con comandos L y comprueba que el resultado es idéntico.