CircularText
// preview
Text
Size (px)
Seconds per revolution
Direction
cwccw
On hover
nonepauseslowfast
// source
TSJS
TailwindCSS
/*!
* CircularText, a DesignPass.dev component by Ernest Liu (ernestliu.com)
* Docs & live playground: https://designpass.dev/components/circular-text
* MIT licensed, keep this notice in copies and adaptations.
*/
"use client";
import React, { useEffect, useMemo, useRef, type CSSProperties } from "react";
export interface CircularTextProps {
text: string;
/** Diameter of the ring (px). */
size?: number;
/** Seconds per full revolution. */
spinDuration?: number;
/** Spin direction. */
direction?: "clockwise" | "counterclockwise";
/** What the spin does while hovered; changes ramp smoothly, no snapping. */
onHover?: "none" | "pause" | "slow" | "fast";
className?: string;
style?: CSSProperties;
}
const HOVER_RATE: Record<NonNullable<CircularTextProps["onHover"]>, number> = {
none: 1,
pause: 0,
slow: 0.3,
fast: 3.5,
};
/**
* Text arranged around a spinning ring. One infinite WAAPI rotation drives
* the whole ring (a single compositor-friendly transform), and hover
* eases the playback rate toward pause / slow / fast instead of
* snapping. Zero dependencies, honors prefers-reduced-motion.
*/
export default function CircularText({
text,
size = 200,
spinDuration = 18,
direction = "clockwise",
onHover = "slow",
className = "",
style,
}: CircularTextProps) {
const ringRef = useRef<HTMLSpanElement>(null);
const chars = useMemo(() => [...text], [text]);
useEffect(() => {
const ring = ringRef.current;
if (!ring) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const sign = direction === "clockwise" ? 1 : -1;
const spin = ring.animate(
[{ transform: "rotate(0deg)" }, { transform: `rotate(${sign * 360}deg)` }],
{ duration: spinDuration * 1000, iterations: Infinity, easing: "linear" },
);
let frame = 0;
let rate = 1;
// Ease the playback rate toward a target so hover transitions feel
// like momentum instead of a hard cut.
const rampTo = (target: number) => {
cancelAnimationFrame(frame);
const step = () => {
rate += (target - rate) * 0.08;
if (Math.abs(target - rate) < 0.01) {
rate = target;
spin.playbackRate = rate;
return;
}
spin.playbackRate = rate;
frame = requestAnimationFrame(step);
};
frame = requestAnimationFrame(step);
};
const onEnter = () => rampTo(HOVER_RATE[onHover]);
const onLeave = () => rampTo(1);
if (onHover !== "none") {
ring.addEventListener("mouseenter", onEnter);
ring.addEventListener("mouseleave", onLeave);
}
return () => {
cancelAnimationFrame(frame);
ring.removeEventListener("mouseenter", onEnter);
ring.removeEventListener("mouseleave", onLeave);
spin.cancel();
};
}, [spinDuration, direction, onHover]);
return (
<span
ref={ringRef}
aria-label={text}
role="img"
className={`relative inline-block select-none will-change-transform ${className}`}
style={{ width: size, height: size, ...style }}
>
{chars.map((char, index) => (
<span
key={index}
aria-hidden="true"
className="absolute left-1/2 top-1/2 inline-block"
style={{
transform: `translate(-50%, -50%) rotate(${(360 / chars.length) * index}deg) translateY(calc(${-size / 2}px + 0.6em))`,
}}
>
{char === " " ? "\u00A0" : char}
</span>
))}
</span>
);
}
// install
Install the CircularText component from DesignPass into this project by running:
npx shadcn@latest add "https://designpass.dev/r/CircularText-TS-TW.json"
If the project has no components.json yet, run `npx shadcn@latest init` first.
Then show me a minimal usage example.Need the license details? Read the component license.