"use client";
import * as React from "react";
export type CircularTextDirection = "clockwise" | "counterclockwise";
export type CircularTextHover = "none" | "pause" | "accelerate";
export interface CircularTextProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
/** Text set around the circle (repeated to fill it). */
text?: string;
/** Inserted between repetitions; drawn in the accent colour. */
separator?: string;
/** Times the text repeats around the circle. Estimated from the circumference when omitted. */
repeat?: number;
/** Outer diameter in px. */
size?: number;
/** Font size of the ring text in px. */
fontSize?: number;
/** Rotation speed in degrees per second (0 = still). */
speed?: number;
direction?: CircularTextDirection;
/** What hovering (or keyboard focus on a link) does to the rotation. */
hover?: CircularTextHover;
/** Draw a hairline ring between the text and the centre slot. */
ring?: boolean;
/** Separator and ring colour (any CSS colour). */
accent?: string;
/** Render as a link. */
href?: string;
target?: string;
rel?: string;
/** Accessible name. Defaults to `text`. */
label?: string;
/** Classes for the ring text (font, case, weight). */
textClassName?: string;
/** Content of the centre slot, e.g. an arrow icon. */
children?: React.ReactNode;
}
/* --------------------------- reduced-motion hook --------------------------- */
const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
function subscribeReducedMotion(onChange: () => void) {
const mq = window.matchMedia(REDUCED_QUERY);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia(REDUCED_QUERY).matches,
() => false,
);
}
/** Rough advance of one glyph relative to the font size; textLength corrects the rest. */
const GLYPH_ADVANCE = 0.66;
const HOVER_RATE: Record<CircularTextHover, number> = { none: 1, pause: 0, accelerate: 3.5 };
/**
* Text on a circular SVG textPath that turns slowly around a centre slot. The text is repeated
* to fill the circumference and justified with `textLength`, so the seam always closes cleanly.
* Rotation runs on the Web Animations API, eases its speed on hover, and pauses offscreen.
*/
export function CircularText({
text = "Available for work",
separator = " • ",
repeat,
size = 168,
fontSize = 11,
speed = 24,
direction = "clockwise",
hover = "accelerate",
ring = true,
accent = "#FF7448",
href,
target,
rel,
label,
textClassName = "font-mono uppercase",
children,
className = "",
style,
onPointerEnter,
onPointerLeave,
onFocus,
onBlur,
...props
}: CircularTextProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLElement>(null);
const spinRef = React.useRef<SVGSVGElement>(null);
const engagedRef = React.useRef<((engaged: boolean) => void) | null>(null);
const pathId = `ct-${React.useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
// Geometry: the baseline sits on a circle; glyphs grow outward from it.
const half = size / 2;
const r = Math.max(8, half - fontSize * 0.8 - 2);
const circumference = 2 * Math.PI * r;
const ringRadius = Math.max(4, r - fontSize * 0.75);
const slot = Math.max(0, ringRadius * 2 - 10);
const unit = `${text}${separator}`;
const count =
repeat ?? Math.max(1, Math.round(circumference / Math.max(1, Array.from(unit).length * fontSize * GLYPH_ADVANCE)));
const path = `M ${half} ${half - r} a ${r} ${r} 0 1 1 0 ${2 * r} a ${r} ${r} 0 1 1 0 ${-2 * r}`;
React.useEffect(() => {
const root = rootRef.current;
const spinner = spinRef.current;
if (!root || !spinner || reduceMotion || speed <= 0 || typeof spinner.animate !== "function") return;
const turn = direction === "counterclockwise" ? ["rotate(0deg)", "rotate(-360deg)"] : ["rotate(0deg)", "rotate(360deg)"];
const animation = spinner.animate(
[{ transform: turn[0] }, { transform: turn[1] }],
{ duration: (360 / speed) * 1000, iterations: Infinity, easing: "linear" },
);
let inView = true;
let rate = 1;
let targetRate = 1;
let frame = 0;
// Ease the playback rate towards its target instead of jumping (setting it keeps currentTime).
const ease = () => {
frame = 0;
rate += (targetRate - rate) * 0.12;
if (Math.abs(targetRate - rate) < 0.01) rate = targetRate;
animation.playbackRate = rate;
if (rate !== targetRate) frame = requestAnimationFrame(ease);
};
engagedRef.current = (engaged: boolean) => {
targetRate = engaged ? HOVER_RATE[hover] ?? 1 : 1;
if (!frame) frame = requestAnimationFrame(ease);
};
const sync = () => {
if (inView && !document.hidden) animation.play();
else animation.pause();
};
const observer = new IntersectionObserver((entries) => {
inView = entries.some((entry) => entry.isIntersecting);
sync();
});
observer.observe(root);
document.addEventListener("visibilitychange", sync);
return () => {
cancelAnimationFrame(frame);
engagedRef.current = null;
observer.disconnect();
document.removeEventListener("visibilitychange", sync);
animation.cancel();
};
}, [reduceMotion, speed, direction, hover]);
const handlePointerEnter = (event: React.PointerEvent<HTMLElement>) => {
onPointerEnter?.(event);
if (event.pointerType === "mouse") engagedRef.current?.(true);
};
const handlePointerLeave = (event: React.PointerEvent<HTMLElement>) => {
onPointerLeave?.(event);
engagedRef.current?.(false);
};
const handleFocus = (event: React.FocusEvent<HTMLElement>) => {
onFocus?.(event);
if (event.currentTarget.matches(":focus-visible")) engagedRef.current?.(true);
};
const handleBlur = (event: React.FocusEvent<HTMLElement>) => {
onBlur?.(event);
engagedRef.current?.(false);
};
const name = label ?? text;
const Tag = (href ? "a" : "div") as React.ElementType;
const linkProps = href ? { href, target, rel, "aria-label": name } : {};
return (
<Tag
ref={rootRef}
className={`relative inline-flex shrink-0 items-center justify-center rounded-full text-zinc-900 outline-none dark:text-zinc-100 ${
href
? "focus-visible:ring-2 focus-visible:ring-[var(--ct-accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-zinc-950"
: ""
} ${className}`}
style={{ ["--ct-accent" as string]: accent, width: size, height: size, ...style }}
onPointerEnter={handlePointerEnter}
onPointerLeave={handlePointerLeave}
onFocus={handleFocus}
onBlur={handleBlur}
{...linkProps}
{...props}
>
{href ? null : <span className="sr-only">{name}</span>}
<svg
ref={spinRef}
aria-hidden="true"
viewBox={`0 0 ${size} ${size}`}
width={size}
height={size}
className="pointer-events-none absolute inset-0 overflow-visible"
>
<defs>
<path id={pathId} d={path} fill="none" />
</defs>
<text
className={`fill-current ${textClassName}`}
style={{ fontSize, whiteSpace: "pre" }}
>
<textPath href={`#${pathId}`} textLength={circumference} lengthAdjust="spacing">
{Array.from({ length: count }, (_, i) => (
<React.Fragment key={i}>
<tspan>{text}</tspan>
<tspan fill="var(--ct-accent)">{separator}</tspan>
</React.Fragment>
))}
</textPath>
</text>
</svg>
{ring ? (
<span
aria-hidden="true"
className="pointer-events-none absolute rounded-full border border-[var(--ct-accent)] opacity-40"
style={{ width: ringRadius * 2, height: ringRadius * 2 }}
/>
) : null}
{children !== undefined && children !== null ? (
<span className="relative flex items-center justify-center rounded-full" style={{ width: slot, height: slot }}>
{children}
</span>
) : null}
</Tag>
);
}