yugodesign

Content·10.9

Spinning Text

A seal, turning at one rate.

the last twenty percent · yugo design ·
spinning-text

Install

One dependency. The component is copied into your project, so the file is yours after that.

terminal
bun add motion

Or let the shadcn CLI do the copying: same file, landing in components/yugo.

terminal
bunx shadcn@latest add https://yugo.click/r/spinning-text.json

Usage

stats.tsx
"use client";

import { SpinningText } from "@/components/yugo/spinning-text";

export function Seal() {
  return (
    <div className="relative grid place-items-center">
      <SpinningText
        text="the last twenty percent · yugo design · "
        duration={16}
        radius={5.2}
        fontSize={0.66}
      />
      <span aria-hidden className="absolute size-[5px] rounded-[1.5px] bg-[#4568FF]" />
    </div>
  );
}

Source

components/yugo/spinning-text.tsx
"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import {
  motion,
  useAnimationFrame,
  useInView,
  useMotionValue,
  useReducedMotion,
} from "motion/react";

const TURN = 12;
const RADIUS = 5;
const SIZE = 1;

export type SpinningTextLetter = {
  key: string;
  label: string;
  /** degrees around the ring, measured from twelve o'clock */
  angle: number;
};

export type UseSpinningTextOptions = {
  text: string;
  play?: boolean;
};

export type UseSpinningTextResult<T extends HTMLElement> = {
  ref: React.RefObject<T | null>;
  letters: SpinningTextLetter[];
  running: boolean;
};

/**
 * The ring, with nothing drawn. A rotation that reports nothing turns at one
 * rate — the spinner's own defence — so the caller gets `running` and a set
 * of angles, and nothing here decides what a letter looks like.
 */
export function useSpinningText<T extends HTMLElement = HTMLDivElement>({
  text,
  play = true,
}: UseSpinningTextOptions): UseSpinningTextResult<T> {
  const ref = useRef<T>(null);
  const inView = useInView(ref);
  const reduced = useReducedMotion();
  const [awake, setAwake] = useState(true);

  useEffect(() => {
    const read = () => setAwake(!document.hidden);
    read();
    document.addEventListener("visibilitychange", read);
    return () => document.removeEventListener("visibilitychange", read);
  }, []);

  const letters = useMemo(() => {
    const glyphs = Array.from(text);
    const step = glyphs.length > 0 ? 360 / glyphs.length : 0;
    return glyphs.map((label, i) => ({
      key: `${i}-${label}`,
      label,
      angle: i * step,
    }));
  }, [text]);

  return {
    ref,
    letters,
    running: play && awake && inView && !reduced && letters.length > 0,
  };
}

export type SpinningTextProps = UseSpinningTextOptions & {
  duration?: number;
  reverse?: boolean;
  radius?: number;
  fontSize?: number;
  className?: string;
};

export function SpinningText({
  text,
  play = true,
  duration = TURN,
  reverse = false,
  radius = RADIUS,
  fontSize = SIZE,
  className = "",
}: SpinningTextProps) {
  const { ref, letters, running } = useSpinningText<HTMLDivElement>({
    text,
    play,
  });

  // The angle is accumulated, not declared. `animate={{ rotate: 360 }}` has
  // no way to pause: withdrawing it animates the ring BACK to zero, which
  // reads as the seal unwinding. A MotionValue stepped per frame holds its
  // angle the instant `running` goes false and continues from it. §5h
  const angle = useMotionValue(0);
  useAnimationFrame((_, delta) => {
    if (!running) return;
    const step = (delta / 1000) * (360 / duration) * (reverse ? -1 : 1);
    angle.set((angle.get() + step) % 360);
  });

  // The ring has a real box. A zero-sized seal that only exists once its
  // parent is told to be `relative` is a component that reserves nothing,
  // and invariant 1 says every state pays for its space up front.
  const extent = `calc(${radius * 2}ch + 1em)`;

  return (
    <div
      ref={ref}
      className={`relative select-none text-stone-700 dark:text-stone-200 ${className}`}
      style={{ fontSize: `${fontSize}rem`, width: extent, height: extent }}
    >
      <span className="sr-only">{text}</span>

      <motion.div
        aria-hidden="true"
        className="absolute inset-0"
        style={{ rotate: angle }}
      >
        {letters.map((letter) => (
          <span
            key={letter.key}
            className="absolute left-1/2 top-1/2 inline-block"
            style={{
              transform: `translate(-50%, -50%) rotate(${letter.angle}deg) translateY(${-radius}ch)`,
              transformOrigin: "center",
            }}
          >
            {letter.label}
          </span>
        ))}
      </motion.div>
    </div>
  );
}

Props

text
string

The string to wrap around the ring. Trailing separators matter: the last character sits next to the first.

duration12
number

Seconds for one full turn, at a constant rate.

reversefalse
boolean

Turn anticlockwise.

radius5
number

Distance from the centre to each glyph, in ch, so the ring scales with the font rather than with a pixel guess.

fontSize1
number

Rem. It sets the element's own font size, which is what the ch unit and the reserved box are both measured from.

playtrue
boolean

Whether the ring turns. False holds it exactly where it is, letters still in place.

className""
string

Appended last to the ring, so the colour is the caller's.