Text circles slowly around an icon, like a stamp. Pauses under the pointer and picks up where it stopped.
Customize
Usage
import { ArrowUpRight } from "lucide-react";
import { RotatingBadge } from "@/components/motionkit/rotating-badge";
<a href="/contact" aria-label="Available for projects">
<RotatingBadge text="Available for projects">
<ArrowUpRight />
</RotatingBadge>
</a>Motion
The exact curves this component runs, plotted with Motion's own maths.
Turn
Runs on its own
Works in the App Router as is. Requires Tailwind CSS v4.
npm install motionSave as components/motionkit/rotating-badge.tsx.
"use client";
import { useId, useRef, type ReactNode } from "react";
import { motion, useAnimationFrame, useMotionValue, useReducedMotion } from "motion/react";
type RotatingBadgeProps = {
/** Text around the ring. It repeats to fill the circle. */
text: string;
/** What sits in the middle, e.g. an icon or a logo. */
children?: ReactNode;
/** Diameter in pixels. */
size?: number;
/** Seconds for one full turn. */
duration?: number;
/** Turn the other way. */
reverse?: boolean;
/** Stop turning while the pointer is over the badge. */
pauseOnHover?: boolean;
color?: string;
className?: string;
};
export function RotatingBadge({
text,
children,
size = 120,
duration = 14,
reverse = false,
pauseOnHover = true,
color = "#F6F1E8",
className = "",
}: RotatingBadgeProps) {
const reduceMotion = useReducedMotion();
const pathId = useId();
const rotate = useMotionValue(0);
const hovered = useRef(false);
// turned by a frame clock rather than a fixed loop, so it can pause on hover and resume where it stopped
useAnimationFrame((_, delta) => {
if (reduceMotion || (pauseOnHover && hovered.current)) return;
rotate.set((rotate.get() + ((reverse ? -360 : 360) * delta) / (duration * 1000)) % 360);
});
const radius = 38;
const circumference = 2 * Math.PI * radius;
// repeat the phrase as often as fits at a comfortable size, then scale the type so the copies
// fill the ring; textLength trims the last fraction, so spacing never has to squash
const phrase = `${text.trim()} · `;
const charWidth = 0.68; // average uppercase advance plus tracking, in ems
const repeats = Math.max(1, Math.round(circumference / (phrase.length * charWidth * 7)));
const fontSize = Math.min(10, Math.max(5, circumference / (repeats * phrase.length * charWidth)));
const ring = phrase.repeat(repeats);
return (
<div
className={`relative inline-flex items-center justify-center ${className}`}
style={{ width: size, height: size }}
onPointerEnter={() => (hovered.current = true)}
onPointerLeave={() => (hovered.current = false)}
>
<span className="sr-only">{text}</span>
<motion.svg
aria-hidden
viewBox="0 0 100 100"
className="absolute inset-0 size-full"
style={{ rotate }}
>
<defs>
<path id={pathId} d={`M 50 50 m -${radius} 0 a ${radius} ${radius} 0 1 1 ${radius * 2} 0 a ${radius} ${radius} 0 1 1 -${radius * 2} 0`} />
</defs>
<text fill={color} fontSize={fontSize} fontWeight={500} letterSpacing={fontSize * 0.08} style={{ textTransform: "uppercase" }}>
<textPath href={`#${pathId}`} textLength={circumference} lengthAdjust="spacing">
{ring}
</textPath>
</text>
</motion.svg>
<span className="relative flex items-center justify-center" style={{ color }}>
{children}
</span>
</div>
);
}
| Prop | Type | Default | Description |
|---|---|---|---|
| text | string | — | Text around the ring. It repeats to fill the circle. |
| children | ReactNode | — | What sits in the middle. |
| size | number | 120 | Diameter in pixels. |
| duration | number | 14 | Seconds per turn. |
| reverse | boolean | false | Turn anticlockwise. |
| pauseOnHover | boolean | true | Stop while the pointer is over it. |
| color | string | "#F6F1E8" | Text and icon colour. |
Shiny Text
A narrow glint of light sweeps across the letters every few seconds, like light catching metal.
Think beyond ordinary.
Gradient Text
Headline text with a slowly flowing, seamlessly looping gradient.
Liquid light
Liquid Gradient Text
Pools of colour swirl and flow inside the letters, for headings that feel alive without moving an inch.