Motion and effects
Logo orbit
Links circling a logo, each spinning on its own axis. Pauses on hover and focus so they are easy to hit, and the link under the pointer buzzes.
Code 1 file
src/gallery/logo-orbit/LogoOrbit.astroAstro114 lines
---
// A logo with links circling it, each spinning on its own axis as the ring turns.
// Pauses on hover and focus so the links are easy to hit; the link under the
// pointer gives a short buzz.
// Placeholder mark and links: swap in the real logo and profiles.
interface Link { name: string; url: string; icon: string }
interface Props { links?: Link[] }
// 24×24 outline paths.
const icons = {
mail: 'M3 6h18v12H3z M3 7l9 6 9-6',
phone: 'M6 3h4l1.5 5-2.5 1.5a11 11 0 0 0 5.5 5.5L16 12.5l5 1.5v4a2 2 0 0 1-2 2A16 16 0 0 1 4 5a2 2 0 0 1 2-2z',
pin: 'M12 21s-7-6.2-7-12a7 7 0 0 1 14 0c0 5.8-7 12-7 12z M12 11.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5z',
globe: 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18z M3 12h18 M12 3c2.5 2.6 3.8 5.6 3.8 9s-1.3 6.4-3.8 9c-2.5-2.6-3.8-5.6-3.8-9S9.5 5.6 12 3z',
};
const {
links = [
{ name: 'Email', url: '#', icon: icons.mail },
{ name: 'Phone', url: '#', icon: icons.phone },
{ name: 'Find us', url: '#', icon: icons.pin },
{ name: 'Website', url: '#', icon: icons.globe },
],
} = Astro.props;
---
<div class="stage" data-orbit>
<div class="logo">
<svg viewBox="0 0 100 100" role="img" aria-label="Placeholder logo">
<circle cx="50" cy="50" r="48" fill="#141414" />
<circle cx="50" cy="50" r="44" fill="none" stroke="#c9a227" stroke-width="2" />
<text x="50" y="59" text-anchor="middle" fill="#c9a227" font-size="26" font-weight="500" letter-spacing="1">LOGO</text>
</svg>
</div>
<ul class="ring-track">
{links.map((link) => (
<li class="spot">
<div class="spin">
<a href={link.url} aria-label={link.name} title={link.name} class="social-link">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d={link.icon} /></svg>
</a>
</div>
</li>
))}
</ul>
</div>
<style>
.stage {
--orbit-ease: cubic-bezier(0.23, 1, 0.32, 1);
position: absolute; left: 50%; top: 50%;
height: 88%; max-width: 88%; aspect-ratio: 1;
translate: -50% -50%;
container-type: inline-size;
}
.logo {
position: absolute; top: 50%; left: 50%;
width: 52%;
translate: -50% -50%;
}
.logo svg { display: block; width: 100%; height: auto; }
.ring-track {
position: absolute; inset: 0; margin: 0; padding: 0; list-style: none;
animation: orbit 48s linear infinite;
}
.spot {
--angle: 45deg;
position: absolute; top: 50%; left: 50%;
width: 2.75rem; height: 2.75rem;
margin: -1.375rem 0 0 -1.375rem;
transform: rotate(var(--angle)) translateY(-42cqw) rotate(calc(var(--angle) * -1));
}
/* Evenly spaced for four links. */
.spot:nth-child(2) { --angle: 135deg; }
.spot:nth-child(3) { --angle: 225deg; }
.spot:nth-child(4) { --angle: 315deg; }
/* Each link turns on its own axis, faster than the ring. */
.spin { animation: orbit 9s linear infinite; }
.stage:hover .ring-track,
.stage:hover .spin,
.stage:focus-within .ring-track,
.stage:focus-within .spin { animation-play-state: paused; }
.social-link {
display: grid; place-items: center; width: 2.75rem; height: 2.75rem; border-radius: 50%;
background: #1a1a1a; color: #c9a227;
transition: background-color 160ms ease, color 160ms ease, transform 160ms var(--orbit-ease);
}
.social-link:focus-visible { animation: buzz 360ms ease-in-out; }
@media (hover: hover) and (pointer: fine) {
.social-link:hover { background: #c9a227; color: #0a0a0a; animation: buzz 360ms ease-in-out; }
}
.social-link:active { transform: scale(0.94); }
.social-link svg { width: 1.25rem; height: 1.25rem; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
@keyframes orbit { to { rotate: 360deg; } }
/* Uses rotate and translate, so the transform scale on press still works. */
@keyframes buzz {
20% { rotate: -9deg; translate: -1.5px 0; }
40% { rotate: 8deg; translate: 1.5px 0; }
60% { rotate: -5deg; translate: -1px 0; }
80% { rotate: 3deg; translate: 0.5px 0; }
}
@media (prefers-reduced-motion: reduce) {
.ring-track, .spin, .social-link:hover, .social-link:focus-visible { animation: none; }
}
</style>
<script>
document.querySelectorAll<HTMLElement>('[data-orbit]').forEach((stage) => {
// Placeholder links go nowhere; stop them jumping the page to the top.
stage.querySelectorAll<HTMLAnchorElement>('a[href="#"]').forEach((a) => a.addEventListener('click', (e) => e.preventDefault()));
});
</script>