Skip to content
Buttons

Buttons

CircleCTA

Circular link whose outline draws itself around the label on hover.

npx bezel-add add circle-cta

About

A circular call-to-action link for React whose outline draws itself around the label on hover, animated with GSAP stroke dash offsets, and undraws when the pointer leaves. It is a common pattern on portfolio and agency sites for "view more" or "see project" links. Line breaks in the label stack the text, and the size and stroke colour are configurable.

Key props: href (link target), label (text, use \n for line breaks), size (diameter in rem, default 10), strokeColor (colour of the drawn circle)

packages/ui/src/buttons/CircleCTA.tsx · 77 lines

"use client";
import { useEffect, useRef } from "react";
import gsap from "gsap";

type Props = {
  href: string;
  label?: string;
  size?: number;
  strokeColor?: string;
};

export function CircleCTA({ href, label = "view\nmore", size = 10, strokeColor = "#fff" }: Props) {
  const ctaRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = ctaRef.current;
    if (!el) return;
    const circle = el.querySelector<SVGCircleElement>(".yui-circle-draw");
    if (!circle) return;

    const r = parseFloat(circle.getAttribute("r") || "48");
    const circumference = 2 * Math.PI * r;

    gsap.set(circle, {
      strokeDasharray: circumference,
      strokeDashoffset: circumference,
    });

    const onEnter = () =>
      gsap.to(circle, { strokeDashoffset: 0, duration: 0.6, ease: "power3.out" });
    const onLeave = () =>
      gsap.to(circle, { strokeDashoffset: circumference, duration: 0.5, ease: "power3.in" });

    el.addEventListener("mouseenter", onEnter);
    el.addEventListener("mouseleave", onLeave);
    return () => {
      el.removeEventListener("mouseenter", onEnter);
      el.removeEventListener("mouseleave", onLeave);
    };
  }, []);

  const lines = label.split("\n");

  return (
    <>
      <style>{`
        .yui-circle-cta { position: relative; display: inline-block; }
        .yui-circle-cta a { display: flex; align-items: center; justify-content: center; position: relative; width: 100%; height: 100%; }
        .yui-circle-cta a:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
        .yui-circle-cta svg { position: absolute; top: 0; left: 0; width: 100%; height: 100%; transform: rotate(-90deg); }
        .yui-circle-draw { fill: none; stroke-width: 1; }
        .yui-circle-label {
          position: relative; z-index: 1; text-align: center; line-height: 1.2;
          font-size: 0.85rem; font-weight: 500; letter-spacing: 0.04em; text-transform: uppercase;
          transition: opacity 0.3s;
        }
        .yui-circle-cta:hover .yui-circle-label { opacity: 0.7; }
      `}</style>
      <div
        ref={ctaRef}
        className="yui-circle-cta"
        style={{ width: `${size}rem`, height: `${size}rem` }}
      >
        <a href={href}>
          <svg viewBox="0 0 100 100">
            <circle className="yui-circle-draw" cx="50" cy="50" r="48" stroke={strokeColor} />
          </svg>
          <div className="yui-circle-label">
            {lines.map((line, i) => (
              <span key={i} style={{ display: "block" }}>{line}</span>
            ))}
          </div>
        </a>
      </div>
    </>
  );
}