# Circular Text (Moonarc)

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.

- Import: `import CircularText from '@moonarc/core/CircularText'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/circular-text.json`
- Tier A · category text · trigger always
- Readout: `<CircularText duration={18}>`
- Browser support: widely (every browser)
- Measured cost: 0 B JS (CSS 4.8 kB raw)
- Page: https://moonarc.dev/components/circular-text/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `text` | `string` | none | The 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. |
| `as` | `HTMLTag` | `'span'` | Element to render. |
| `size` | `string` | `'10em'` | Ring diameter, any CSS length. |
| `duration` | `number` | `18` | Seconds per full turn. Under 8 reads as a spinner, not a badge. |
| `direction` | `'cw' | 'ccw'` | `'cw'` | Turn direction. |
| `id` | `string` | none | Id for the circle path, derived from the text by default; set it when the same text appears twice on one page. |

## Usage

```astro
<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>
```

## Reduced motion

The ring is still.

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- textLength on the textPath with lengthAdjust="spacing" stretches only the letter-spacing: the glyphs keep their shape, the string fills the circumference exactly once, and the seam is a normal word gap.
- The font size is derived from the character count on the server (0.62 em per character, counted in graphemes), so a longer badge gets smaller instead of overlapping itself.
- One rotation on an HTML wrapper around the <svg>, not on the <svg> itself, whose transform is not composited in every engine: the text is rasterised once and turns as a texture.
- The ring is laid out left to right whatever the page's direction: the path starts at the left and runs clockwise, and under dir="rtl" the text ran backwards off the path's start and the ring was empty.
- Linear timing: a badge that eases would look like it keeps stopping.
- The ring is aria-hidden and a visually hidden span (.ma-sr) carries the sentence, so a screen reader reads the sentence once.

## Replaces

- CircularText (React Bits)
- rotating badges (Magic UI, Animata)

## Source

```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>

```
