Rejouice — loading0

Masked line reveal

Each line of a headline slides up from behind its own clipping mask, staggered, so the sentence assembles itself.

Grade: BRuntimes: css · gsap · motionStatus: live

Nothing appears. Everything arrives.

Masked line reveal — lines rise from their own clip

Intent

A headline that fades in reads as a page loading. A headline whose lines rise out of a mask reads as typesetting happening in front of you. The mask is what sells it: without the overflow clip the lines look like they are sliding around on top of the page rather than emerging from it.

Implementation

The default CSS state is *visible*. JavaScript opts into the hidden state, so a script failure degrades to plain readable text rather than a blank page.

Dependencies: none

html-css-js
<h1 class="t-display reveal" data-reveal>
  <span class="reveal__mask"><span class="reveal__line">We build the</span></span>
  <span class="reveal__mask"><span class="reveal__line">part of the internet</span></span>
  <span class="reveal__mask"><span class="reveal__line">people remember.</span></span>
</h1>

<style>
.reveal__mask {
  display: block;
  overflow: hidden;
  /* Room for descenders, which a tight clip would shave off. */
  padding-block-end: 0.08em;
}

.reveal__line {
  display: block;
  transition:
    transform var(--dur-reveal, 0.95s) var(--ease-out-expo, cubic-bezier(0.16, 1, 0.3, 1))
    var(--reveal-delay, 0ms);
}

/* JS adds .is-armed, so no-JS keeps the text visible. */
.reveal.is-armed .reveal__line { transform: translateY(110%); }
.reveal.is-armed.is-revealed .reveal__line { transform: translateY(0); }

@media (prefers-reduced-motion: reduce) {
  .reveal.is-armed .reveal__line { transform: none; transition: none; }
}
</style>

<script type="module">
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;

for (const root of document.querySelectorAll("[data-reveal]")) {
  const lines = root.querySelectorAll(".reveal__line");
  lines.forEach((line, i) => line.style.setProperty("--reveal-delay", i * 70 + "ms"));

  if (reduce) continue;
  root.classList.add("is-armed");
}

if (!reduce) {
  await document.fonts.ready;

  const io = new IntersectionObserver(
    (entries) => {
      for (const entry of entries) {
        if (!entry.isIntersecting) continue;
        entry.target.classList.add("is-revealed");
        io.unobserve(entry.target);
      }
    },
    { threshold: 0.25 },
  );

  for (const root of document.querySelectorAll("[data-reveal]")) io.observe(root);
}
</script>

Use it when

  • The hero statement, once per page.
  • Section openers on long pages, at a shorter duration than the hero.

Avoid it when

  • On every heading — the effect is a punctuation mark and stops working when it is the grammar.
  • On text that must be selectable and copyable during the animation.

Accessibility

  • Under `prefers-reduced-motion: reduce`, show the final state immediately — do not leave the text translated out of view.
  • Keep the lines as inline spans of one heading so the accessible name is the whole sentence.
  • Do not gate the reveal on JavaScript alone; if the observer never fires, the text must still be visible.

Performance

  • Animate `transform` only. Animating `top` or `margin` forces layout on every frame.
  • Add `will-change: transform` on the inner element and remove it when the animation completes.
  • Wait for `document.fonts.ready` before revealing, or the first frame shows the fallback face.

Knobs

distance
How far the line travels. 100% of its own height reads cleanest. Default: 100%
stagger
Delay between lines. Default: 70ms

Composition

Instruction for Claude Code

Implement a masked line reveal: wrap each headline line in an overflow-hidden block with 0.08em bottom padding for descenders, and translate the inner span from 110% to 0 with a 640ms ease-out-expo transition staggered 70ms per line. The CSS default state must be visible — JavaScript adds an is-armed class to opt into the hidden state so no-JS and failed-script cases still show the text. Trigger with IntersectionObserver at 0.25 threshold after document.fonts.ready, unobserve after firing, and skip arming entirely under prefers-reduced-motion.