Orbit button
Glowing dots orbit a pill CTA, gather around its arrow on hover and burst outward when pressed.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
const DOTS = 3;
const LAP = 7; // seconds for one idle lap of the border
// A point on the pill border at fraction u (0 to 1), clockwise from the top left end of the top edge,
// plus the outward normal at that point. The pill is two straight edges joined by two half circles.
function onPill(w: number, h: number, u: number) {
const r = h / 2, flat = w - h, arc = Math.PI * r;
let s = (((u % 1) + 1) % 1) * (2 * flat + 2 * arc);
if (s < flat) return { x: r + s, y: 0, nx: 0, ny: -1 };
s -= flat;
if (s < arc) { const a = -Math.PI / 2 + s / r; return { x: w - r + r * Math.cos(a), y: r + r * Math.sin(a), nx: Math.cos(a), ny: Math.sin(a) }; }
s -= arc;
if (s < flat) return { x: w - r - s, y: h, nx: 0, ny: 1 };
s -= flat;
const a = Math.PI / 2 + s / r;
return { x: r + r * Math.cos(a), y: r + r * Math.sin(a), nx: Math.cos(a), ny: Math.sin(a) };
}
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const button = useRef<HTMLButtonElement>(null);
const dots = useRef<(HTMLSpanElement | null)[]>([]);
const active = useRef(false); // hovered or focused
const burst = useRef(0);
const inView = useInView(root);
const reduced = usePrefersReducedMotion();
const [sent, setSent] = useState(false);
useEffect(() => {
if (!sent) return;
const timer = setTimeout(() => setSent(false), 2600);
return () => clearTimeout(timer);
}, [sent]);
useEffect(() => {
// Read once here: a frame can still run after unmount.
const btn = button.current;
const els = dots.current.slice(0, DOTS);
if (!btn || els.some((el) => !el)) return;
const u = els.map((_, i) => i / DOTS + 0.08);
let w = btn.offsetWidth, h = btn.offsetHeight;
const draw = () => els.forEach((el, i) => {
const p = onPill(w, h, u[i]);
const push = burst.current * 18;
el!.style.transform = `translate(${p.x + p.nx * push}px, ${p.y + p.ny * push}px) scale(${1 + burst.current * 0.8})`;
el!.style.opacity = String(1 - burst.current * 0.5);
});
const observer = new ResizeObserver(() => { w = btn.offsetWidth; h = btn.offsetHeight; draw(); });
observer.observe(btn);
draw();
if (reduced || !inView) return () => observer.disconnect();
let raf = 0, last = performance.now(), phase = u[0], t = 0;
const frame = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
t += dt;
phase += dt / LAP;
const r = h / 2, flat = w - h, arc = Math.PI * r, length = 2 * flat + 2 * arc;
const arrow = (flat + arc / 2) / length; // the middle of the right hand cap, where the arrow sits
els.forEach((_, i) => {
// Idle: evenly spaced around the border. Active: a quick sweep back and forth around the arrow cap.
const target = active.current
? arrow + Math.sin(t * 7 + i * 1.3) * (arc / length) * 0.42
: phase + i / DOTS;
const diff = ((((target - u[i]) % 1) + 1.5) % 1) - 0.5;
u[i] += diff * (1 - Math.exp(-dt * (active.current ? 10 : 6)));
});
burst.current *= Math.exp(-dt * 7);
if (burst.current < 0.01) burst.current = 0;
draw();
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => { cancelAnimationFrame(raf); observer.disconnect(); };
}, [inView, reduced]);
const on = () => { active.current = true; };
const off = () => { active.current = false; };
return (
<div ref={root} className={styles.root}>
<div className={styles.stack}>
<p className={styles.title}>Close the books in hours, not days</p>
<span className={styles.wrap}>
<button
ref={button}
type="button"
className={styles.button}
onPointerEnter={on}
onPointerLeave={off}
onFocus={on}
onBlur={off}
onPointerDown={() => { if (!reduced) burst.current = 1; }}
onKeyDown={(e) => { if (!reduced && (e.key === "Enter" || e.key === " ")) burst.current = 1; }}
onClick={() => setSent(true)}
>
<span key={String(sent)} className={styles.label}>{sent ? "Check your inbox" : "Start free trial"}</span>
<span className={styles.arrow} aria-hidden="true">
<svg viewBox="0 0 16 16">
{sent ? <path d="M3.5 8.5l3 3 6-7" /> : <path d="M3 8h9.5M8.5 4l4 4-4 4" />}
</svg>
</span>
</button>
<span className={styles.dots} aria-hidden="true">
{Array.from({ length: DOTS }, (_, i) => (
<span key={i} ref={(el) => { dots.current[i] = el; }} className={styles.dot} />
))}
</span>
</span>
<p className={styles.note} aria-live="polite">{sent ? "We sent a sign in link to your work email." : "No card needed. Set up in 5 minutes."}</p>
</div>
</div>
);
}
demo.module.css
.root {
container-type: size; width: 100%; height: 100%; display: grid; place-items: center; padding: 16px;
box-sizing: border-box; background: var(--surface);
}
.stack { display: grid; justify-items: center; gap: 18px; text-align: center; max-width: 100%; }
.title { margin: 0; font-size: clamp(17px, 5cqi, 26px); font-weight: 700; letter-spacing: -0.02em; text-wrap: balance; }
.note { margin: 0; font-size: 13px; color: var(--muted); }
.wrap { position: relative; display: inline-block; }
.button {
position: relative; display: inline-flex; align-items: center; gap: 12px; min-height: 50px; padding: 0 8px 0 24px;
border: 0; border-radius: 999px; background: var(--ink); color: var(--white); font: inherit; font-size: 15px; font-weight: 650;
cursor: pointer; -webkit-tap-highlight-color: transparent; white-space: nowrap;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent), 0 12px 28px -14px var(--ink);
transition: transform .2s var(--out), box-shadow .3s var(--out);
}
.button:hover { box-shadow: 0 0 0 1px var(--accent), 0 14px 32px -14px var(--ink); }
.button:active { transform: scale(.97); }
.button:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 5px; }
.label { animation: swap .35s var(--out); }
@keyframes swap { from { opacity: 0; translate: 0 6px; } }
.arrow {
display: grid; place-items: center; width: 34px; height: 34px; border-radius: 50%; background: var(--white); color: var(--ink);
transition: transform .3s var(--out);
}
.button:hover .arrow, .button:focus-visible .arrow { transform: translateX(3px); }
.arrow svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
/* The dots layer sits exactly over the button; each dot is placed on the border by the frame loop. */
.dots { position: absolute; inset: 0; pointer-events: none; }
.dot {
position: absolute; left: -3px; top: -3px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent);
box-shadow: 0 0 6px 1px var(--accent), 0 0 16px 4px color-mix(in srgb, var(--accent) 40%, transparent);
will-change: transform;
}
@container (min-width: 600px) and (min-height: 380px) {
.button { min-height: 58px; padding-left: 30px; font-size: 17px; gap: 14px; }
.arrow { width: 42px; height: 42px; }
}
@container (max-height: 250px) {
.stack { gap: 14px; }
}
@media (prefers-reduced-motion: reduce) {
.label { animation: none; }
.button, .arrow { transition: none; }
}
Use it for
- The one primary CTA in a hero or pricing section
- Sign up or start trial buttons that should invite a click
- Dark or quiet layouts where a little light draws the eye
Avoid
- Several buttons on one screen, where orbits compete
- Secondary or destructive actions
How it works
Each dot holds a position as a fraction of the pill's perimeter, and a small function turns that fraction into x, y and an outward normal on the two straight edges and two half circles. A requestAnimationFrame loop eases every dot toward its target: evenly spaced and drifting while idle, sweeping back and forth around the arrow cap while hovered or focused. Pressing sets a burst value that pushes the dots along their normals and decays each frame. A ResizeObserver keeps the shape in step with the label.
Accessibility
It is a real button with a visible label, a focus ring and the same gather effect on keyboard focus. The result is announced through a polite live region. The loop pauses off screen, and reduced motion parks the dots on the border with no orbit or burst.