spinningText
A Text block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call spinningText() with no arguments for a working demo, or edit the code below live.
Implementation notes
Fully declarative, matches the spec's domSketch/animation description directly: the input phrase is repeated with a separator until the ring reads full (~28 chars), each character gets a precomputed rotate(angle) translate(0, calc(radius * -1)) transform placing it at its point on the circle, and the whole character group carries a single continuous linear CSS @keyframes rotation (default 10s/revolution, reverse flag flips direction). No lifecycle/JS driving needed. No meaningful fidelity gaps versus the spec.
Status: ported · Reference: Magic UI original
// magicui "Spinning Text" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A short
// phrase (typically repeated with a separator to fill the ring) arranged so
// each character sits at its own point on an invisible circle, oriented to
// follow the circle's curvature — then the whole ring of pre-placed
// characters spins as one rigid group via a single continuous CSS rotation.
// Purely declarative: no imperative/lifecycle code is needed since every
// character's placement is a static, precomputed transform.
import type { DomphyElement, StyleObject } from "@domphy/core";
import { hashString } from "@domphy/core";
import { themeSpacing } from "@domphy/theme";
export interface SpinningTextProps {
/** Text content, repeated with `separator` until the ring reads as full. Defaults to a short demo phrase. */
children?: string;
/** Seconds per full rotation. Defaults to 10. */
duration?: number;
/** Radius of the circular path, in `themeSpacing` units. Defaults to 5. */
radius?: number;
/** Spins counter-clockwise instead of clockwise. Defaults to false. */
reverse?: boolean;
/** Joins repeats of `children` when the ring needs filling out. Defaults to " • ". */
separator?: string;
/** Passthrough style merged onto the outer wrapper. */
style?: StyleObject;
}
// How many characters the ring targets before it reads as "full" — short
// phrases get repeated (with `separator`) until they reach roughly this length.
const TARGET_RING_LENGTH = 28;
/**
* A short phrase spinning continuously in a full circle, like text wrapped
* around an invisible ring — each character is pre-placed at its own angle
* around the circle, and the whole set rotates together as a rigid disc.
* Starts spinning automatically on mount, forever, clockwise by default.
* Call with no arguments for a working demo.
*/
function spinningText(props: SpinningTextProps = {}): DomphyElement<"div"> {
const phrase = props.children ?? "learn more";
const separator = props.separator ?? " • ";
const durationSeconds = props.duration ?? 10;
const radiusUnits = props.radius ?? 5;
const reverse = props.reverse ?? false;
const repeatUnit = `${phrase}${separator}`;
let ringText = repeatUnit;
while (ringText.length < TARGET_RING_LENGTH) ringText += repeatUnit;
const characters = Array.from(ringText);
const radiusStyle = themeSpacing(radiusUnits);
const diameterStyle = themeSpacing(radiusUnits * 2);
const keyframes = {
from: { transform: "rotate(0deg)" },
to: { transform: "rotate(360deg)" },
};
const animationName = `spinning-text-rotate-${hashString(JSON.stringify(keyframes))}`;
const characterSpans: DomphyElement<"span">[] = characters.map(
(character, index) => {
const angleDegrees = (360 / characters.length) * index;
return {
// Non-breaking space so bare spaces in the phrase render as real content.
span: character === " " ? " " : character,
_key: `character-${index}`,
style: {
position: "absolute",
insetBlockStart: "50%",
insetInlineStart: "50%",
transformOrigin: "0 0",
transform: `rotate(${angleDegrees}deg) translate(0, calc(${radiusStyle} * -1))`,
},
};
},
);
return {
div: [
{
div: characterSpans,
ariaHidden: "true",
_key: "ring",
style: {
position: "absolute",
inset: 0,
animation: `${animationName} ${durationSeconds}s linear infinite ${reverse ? "reverse" : "normal"}`,
[`@keyframes ${animationName}`]: keyframes,
} as StyleObject,
},
],
role: "img",
ariaLabel: phrase,
style: {
position: "relative",
display: "inline-block",
width: diameterStyle,
height: diameterStyle,
...(props.style ?? {}),
} as StyleObject,
};
}
export { spinningText };