# Gradient Text (Moonarc)

A gradient fill drifting slowly through the text with no seam at the loop point. Only the background position moves. Pure CSS; works on any inline or block text.

- Import: `import GradientText from '@moonarc/core/GradientText'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/gradient-text.json`
- Tier A · category text · trigger always
- Readout: `<GradientText duration={6}>`
- Browser support: widely (Chrome 120 · Firefox 49 · Safari 14)
- Measured cost: 0 B JS (CSS 4.5 kB raw)
- Page: https://moonarc.dev/components/gradient-text/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `as` | `HTMLTag` | `'span'` | Element to render. |
| `colors` | `string[]` | `violet, cyan, rose (oklch)` | 2–5 colours. The first is repeated at the end so the loop has no visible seam. |
| `duration` | `number` | `6` | Seconds per full drift. Under 3 flickers. |
| `angle` | `number` | `90` | Gradient angle in degrees. |

## Usage

```astro
<h1><GradientText>Motion for Astro.</GradientText></h1>
<GradientText colors={['#f59e0b', '#ec4899']} angle={45} duration={4}>two colours</GradientText>
```

## Reduced motion

The gradient stays put.

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- The first stop is appended as the last so -300% lands exactly where 0% began: no visible seam.
- Linear timing: continuous motion must not ease.
- Gradient text loses currentColor; keep it for headlines, not body copy.
- Only background-position moves: no layout, but it is not composited, so every frame repaints the text's own box.
- Print drops backgrounds, and with them the only paint the transparent text has, so in print the text takes the colour around it.

## Replaces

- GradientText (React Bits)
- AnimatedGradientText (Magic UI)

## Source

```astro
---
/**
 * GradientText — a gradient fill that drifts through the text, zero JS.
 *
 * The gradient is three times wider than the text and only its position
 * moves: no layout, though background-position is not composited, so each
 * frame repaints the text's own box. The last stop repeats the first so the
 * loop has no seam.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

interface Props extends HTMLAttributes<'span'> {
  /** Element to render. */
  as?: HTMLTag;
  /** Colours, 2–5, any CSS colour. */
  colors?: string[];
  /** Seconds per full drift. */
  duration?: number;
  /** Gradient angle in degrees. */
  angle?: number;
}

const { as: Tag = 'span', colors = ['oklch(70% 0.19 300)', 'oklch(78% 0.15 200)', 'oklch(75% 0.17 350)'], duration = 6, angle = 90, class: className, style, ...rest } = Astro.props;
const stops = [...colors.slice(0, 5), colors[0]].join(', ');
// the caller's style joins the component's instead of replacing it: a style prop used to drop the stops, and the text,
// transparent over no gradient, disappeared
const vars = [`--ma-gt-stops:${stops}`, `--ma-gt-duration:${duration}s`, `--ma-gt-angle:${angle}deg`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<Tag class:list={['ma-gradient-text', className]} style={vars} {...rest}><slot /></Tag>

<style is:global>
  @layer components {
    :where(.ma-gradient-text) {
      background-image: linear-gradient(var(--ma-gt-angle, 90deg), var(--ma-gt-stops));
      background-size: 300% 100%;
      -webkit-background-clip: text;
      background-clip: text;
      color: transparent;
      animation: ma-gradient-text var(--ma-gt-duration, 6s) linear infinite;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-gradient-text) {
        animation: none;
      }
    }
    /* Print drops backgrounds by default, and with them the only paint the transparent text had: it prints in the
       colour around it instead. */
    @media print {
      :where(.ma-gradient-text) {
        color: inherit;
        background: none;
        animation: none;
      }
    }
  }

  @keyframes ma-gradient-text {
    to {
      background-position: -300% 0;
    }
  }
</style>

```
