Everything after the first part circles the centre of the canvas, forever. One shared pivot, so here the canvas is the right reference box, unlike a part turning about itself.
Every animation on the page redraws with the shape you pick.
<!-- 1. Markup -->
<svg viewBox="0 0 120 120" class="k-orbit" aria-hidden="true">
<circle cx="60" cy="60" r="34" fill="none" stroke="currentColor" stroke-width="8" vector-effect="non-scaling-stroke" />
<line x1="60" y1="12" x2="60" y2="38" stroke="currentColor" stroke-width="8" stroke-linecap="round" vector-effect="non-scaling-stroke" />
<line x1="60" y1="82" x2="60" y2="108" stroke="currentColor" stroke-width="8" stroke-linecap="round" vector-effect="non-scaling-stroke" />
<circle cx="60" cy="60" r="10" fill="currentColor" />
</svg>
// 2. JS
function orbit(svg) {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return () => {}
const [, ...orbiters] = paths(svg)
const box = svg.viewBox.baseVal
// One pivot shared by every orbiter: the centre of the canvas. view-box is
// the right reference for that, and its origin is the user-space origin,
// which is why the centre is given in the drawing's own coordinates.
orbiters.forEach((part) => {
part.style.transformBox = 'view-box'
part.style.transformOrigin = `${box.x + box.width / 2}px ${box.y + box.height / 2}px`
})
const animations = orbiters.map((part) =>
part.animate([{ transform: 'rotate(0deg)' }, { transform: 'rotate(360deg)' }], {
duration: 6 * 1000,
iterations: Infinity,
easing: 'linear',
}),
)
return () => {
animations.forEach((a) => a.cancel())
orbiters.forEach((part) => {
part.style.transformBox = ''
part.style.transformOrigin = ''
})
}
}
function paths(svg) {
return [...svg.querySelectorAll('path, line, polyline, polygon, circle, ellipse, rect')].filter(
(el) => !el.closest('defs, clipPath, mask'),
)
}
orbit(document.querySelector('.k-orbit'))