Skip to content

Components / Text

Circular Text

A ring of text turning slowly around an icon, a logo, or nothing. SVG text on a circle, justified to the full circumference so the loop has no seam, spun by one compositor rotation. Pure CSS; the centre is the slot.

Live demo

MEASURED · NOT ESTIMATED · ZERO BYTES ·

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 4.8 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

every browser.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import CircularText from '@moonarc/core/CircularText';
---
<CircularText text="MEASURED · NOT ESTIMATED · ZERO BYTES · " size="9rem" class="kicker">
  <svg width="28" height="28" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2" /></svg>
</CircularText>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/circular-text.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

CircularText.astro
---
/**
 * CircularText — a badge of text on a ring, turning slowly around whatever
 * sits in the middle. SVG <textPath> on a circle, justified to the full
 * circumference with textLength, spun by one CSS rotation on the compositor.
 * Zero JS. The centre is the slot.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

interface Props extends HTMLAttributes<'span'> {
  /** The text. Ends with a separator (· or —) so the loop has no seam. */
  text: string;
  /** Element to render. */
  as?: HTMLTag;
  /** Ring diameter, any CSS length. */
  size?: string;
  /** Seconds per full turn. */
  duration?: number;
  /** Turn direction. */
  direction?: 'cw' | 'ccw';
  /** Id for the circle path; set it when the same text appears twice on a page. */
  id?: string;
}

const { text, as: Tag = 'span', size = '10em', duration = 18, direction = 'cw', id, class: className, style, ...rest } = Astro.props;
// the same text always draws the same path id, so the HTML is stable across builds
const hash = [...text].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7);
const pathId = id ?? `ma-circ-${hash.toString(36)}`;
const R = 40;
const circumference = +(2 * Math.PI * R).toFixed(2);
// font size in viewBox units so the string fits the ring once: 0.62 em per character is Inter's average
const chars = Array.from(new Intl.Segmenter().segment(text)).length;
const fontSize = +Math.min(13, circumference / (chars * 0.62)).toFixed(2);
const vars = [`--ma-circ-size:${size}`, `--ma-circ-dur:${duration}s`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<Tag class:list={['ma-circ', className]} data-ccw={direction === 'ccw' ? '' : undefined} style={vars} {...rest}>
  <span class="ma-circ__ring" aria-hidden="true">
    <svg viewBox="0 0 100 100">
      <defs><path id={pathId} d={`M50,50 m-${R},0 a${R},${R} 0 1,1 ${2 * R},0 a${R},${R} 0 1,1 -${2 * R},0`} /></defs>
      <text font-size={fontSize}><textPath href={`#${pathId}`} textLength={circumference} lengthAdjust="spacing">{text}</textPath></text>
    </svg>
  </span>
  <span class="ma-circ__center"><slot /></span>
  <span class="ma-sr">{text}</span>
</Tag>

<style is:global>
  @layer components {
    :where(.ma-circ) {
      position: relative;
      display: inline-grid;
      place-items: center;
      width: var(--ma-circ-size, 10em);
      height: var(--ma-circ-size, 10em);
      vertical-align: middle;
    }
    /* the turn is on an HTML wrapper, not the <svg>: a transform on the SVG root is not always composited */
    :where(.ma-circ__ring) {
      grid-area: 1 / 1;
      display: block;
      width: 100%;
      height: 100%;
      /* linear on purpose: continuous rotation, no spring */
      animation: ma-circ-spin var(--ma-circ-dur, var(--ma-dur-ambient)) linear infinite;
      will-change: rotate;
    }
    /* direction: ltr. The path runs clockwise from the left; a right-to-left page laid the text out backwards from the
       path's start, off the path, and the ring was empty. */
    :where(.ma-circ__ring > svg) {
      display: block;
      width: 100%;
      height: 100%;
      overflow: visible;
      direction: ltr;
      fill: currentColor;
      font-family: inherit;
      font-weight: inherit;
      letter-spacing: inherit;
      text-transform: inherit;
    }
    :where(.ma-circ[data-ccw] .ma-circ__ring) {
      animation-direction: reverse;
    }
    :where(.ma-circ__center) {
      grid-area: 1 / 1;
      display: grid;
      place-items: center;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-circ__ring) {
        animation: none;
        will-change: auto;
      }
    }
  }

  @keyframes ma-circ-spin {
    to {
      rotate: 1turn;
    }
  }
</style>

Props

PropTypeDefaultDescription
textstringnoneThe text. End it with a separator (· or an em dash) so the loop closes without a seam; 20–40 characters fill the ring at a readable size.
asHTMLTag'span'Element to render.
sizestring'10em'Ring diameter, any CSS length.
durationnumber18Seconds per full turn. Under 8 reads as a spinner, not a badge.
direction'cw' | 'ccw''cw'Turn direction.
idstringnoneId for the circle path, derived from the text by default; set it when the same text appears twice on one page.

Reduced motion

The ring is still.

With ClientRouter

CSS-only; nothing to rebind.

Why it is built this way

Replaces: CircularText (React Bits) · rotating badges (Magic UI, Animata). See the migration table.

Marquee

Ticker

Infinite scrolling strip for logos, quotes or tags in any direction.

every browseralwayshover

A highlight sweeps across the text every few seconds.

every browseralways

A wave of boldness rolls through the letters of a line, each character animating its font weight on its own delay.

every browseralways

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown