Content·10.9
Spinning Text
A seal, turning at one rate.
the last twenty percent · yugo design ·
Install
One dependency. The component is copied into your project, so the file is yours after that.
bun add motionOr let the shadcn CLI do the copying: same file, landing in components/yugo.
bunx shadcn@latest add https://yugo.click/r/spinning-text.jsonUsage
"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
"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
textstringThe string to wrap around the ring. Trailing separators matter: the last character sits next to the first.
duration12numberSeconds for one full turn, at a constant rate.
reversefalsebooleanTurn anticlockwise.
radius5numberDistance from the centre to each glyph, in ch, so the ring scales with the font rather than with a pixel guess.
fontSize1numberRem. It sets the element's own font size, which is what the ch unit and the reserved box are both measured from.
playtruebooleanWhether the ring turns. False holds it exactly where it is, letters still in place.
className""stringAppended last to the ring, so the colour is the caller's.