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.
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
<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
Conflicts with
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.