# Shiny Text (Moonarc)

A highlight sweeps across the text every few seconds. The text keeps its own colour; the shine is a gradient clipped to a repeated copy on top, and only its position animates. Pure CSS.

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

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `text` | `string` | none | The text; repeated in a pseudo-element, so it must be a plain string. |
| `as` | `HTMLTag` | `'span'` | Element to render. |
| `duration` | `number` | `3` | Seconds per cycle; the sweep takes 60% of it and the rest is a pause. |
| `color` | `string` | `var(--ma-shine)` | Highlight colour; the token is theme-aware. |
| `width` | `number` | `0.25` | Highlight width as a fraction of the text, 0.05–0.6. |

## Usage

```astro
<h2><ShinyText text="Measured, not estimated." /></h2>
<ShinyText text="Pro" color="oklch(85% 0.17 70)" duration={2} />
```

## Reduced motion

The sweep is removed; the text shows in its own colour.

## With ClientRouter

CSS-only; nothing to rebind.

## Craft

- The base text is real text in a real colour; the pseudo-element only adds the highlight, so there is no transparent-text fallback problem and the colour is inherited as usual.
- A 60/40 sweep-to-pause ratio: a highlight that never rests reads as a loading state.
- Generated content is part of an element's accessible name, so the copy declares an empty alternative (content: attr(data-text) / '') and a screen reader reads the sentence once.
- Only background-position moves, on a 250% texture: no layout and no filter. background-position is not composited, so every frame repaints the text's own box (measured in Chromium); keep it to a headline or a label.

## Replaces

- ShinyText (React Bits)
- AnimatedShinyText (Magic UI)

## Source

```astro
---
/**
 * ShinyText — a highlight sweeps across the text on a loop, zero JS.
 *
 * The text is painted once in its own colour; a pseudo-element repeats it
 * with a moving gradient clipped to the glyphs on top. Only background-
 * position animates: no layout, but it is not composited either, so each
 * frame repaints the text's own box.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

interface Props extends HTMLAttributes<'span'> {
  /** The text. */
  text: string;
  /** Element to render. */
  as?: HTMLTag;
  /** Seconds per sweep, including the pause. */
  duration?: number;
  /** Highlight colour. */
  color?: string;
  /** Width of the highlight as a fraction of the text, 0–1. */
  width?: number;
}

const { text, as: Tag = 'span', duration = 3, color = 'var(--ma-shine)', width = 0.25, class: className, style, ...rest } = Astro.props;
const half = Math.max(0.05, Math.min(width, 0.6)) * 50;
// the caller's style joins the component's properties instead of replacing them (duration, colour and width were lost)
const vars = [`--ma-shiny-duration:${duration}s`, `--ma-shiny-color:${color}`, `--ma-shiny-a:${50 - half}%`, `--ma-shiny-b:${50 + half}%`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

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

<style is:global>
  @layer components {
    :where(.ma-shiny) {
      position: relative;
      display: inline-block;
    }
    :where(.ma-shiny)::after {
      /* the copy is paint only: the alternative text after the slash is empty, so assistive tech reads the sentence
         once (generated content is part of an element's name). The first line is for engines without that syntax. */
      content: attr(data-text);
      content: attr(data-text) / '';
      position: absolute;
      inset: 0;
      pointer-events: none;
      color: transparent;
      background: linear-gradient(110deg, transparent var(--ma-shiny-a, 37.5%), var(--ma-shiny-color, var(--ma-shine)) 50%, transparent var(--ma-shiny-b, 62.5%));
      background-size: 250% 100%;
      background-position: 120% 0;
      -webkit-background-clip: text;
      background-clip: text;
      animation: ma-shiny var(--ma-shiny-duration, 3s) var(--ma-ease-in-out) infinite;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-shiny)::after {
        animation: none;
        opacity: 0;
      }
    }
  }

  @keyframes ma-shiny {
    0% {
      background-position: 120% 0;
    }
    60%,
    100% {
      background-position: -20% 0;
    }
  }
</style>

```
