Skip to content

Components / Scroll

Fill Text

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.

Live demo

The words start faint and fill in, line by line, as this block moves up the screen. Scroll the page to see it.

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.6 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Limited availability

Chrome 120 · not Firefox · Safari 26; needs scroll-timeline, bg-clip-text. Elsewhere: the text is full colour from the start, with no fill.

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 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." />
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/fill-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.

FillText.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>

Props

PropTypeDefaultDescription
textstringnoneThe statement. The default slot wins, and may hold inline markup (an em, a link).
asHTMLTag'p'Element to render.
faintstringnoneThe colour the words start at. Unset, the text colour at 18 % over the ground, so it follows any tone.
startstring'entry 90%'Where in the block's crossing the fill starts (an animation-range start).
endstring'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.

Reduced motion

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

With ClientRouter

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

Why it is built this way

Replaces: TextReveal (Magic UI) · ScrollReveal (React Bits) · Text fill on scroll (GSAP ScrollTrigger). See the migration table.

A highlighter sweeps across the phrases you marked, one after another, when the sentence scrolls into view.

every browserscroll

Entrance on scroll that plays once and stays, staggers its children, scrubs with the scroll position when asked, honours reduced motion, and keeps working after every ClientRouter navigation.

every browserscroll

Bind translate, scale, rotate and opacity to the scroll position: the element goes from the start values you give to neutral across a range of its own passage through the viewport, exactly in step.

Chrome 115 · not Firefox · Safari 26scroll

Splits a string into words, characters or lines on the server and reveals them with a capped stagger when they scroll into view.

every browserscroll

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