Design gallery
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>