Display
CircularText
Letters set around a spinning ring that speeds up, slows or pauses on hover.
npx bezel-add add circular-textpackages/ui/src/display/CircularText.tsx · 203 lines
"use client";
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { motion, useAnimation, useReducedMotion } from "motion/react";
function cn(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(" ");
}
export type CircularTextHover = "slowDown" | "speedUp" | "pause" | "goBonkers";
export type CircularTextProps = {
text: string;
spinDuration?: number;
onHover?: CircularTextHover;
className?: string;
size?: "sm" | "md" | "lg";
children?: ReactNode; // optional center content (logo, icon)
};
const FALLBACK_RADIUS: Record<NonNullable<CircularTextProps["size"]>, number> = {
sm: 36,
md: 50,
lg: 132,
};
const getRotationTransition = (duration: number, from: number, loop = true) => ({
from,
to: from + 360,
ease: "linear" as const,
duration,
type: "tween" as const,
repeat: loop ? Infinity : 0,
});
const getTransition = (duration: number, from: number) => ({
rotate: getRotationTransition(duration, from),
scale: { type: "spring" as const, damping: 20, stiffness: 300 },
});
/** Letters orbit a ring; hover can speed up, slow, pause, or go wild. */
export function CircularText({
text,
spinDuration = 20,
onHover = "speedUp",
className = "",
size = "md",
children,
}: CircularTextProps) {
const letters = Array.from(text);
const controls = useAnimation();
const hostRef = useRef<HTMLDivElement>(null);
const [letterRadius, setLetterRadius] = useState(FALLBACK_RADIUS[size]);
const prefersReducedMotion = useReducedMotion();
const activeDuration = prefersReducedMotion ? spinDuration * 4 : spinDuration;
useLayoutEffect(() => {
const host = hostRef.current;
if (!host) return;
const measure = () => {
const min = Math.min(host.clientWidth, host.clientHeight);
if (min <= 0) return;
const inset = size === "sm" ? 0.4 : 0.44;
setLetterRadius(min * inset);
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(host);
return () => ro.disconnect();
}, [size]);
useEffect(() => {
void controls.start({
rotate: 360,
scale: 1,
transition: getTransition(activeDuration, 0),
});
}, [activeDuration, text, onHover, controls]);
const handleHoverStart = () => {
if (prefersReducedMotion || !onHover) return;
let transitionConfig;
let scaleVal = 1;
switch (onHover) {
case "slowDown":
transitionConfig = getTransition(activeDuration * 2, 0);
break;
case "speedUp":
transitionConfig = getTransition(activeDuration / 4, 0);
break;
case "pause":
transitionConfig = {
rotate: { type: "spring" as const, damping: 20, stiffness: 300 },
scale: { type: "spring" as const, damping: 20, stiffness: 300 },
};
break;
case "goBonkers":
transitionConfig = getTransition(activeDuration / 20, 0);
scaleVal = 0.8;
break;
default:
transitionConfig = getTransition(activeDuration, 0);
}
void controls.start({ rotate: 360, scale: scaleVal, transition: transitionConfig });
};
const handleHoverEnd = () => {
if (prefersReducedMotion) return;
void controls.start({
rotate: 360,
scale: 1,
transition: getTransition(activeDuration, 0),
});
};
return (
<>
<style>{`
.circ-text-host {
position: relative;
display: grid;
place-items: center;
aspect-ratio: 1 / 1;
width: 7.5rem;
pointer-events: none;
}
.circ-text-host--sm { width: 5.5rem; }
.circ-text-host--md { width: 7.5rem; }
.circ-text-host--lg { width: 22rem; }
@media (min-width: 640px) {
.circ-text-host--lg { width: 26rem; }
}
.circ-text-anchor {
position: absolute;
inset: 0;
transform: translate(0, 0);
}
.circ-text-ring {
position: relative;
width: 100%;
height: 100%;
transform-origin: center center;
pointer-events: auto;
}
.circ-text-ring span {
position: absolute;
left: 50%;
top: 50%;
display: inline-block;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--bz-gold, #7a6015);
transform-origin: center center;
white-space: pre;
font-size: 0.5625rem;
}
.circ-text-host--sm .circ-text-ring span { font-size: 0.4375rem; }
.circ-text-host--lg .circ-text-ring span {
font-size: 0.72rem;
letter-spacing: 0.14em;
}
.circ-text-center {
position: relative;
z-index: 2;
width: 70%;
height: 70%;
display: grid;
place-items: center;
}
@media (prefers-reduced-motion: reduce) {
.circ-text-ring { transform: none !important; }
}
`}</style>
<div
ref={hostRef}
className={cn("circ-text-host", `circ-text-host--${size}`, className)}
aria-hidden
>
{children ? <div className="circ-text-center">{children}</div> : null}
<div className="circ-text-anchor">
<motion.div
className="circ-text-ring"
initial={{ rotate: 0 }}
animate={controls}
onMouseEnter={handleHoverStart}
onMouseLeave={handleHoverEnd}
>
{letters.map((letter, i) => {
const rotationDeg = (360 / letters.length) * i;
const transform = `translate(-50%, -50%) rotate(${rotationDeg}deg) translateY(-${letterRadius}px) rotate(${-rotationDeg}deg)`;
return (
<span key={`${letter}-${i}`} style={{ transform, WebkitTransform: transform }}>
{letter}
</span>
);
})}
</motion.div>
</div>
</div>
</>
);
}