Swish UI
All componentsButtons and controls Free

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.

Found on