DesignPass.dev

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.

// updates

Know when new components drop

A short email when something new lands in the library. No noise, unsubscribe anytime.