Circular Spinning Text Badge Around a Button

A round badge whose words run around its edge and turn slowly, with a button in the middle. It hangs off the corner of a product picture in a pottery studio hero, and a smaller ring turns the other way in the opposite corner. Each letter is placed with a rotate and a translate, so any sentence and any radius works without an SVG path. On phones the copy stacks above the picture and the badge stays inside the screen.

Code

// Adapted from Motion Primitives (https://motion-primitives.com/docs/spinning-text)
// Copyright (c) ibelick. MIT License: https://we.inc/components/licenses
import { useState } from "react";
import { motion, useReducedMotion } from "framer-motion";

// The words that travel around each ring. End with a separator so the loop joins cleanly.
const RING_TEXT = "OPEN STUDIO SATURDAYS • BOOK A WHEEL • ";
const SMALL_RING_TEXT = "HAND THROWN • KILN FIRED • ";

const sessions = [
  { day: "Sat", time: "10:00", name: "First pot, beginners", left: "3 wheels left" },
  { day: "Sat", time: "14:30", name: "Glazing and firing", left: "5 wheels left" },
  { day: "Sun", time: "11:00", name: "Mugs with handles", left: "2 wheels left" },
];

/** Letters set on a circle that turns forever. `radius` is in pixels. */
function SpinningText({
  text,
  radius,
  duration = 14,
  reverse = false,
  paused = false,
  className = "",
}: {
  text: string;
  radius: number;
  duration?: number;
  reverse?: boolean;
  paused?: boolean;
  className?: string;
}) {
  const reduceMotion = useReducedMotion();
  const letters = text.split("");
  const still = reduceMotion || paused;
  return (
    <motion.div
      className={`relative ${className}`}
      style={{ width: radius * 2 + 28, height: radius * 2 + 28 }}
      animate={still ? { rotate: 0 } : { rotate: reverse ? -360 : 360 }}
      transition={still ? { duration: 0.6 } : { repeat: Infinity, ease: "linear", duration }}
    >
      {letters.map((letter, index) => (
        <motion.span
          aria-hidden="true"
          key={`${index}-${letter}`}
          className="absolute left-1/2 top-1/2 inline-block"
          style={{
            transform: `translate(-50%, -50%) rotate(${(360 / letters.length) * index}deg) translateY(${-radius}px)`,
          }}
          initial={reduceMotion ? false : { opacity: 0 }}
          animate={{ opacity: 1 }}
          transition={{ delay: 0.15 + index * 0.018, duration: 0.3 }}
        >
          {letter}
        </motion.span>
      ))}
      <span className="sr-only">{text}</span>
    </motion.div>
  );
}

export default function CircularSpinningTextBadgeAroundAButton() {
  const reduceMotion = useReducedMotion();
  const [paused, setPaused] = useState(false);

  return (
    <section className="overflow-hidden bg-orange-50 px-5 py-14 text-stone-900 sm:px-8 sm:py-20">
      <div className="mx-auto grid max-w-5xl items-center gap-12 lg:grid-cols-2 lg:gap-8">
        <motion.div
          initial={reduceMotion ? false : { opacity: 0, y: 20 }}
          animate={{ opacity: 1, y: 0 }}
          transition={{ type: "spring", stiffness: 90, damping: 18 }}
        >
          <p className="text-xs font-semibold uppercase tracking-widest text-orange-700">Kiln and Clay, Harbour Road</p>
          <h2 className="mt-4 text-4xl font-black leading-none tracking-tight sm:text-6xl">
            Throw your first pot this weekend.
          </h2>
          <p className="mt-5 max-w-md text-base leading-7 text-stone-700">
            Two hours at the wheel with a potter beside you. We fire and glaze what you make, and you collect it the following week.
          </p>

          <ul className="mt-7 max-w-md divide-y divide-stone-900/10 border-y border-stone-900/10">
            {sessions.map((session) => (
              <li key={session.name} className="flex items-center gap-4 py-3 text-sm">
                <span className="w-20 shrink-0 font-mono text-xs uppercase tracking-wider text-stone-600">
                  {session.day} {session.time}
                </span>
                <span className="flex-1 font-semibold">{session.name}</span>
                <span className="hidden text-xs text-stone-600 sm:block">{session.left}</span>
              </li>
            ))}
          </ul>

          <div className="mt-7 flex flex-wrap items-center gap-3">
            <a
              href="#"
              className="inline-flex h-12 items-center rounded-full bg-stone-900 px-6 text-sm font-semibold text-orange-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-600 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-50"
            >
              See all classes
            </a>
            <button
              type="button"
              aria-pressed={paused}
              onClick={() => setPaused((value) => !value)}
              className="inline-flex h-12 items-center rounded-full border border-stone-900/20 px-5 text-sm font-semibold text-stone-800 hover:bg-stone-900/5 focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-600"
            >
              {paused ? "Spin the badge" : "Pause the badge"}
            </button>
          </div>
        </motion.div>

        {/* Artwork with the spinning badge hanging off its corner */}
        <div className="relative mx-auto w-full max-w-sm pb-10 pr-6 sm:pr-0">
          <motion.div
            initial={reduceMotion ? false : { opacity: 0, scale: 0.94 }}
            animate={{ opacity: 1, scale: 1 }}
            transition={{ type: "spring", stiffness: 80, damping: 16, delay: 0.1 }}
            className="relative aspect-[4/5] overflow-hidden rounded-[2.5rem] bg-orange-600"
          >
            <svg viewBox="0 0 320 400" aria-hidden="true" className="absolute inset-0 h-full w-full">
              <defs>
                <linearGradient id="spin-badge-vase" x1="0" y1="0" x2="1" y2="0">
                  <stop offset="0" stopColor="#7c2d12" />
                  <stop offset="0.45" stopColor="#fdba74" />
                  <stop offset="1" stopColor="#9a3412" />
                </linearGradient>
              </defs>
              <circle cx="250" cy="92" r="150" fill="#fb923c" />
              <circle cx="60" cy="330" r="120" fill="#c2410c" />
              <ellipse cx="160" cy="352" rx="118" ry="16" fill="#7c2d12" opacity="0.45" />
              <path
                d="M130 92h60c-4 22-10 34 4 56 26 40 44 66 44 110 0 52-34 92-78 92s-78-40-78-92c0-44 18-70 44-110 14-22 8-34 4-56Z"
                fill="url(#spin-badge-vase)"
              />
              <ellipse cx="160" cy="92" rx="30" ry="7" fill="#431407" />
              <path d="M96 232c40 14 88 14 128 0M90 262c44 16 96 16 140 0" stroke="#fff7ed" strokeWidth="3" fill="none" opacity="0.7" />
            </svg>
            <p className="absolute bottom-6 left-6 rounded-full bg-orange-50 px-3 py-1 text-xs font-semibold text-orange-800">No. 14, ash glaze</p>
          </motion.div>

          {/* Small counter-rotating ring, top left */}
          <div className="absolute -left-4 -top-8 hidden text-stone-900 sm:block">
            <SpinningText text={SMALL_RING_TEXT} radius={44} duration={18} reverse paused={paused} className="font-mono text-xs font-semibold" />
          </div>

          {/* Main badge */}
          <div className="absolute -bottom-2 -right-2 flex items-center justify-center rounded-full bg-stone-900 text-orange-50 shadow-2xl shadow-orange-900/40 sm:-right-12">
            <SpinningText text={RING_TEXT} radius={66} duration={14} paused={paused} className="font-mono text-xs font-semibold tracking-wide" />
            <a
              href="#"
              aria-label="Book a wheel for Saturday"
              className="absolute flex h-20 w-20 items-center justify-center rounded-full bg-orange-500 text-stone-900 transition-transform duration-300 hover:scale-110 focus:outline-none focus-visible:ring-4 focus-visible:ring-orange-200 motion-reduce:transition-none"
            >
              <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-8 w-8">
                <path d="M7 17 17 7m0 0H8.5M17 7v8.5" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
            </a>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/circular-spinning-text-badge-around-a-button.json

When to use it

How to customize it

Accessibility

Questions

How do you put text on a circle in React?

Split the sentence into letters and give each one the same three transforms: centre it, rotate it by its share of 360 degrees, then push it outwards by the radius. Rotating the parent then spins the whole ring as one layer.

Why not use an SVG textPath?

A text path works too, but it needs a path sized to the text and it is harder to style with utility classes. Positioned letters take any font class, any radius and any length with no extra markup.

How do I stop the letters from bunching up or leaving a gap?

The letters are spread evenly over the full circle, so spacing depends on the text length and the radius. Add or remove a few characters, or change `radius`, until the ring looks even.

Is a spinning badge heavy on performance?

No. Only one element rotates and it uses a transform, so the browser moves it on the compositor without re-laying out the page.

Can I pause it on hover?

Yes. Pass `paused` from a hover state instead of the button state. The ring eases back to its starting angle and stays there until `paused` is false again.

Adapted from Spinning Text in Motion Primitives, used under its open-source licence. Licences and credits

Related: Rotating Word Headline with Spring Flip, Shimmering Chrome Gradient Headline, Magnetic Button That Follows the Pointer.

More text effects or all free UI components. Start for free to have a full site built around it.

More in Text Effects

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap