Components
Orbit animation in CSS: integration icons circling a logo
Build an integration orbit: icons circling a center logo on two rings at different speeds, kept upright, paused on hover. Live demo, CSS, React and timing.
The integration orbit is a staple of SaaS landing pages: your logo in the middle, the tools you connect to circling around it on one or two rings. It says "works with everything" without a wall of logos, and because it moves slowly it draws attention without competing with the headline.
It looks like it needs JavaScript or a canvas, but it is two CSS keyframe animations and a bit of trigonometry done by transform. This guide builds the orbit with two rings at different speeds, icons that stay upright, pause on hover and a pause button, and a React version.
How the animation is put together
There are only two animations in the whole component, and they are the same keyframes run in opposite directions.
| Part | Property | Duration | Delay | Easing |
|---|---|---|---|---|
| Inner ring | transform: rotate(0 → 360deg) | 18 s, infinite | 0 | linear |
| Inner icons | rotate(360deg → 0) (reverse) | 18 s, infinite | 0 | linear |
| Outer ring | rotate(360deg → 0) (reverse) | 32 s, infinite | 0 | linear |
| Outer icons | rotate(0 → 360deg) | 32 s, infinite | 0 | linear |
| Pause | animation-play-state | instant | 0 | none |
The icons are not animated along a path. Each one is placed on its ring once, with a static transform, and then the whole ring turns. The icon inside spins the other way at the same rate, so the two rotations cancel and the icon stays level as it travels.
The HTML
<div class="orb">
<div class="orb-ring" style="--r:72px; --d:18s">
<span class="orb-item" style="--a:0deg"><span class="orb-icon"><svg>…</svg></span></span>
<span class="orb-item" style="--a:120deg"><span class="orb-icon"><svg>…</svg></span></span>
<span class="orb-item" style="--a:240deg"><span class="orb-icon"><svg>…</svg></span></span>
</div>
<div class="orb-ring" style="--r:124px; --d:32s; --dir:reverse; --rdir:normal">
<span class="orb-item" style="--a:30deg"><span class="orb-icon"><svg>…</svg></span></span>
<span class="orb-item" style="--a:120deg"><span class="orb-icon"><svg>…</svg></span></span>
<span class="orb-item" style="--a:210deg"><span class="orb-icon"><svg>…</svg></span></span>
<span class="orb-item" style="--a:300deg"><span class="orb-icon"><svg>…</svg></span></span>
</div>
<div class="orb-core" aria-hidden="true">CM</div>
</div>
<button class="orb-toggle" type="button" aria-pressed="false">Pause</button>
Each ring carries its own radius (--r), duration (--d) and direction. Each item carries its starting angle (--a). To space n icons evenly, use 360 / n degrees; offsetting the outer ring by 30 degrees keeps its icons from lining up with the inner ones. The SVGs are shortened here; the demo uses simple 24 × 24 stroke icons, and real logos work the same way.
The CSS
.orb { position: relative; width: 280px; height: 280px; }
.orb-ring {
position: absolute; top: 50%; left: 50%;
width: calc(var(--r) * 2); height: calc(var(--r) * 2);
margin: calc(var(--r) * -1) 0 0 calc(var(--r) * -1);
border: 1px dashed rgba(255, 255, 255, .14); border-radius: 50%;
animation: orb-spin var(--d) linear infinite var(--dir, normal);
}
/* place the icon on the ring: turn, push out, turn back */
.orb-item {
position: absolute; top: 50%; left: 50%;
width: 40px; height: 40px; margin: -20px 0 0 -20px;
transform: rotate(var(--a)) translateX(var(--r)) rotate(calc(-1 * var(--a)));
}
/* spin the other way at the same speed, so it stays upright */
.orb-icon {
display: grid; place-items: center; width: 100%; height: 100%;
border-radius: 12px; background: #1c1f26; color: #e4e4e7;
animation: orb-spin var(--d) linear infinite var(--rdir, reverse);
}
.orb-core {
position: absolute; top: 50%; left: 50%;
width: 64px; height: 64px; margin: -32px 0 0 -32px;
display: grid; place-items: center; border-radius: 18px;
background: #fb9f3d; color: #140a00; font: 700 20px Inter, sans-serif;
}
@keyframes orb-spin { to { transform: rotate(360deg); } }
.orb:hover .orb-ring, .orb:hover .orb-icon,
.orb.is-paused .orb-ring, .orb.is-paused .orb-icon { animation-play-state: paused; }
@media (prefers-reduced-motion: reduce) {
.orb-ring, .orb-icon { animation: none; }
.orb-toggle { display: none; }
}
The placement transform reads right to left: rotate the item's own axis by --a, push it out along that axis by the radius, then rotate back by --a so its starting orientation is level. Without the last rotate() the icons would start tilted at their angle.
The --d variable is set on the ring and inherited by the icons, so the ring and its icons can never drift out of sync. That matters: if the counter-rotation is even slightly slower than the ring, the icons start to tilt and keep tilting more with every loop. Pausing has to target both the ring and the icons for the same reason.
The JavaScript
const orb = document.querySelector('.orb');
const btn = document.querySelector('.orb-toggle');
btn.addEventListener('click', () => {
const paused = orb.classList.toggle('is-paused');
btn.setAttribute('aria-pressed', String(paused));
btn.textContent = paused ? 'Play' : 'Pause';
});
That is all the script does. The motion itself is pure CSS, so it keeps running smoothly even while the main thread is busy, since rotations run on the compositor.
The React version
import { useState } from 'react';
const rings = [
{ r: 72, d: 18, icons: [{ a: 0, el: <MailIcon /> }, { a: 120, el: <ChatIcon /> }, { a: 240, el: <CalendarIcon /> }] },
{ r: 124, d: 32, reverse: true, icons: [
{ a: 30, el: <DbIcon /> }, { a: 120, el: <CloudIcon /> },
{ a: 210, el: <CodeIcon /> }, { a: 300, el: <BoltIcon /> }] },
];
export function Orbit({ logo = 'CM' }) {
const [paused, setPaused] = useState(false);
return (
<>
<div className={'orb' + (paused ? ' is-paused' : '')}>
{rings.map((ring, i) => (
<div key={i} className="orb-ring" style={{
'--r': ring.r + 'px', '--d': ring.d + 's',
'--dir': ring.reverse ? 'reverse' : 'normal',
'--rdir': ring.reverse ? 'normal' : 'reverse' }}>
{ring.icons.map(icon => (
<span key={icon.a} className="orb-item" style={{ '--a': icon.a + 'deg' }}>
<span className="orb-icon">{icon.el}</span>
</span>
))}
</div>
))}
<div className="orb-core" aria-hidden="true">{logo}</div>
</div>
<button className="orb-toggle" type="button" aria-pressed={paused}
onClick={() => setPaused(p => !p)}>{paused ? 'Play' : 'Pause'}</button>
</>
);
}
The icon components are whatever you already use (inline SVGs, an icon library, or img tags with logos). The CSS is the same file as above. If you prefer to drive the rotation from Framer Motion, animate rotate: 360 on the ring and rotate: -360 on the icons with { duration: 18, ease: 'linear', repeat: Infinity }; the keyframes to Framer Motion guide shows how the two map.
Timing that feels right
- Inner ring: 15 to 25 s per turn. At 18 s and a 72 px radius, an icon moves about 25 px per second. Faster than roughly 40 px per second and the orbit starts to pull focus from the headline.
- Outer ring: 1.6 to 2 times slower, opposite direction. 32 s against 18 s gives a similar on-screen speed on the larger circle, and the opposite direction keeps the two rings from looking like one rotating disc.
- Easing: always
linear.easeor anycubic-bezier()slows down at the end of each turn and speeds up at the start, which shows as a hitch every loop. For a deliberate pulse you could use thelinear()function with a few stops, but for orbits the plain keyword is right. - Pause: instant.
animation-play-statestops where it is and resumes from the same angle, which is what people expect. Do not reset the animation on pause. - Optional entrance: 600 ms. If the orbit fades in on load, use
opacityandscale(.9 → 1)withcubic-bezier(.2, .8, .2, 1)on the container, not on the rings, so it does not interfere with the rotation.
Accessibility notes
- Continuous motion that starts by itself and runs longer than five seconds needs a way to pause it (WCAG 2.2.2). Hover covers mouse users; the button covers keyboard, touch and screen reader users. Its
aria-pressedstate announces whether the orbit is paused. - Under
prefers-reduced-motion: reducethe rings stop and the icons sit at their starting angles, so the layout still shows every integration. The pause button is hidden because there is nothing to pause. - The orbit is decorative. Put the list of integrations in real text nearby, or give the icons accessible names, so the information does not depend on reading moving logos.
- Avoid putting links on orbiting icons. Moving targets are hard to hit; link from a static list instead.
Copy one you already like
Integration orbits vary more than they look: some use different radii per icon, some fade icons at the back, some are driven by GSAP rather than CSS. If you have seen one you want to match, open the page, start the Capture Motion extension and select the orbit. It records the keyframes, duration, delay and easing of each moving element, whether it runs on CSS, the Web Animations API or GSAP, and exports HTML and CSS, React, GSAP, JSON, or a Figma Motion timeline through the Figma plugin. For a starting point you can customize, browse the components in the Capture Motion gallery.
Questions
How do I keep the icons upright while they orbit?
Rotate the ring, then run the same rotation in reverse on each icon, with the same duration and linear timing. The two rotations cancel out, so the icon travels around the circle without turning.
Why use linear easing for an orbit?
Any other curve speeds up and slows down once per loop, which shows as a visible hitch at the point where the animation restarts. Linear keeps a constant angular speed, so the loop has no seam.
Does a continuous orbit need a pause control?
Yes. Motion that starts on its own and runs longer than five seconds needs a way to stop it under WCAG 2.2.2. Pause on hover is not enough for keyboard and touch users, so add a button.
Can I copy an orbit animation from another website?
Yes. The Capture Motion Chrome extension records the keyframes, duration and easing of each rotating element, including GSAP and Web Animations API orbits, and exports them as CSS, React or JSON.
