# Fill Text (Moonarc)

A statement that fills in as it is read: the words start faint and take their full colour in reading order, line after line, while the block crosses the viewport. One inline span with a two-colour gradient clipped to the text, moved by a scroll-driven timeline, so the fill follows the lines wherever they wrap. Zero JavaScript; where scroll-driven animation is missing, and under reduced motion, the text is full colour from the start.

- Import: `import FillText from '@moonarc/core/FillText'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/fill-text.json`
- Tier A · category scroll · trigger scroll
- Readout: `<FillText text="The food is good, the rooms look great …" />`
- Browser support: limited (Chrome 120 · not Firefox · Safari 26); elsewhere: the text is full colour from the start, with no fill
- Measured cost: 0 B JS (CSS 4.6 kB raw)
- Page: https://moonarc.dev/components/fill-text/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `text` | `string` | none | The statement. The default slot wins, and may hold inline markup (an em, a link). |
| `as` | `HTMLTag` | `'p'` | Element to render. |
| `faint` | `string` | none | The colour the words start at. Unset, the text colour at 18 % over the ground, so it follows any tone. |
| `start` | `string` | `'entry 90%'` | Where in the block's crossing the fill starts (an animation-range start). |
| `end` | `string` | `'cover 58%'` | Where it ends: by default a little past the middle of the screen, so the statement is full before it is read to the end. |

## Usage

```astro
---
import FillText from '@moonarc/core/FillText';
---
<FillText class="text-4xl font-semibold" text="The food is good, the rooms look great, and the last post went up three weeks ago. That last part is our job." />
```

## Reduced motion

The text is full colour from the start; nothing fills.

## With ClientRouter

CSS only (a view timeline); nothing to rebind.

## Craft

- One inline span, not a span per word: a background on an inline box runs across its line boxes as one strip, so a gradient edge swept from 100 % to 0 fills the lines in reading order without the text being split.
- The fill ends at cover 58 %: a statement still filling when it reaches the top of the screen reads as broken.
- The faint colour is the text colour mixed over transparent, so the statement stays the right colour on any ground or tone.
- The run hides its fill (-webkit-text-fill-color), not its colour: with color: transparent, currentColor in the gradient would be transparent too, and the statement would vanish.
- The timeline sits in its own rule: lightningcss folds a timeline written beside the animation shorthand into it, and the shorthand resets it in the production build.

## Replaces

- TextReveal (Magic UI)
- ScrollReveal (React Bits)
- Text fill on scroll (GSAP ScrollTrigger)

## Source

```astro
---
/**
 * FillText — a statement that fills in as it is read: the words start faint and take their full colour in reading
 * order, line after line, while the block crosses the viewport. One inline span with a two-colour gradient clipped to
 * the text; a scroll-driven timeline moves the gradient's edge from the end of the last line to the start of the
 * first, so the fill follows the lines as they wrap, whatever the width. Zero JavaScript.
 *
 * Where scroll-driven animation is missing, and under reduced motion, the text is simply full colour.
 */
import type { HTMLAttributes, HTMLTag } from 'astro/types';

interface Props extends HTMLAttributes<'p'> {
  /** Element to render. */
  as?: HTMLTag;
  /** The statement; the default slot wins. */
  text?: string;
  /** The colour the words start at; unset, the text colour at 18 % over the ground. */
  faint?: string;
  /** Where in the block's crossing the fill starts and ends, as animation-range values. */
  start?: string;
  end?: string;
}

const { as: Tag = 'p', text, faint, start = 'entry 90%', end = 'cover 58%', class: className, style, ...rest } = Astro.props;
const vars = [faint && `--ma-fill-faint:${faint}`, `--ma-fill-start:${start}`, `--ma-fill-end:${end}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<Tag class:list={['ma-fill', className]} style={vars} {...rest}><span class="ma-fill__run">{Astro.slots.has('default') ? <slot /> : text}</span></Tag>

<style is:global>
  @layer components {
    @supports (animation-timeline: view()) {
      @media (prefers-reduced-motion: no-preference) {
        :where(.ma-fill__run) {
          /* the fill, not the colour: color stays the text's, so currentColor in the gradient is the text colour */
          -webkit-text-fill-color: transparent;
          background-image: linear-gradient(90deg, currentColor 50%, var(--ma-fill-faint, color-mix(in oklab, currentColor 18%, transparent)) 50%);
          background-size: 200% 100%;
          background-position: 100% 0;
          background-repeat: no-repeat;
          -webkit-background-clip: text;
          background-clip: text;
          animation: ma-fill-sweep linear both;
        }
        /* the timeline in a rule of its own, with other selector text: lightningcss folds a timeline written beside
           the animation shorthand into it, and the shorthand then resets it (the cost ladder's trap) */
        :where(.ma-fill .ma-fill__run) {
          animation-timeline: view();
          animation-range: var(--ma-fill-start, entry 90%) var(--ma-fill-end, cover 58%);
        }
      }
    }
  }
  @keyframes ma-fill-sweep {
    to { background-position: 0 0; }
  }
</style>

```
