A light split hero for an integrations or platform page. On the right, ten drawn app icons circle a central logo on two dashed rings that turn in opposite directions, and the icons stay upright as they travel. Every couple of seconds one app lights up, a pulse runs along a spoke into the logo and a status pill names the app that just synced. On phones the orbit sits under the text at a smaller size.
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
type App = { name: string; tile: string; glyph: string };
/** Each app: its name, tile colour and a 24x24 stroke path for the glyph. */
const outer: App[] = [
{ name: "Calendar", tile: "bg-rose-500 shadow-rose-500/40", glyph: "M5 7h14v12H5zM5 11h14M9 4v4M15 4v4" },
{ name: "Inbox", tile: "bg-sky-500 shadow-sky-500/40", glyph: "M4 7h16v11H4zM4 8l8 6 8-6" },
{ name: "Files", tile: "bg-amber-500 shadow-amber-500/40", glyph: "M4 7h6l2 2h8v9H4z" },
{ name: "Payments", tile: "bg-emerald-500 shadow-emerald-500/40", glyph: "M4 7h16v10H4zM4 11h16M7 14h3" },
{ name: "Video calls", tile: "bg-indigo-500 shadow-indigo-500/40", glyph: "M4 8h11v8H4zM15 11l5-3v8l-5-3" },
{ name: "Reports", tile: "bg-teal-500 shadow-teal-500/40", glyph: "M5 19V5M5 19h14M9 16v-5M13 16V8M17 16v-3" },
];
const inner: App[] = [
{ name: "Messages", tile: "bg-fuchsia-500 shadow-fuchsia-500/40", glyph: "M5 6h14v9h-7l-4 3v-3H5z" },
{ name: "Tasks", tile: "bg-orange-500 shadow-orange-500/40", glyph: "M5 5h14v14H5zM8.5 12l2.5 2.5 4.5-5" },
{ name: "Contacts", tile: "bg-cyan-600 shadow-cyan-600/40", glyph: "M12 11a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 19c0-3 2.7-5 6-5s6 2 6 5" },
{ name: "Storage", tile: "bg-slate-800 shadow-slate-800/40", glyph: "M7 18h10a4 4 0 0 0 .6-7.9A6 6 0 0 0 6.2 11 3.5 3.5 0 0 0 7 18z" },
];
const rings = [
{ apps: outer, radius: 46, seconds: 70, reverse: false },
{ apps: inner, radius: 27, seconds: 46, reverse: true },
];
const all = [...outer, ...inner];
export default function HeroWithOrbitingAppIcons() {
const reduce = !!useReducedMotion();
const ref = useRef<HTMLElement>(null);
const [onScreen, setOnScreen] = useState(true);
const [active, setActive] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver(([e]) => setOnScreen(e.isIntersecting), { threshold: 0.05 });
io.observe(el);
return () => io.disconnect();
}, []);
// Every 1.8 seconds another app "syncs": its tile lights up and a pulse runs to the centre.
useEffect(() => {
if (reduce || !onScreen) return;
const id = setInterval(() => {
if (!document.hidden) setActive((a) => (a + 3) % all.length);
}, 1800);
return () => clearInterval(id);
}, [reduce, onScreen]);
const ring = "focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-600 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50";
return (
<section ref={ref} data-paused={!onScreen} className="hoa-root relative overflow-hidden bg-slate-50 px-6 py-16 text-slate-900 sm:py-24">
<style>{`
@keyframes hero-with-orbiting-app-icons-turn { to { transform: rotate(360deg); } }
@keyframes hero-with-orbiting-app-icons-pulse { 0% { left: 100%; opacity: 0; } 15% { opacity: 1; } 85% { opacity: 1; } 100% { left: 14%; opacity: 0; } }
@keyframes hero-with-orbiting-app-icons-wave { 0% { transform: scale(1); opacity: 0.5; } 100% { transform: scale(2.6); opacity: 0; } }
.hoa-turn { animation: hero-with-orbiting-app-icons-turn linear infinite; }
.hoa-pulse { animation: hero-with-orbiting-app-icons-pulse 1.1s cubic-bezier(0.5, 0, 0.3, 1) both; }
.hoa-wave { animation: hero-with-orbiting-app-icons-wave 3.6s ease-out infinite; }
.hoa-root[data-paused="true"] * { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) { .hoa-turn, .hoa-pulse, .hoa-wave { animation: none; } .hoa-pulse { display: none; } }
`}</style>
<div className="relative mx-auto grid max-w-6xl items-center gap-14 lg:grid-cols-2 lg:gap-8">
<div>
<motion.div initial={reduce ? false : { opacity: 0, y: 24 }} animate={{ opacity: 1, y: 0 }} transition={{ type: "spring", stiffness: 80, damping: 16 }}>
<p className="text-sm font-semibold text-violet-700">140 integrations, one sign-in</p>
<h1 className="mt-4 text-4xl font-semibold tracking-tight sm:text-6xl">Every tool your team uses, finally talking to each other.</h1>
<p className="mt-6 max-w-lg text-lg leading-relaxed text-slate-600">
Junction sits in the middle of your calendar, inbox, files and payments. Change something in one place and the rest update on their own, with no copy and paste.
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
<a href="#" className={`inline-flex items-center justify-center rounded-2xl bg-violet-600 px-6 py-3.5 text-sm font-semibold text-white shadow-lg shadow-violet-600/30 transition hover:bg-violet-700 ${ring}`}>
Connect your first app
</a>
<a href="#" className={`inline-flex items-center justify-center rounded-2xl border border-slate-300 bg-white px-6 py-3.5 text-sm font-semibold text-slate-900 transition hover:border-slate-400 ${ring}`}>
Browse integrations
</a>
</div>
</motion.div>
</div>
<div className="relative mx-auto mb-14 aspect-square w-full max-w-xs sm:max-w-lg lg:mb-10" role="img" aria-label={`A logo in the centre with ${all.length} app icons circling it: ${all.map((a) => a.name).join(", ")}.`}>
<div aria-hidden="true" className="absolute inset-[12%] rounded-full bg-violet-300/40 blur-3xl" />
{rings.map((r, ri) => {
const offset = ri === 0 ? 0 : outer.length;
const spin = { animationDuration: `${r.seconds}s`, animationDirection: r.reverse ? "reverse" : "normal" } as const;
const unspin = { animationDuration: `${r.seconds}s`, animationDirection: r.reverse ? "normal" : "reverse" } as const;
return (
<div key={ri} aria-hidden="true" className="hoa-turn absolute inset-0" style={spin}>
<motion.div
className="absolute rounded-full border border-dashed border-slate-300"
style={{ inset: `${50 - r.radius}%` }}
initial={reduce ? false : { opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", stiffness: 60, damping: 14, delay: 0.1 + ri * 0.12 }}
/>
{r.apps.map((app, i) => {
const deg = (360 / r.apps.length) * i + ri * 30;
const rad = (deg * Math.PI) / 180;
const isActive = active === offset + i;
return (
<div key={app.name}>
{/* A spoke from the centre to this tile. It lives inside the turning ring, so it stays aimed at the tile. */}
{isActive && !reduce && (
<span className="absolute left-1/2 top-1/2 h-px origin-left bg-gradient-to-r from-transparent to-violet-400/60" style={{ width: `${r.radius}%`, transform: `rotate(${deg}deg)` }}>
<span className="hoa-pulse absolute -top-1 h-2 w-2 rounded-full bg-violet-500 shadow-[0_0_12px_3px_rgba(139,92,246,0.7)]" />
</span>
)}
<div className="absolute h-0 w-0" style={{ left: `${50 + r.radius * Math.cos(rad)}%`, top: `${50 + r.radius * Math.sin(rad)}%` }}>
<div className="hoa-turn" style={unspin}>
<motion.div
className="-ml-6 -mt-6 h-12 w-12 sm:-ml-7 sm:-mt-7 sm:h-14 sm:w-14"
initial={reduce ? false : { opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", stiffness: 200, damping: 13, delay: 0.25 + (offset + i) * 0.06 }}
>
<motion.div
className={`flex h-full w-full items-center justify-center rounded-2xl text-white shadow-lg ring-offset-2 ring-offset-slate-50 ${app.tile} ${isActive ? "ring-2 ring-violet-500" : ""}`}
animate={{ scale: isActive ? 1.18 : 1 }}
transition={{ type: "spring", stiffness: 260, damping: 14 }}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className="h-6 w-6 sm:h-7 sm:w-7">
<path d={app.glyph} />
</svg>
</motion.div>
</motion.div>
</div>
</div>
</div>
);
})}
</div>
);
})}
{/* The centre mark, with slow waves leaving it. */}
<div aria-hidden="true" className="absolute left-1/2 top-1/2 h-20 w-20 -translate-x-1/2 -translate-y-1/2 sm:h-24 sm:w-24">
<span className="hoa-wave absolute inset-0 rounded-3xl border border-violet-400" />
<span className="hoa-wave absolute inset-0 rounded-3xl border border-violet-400 [animation-delay:-1.8s]" />
<motion.div
className="relative flex h-full w-full items-center justify-center rounded-3xl bg-gradient-to-br from-violet-500 to-indigo-700 shadow-2xl shadow-violet-600/50"
initial={reduce ? false : { scale: 0, rotate: -90 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 140, damping: 12 }}
>
<svg viewBox="0 0 48 48" fill="none" stroke="white" strokeWidth="4" strokeLinecap="round" className="h-11 w-11 sm:h-14 sm:w-14">
<path d="M24 8v12M24 28v12M8 24h12M28 24h12" />
<circle cx="24" cy="24" r="4" fill="white" stroke="none" />
</svg>
</motion.div>
</div>
<div aria-hidden="true" className="absolute inset-x-0 -bottom-14 flex justify-center">
<AnimatePresence mode="wait" initial={false}>
<motion.p
key={active}
initial={reduce ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.25 }}
className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3.5 py-1.5 text-sm font-medium text-slate-700 shadow-sm"
>
<span className="h-2 w-2 rounded-full bg-emerald-500" />
{all[active].name} synced just now
</motion.p>
</AnimatePresence>
</div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/hero-with-orbiting-app-icons.json
The ring is one element that spins with a CSS keyframe. Each icon is wrapped in a second element that runs the same keyframe for the same duration in the opposite direction, so the two rotations cancel out and the tile never tilts.
Each icon's angle is 360 divided by the number of icons in its ring. The left and top positions are worked out with cosine and sine as percentages, so the orbit scales with its container and needs no fixed pixel sizes.
The spoke is drawn inside the rotating ring, at the same angle as its icon. Since both turn together, a simple straight line from the centre always points at the tile, and the dot only has to slide along it.
Yes. Delete one object from the `rings` array and move its apps into the other list. Six to eight icons is the most that fits comfortably on one ring at phone width.
No. The rings are two CSS rotations, which the browser runs off the main thread, and they are paused when the section leaves the screen.
Related: Hero with Word Reveal Headline and Floating Cards, Split Hero with Self-Playing Chat Demo, Logo Cloud with Drawn Wordmarks.
More hero sections or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.