Masked Reveal
A line by line text reveal with no animation library. I wrap each line in a mask that clips it. The line starts 105% below the mask and rises in 560 ms. One keyframe moves every line, and a CSS variable delays each by its index. I write the line breaks by hand, so nothing is measured at runtime.
- 2026
- CSS
- Type
- Motion
Controls
Speed
Stagger
Rise
Origin
The timing was measured on phobos.com.au, which uses GSAP and SplitText for it, and rebuilt without any animation library.
Source
masked-reveal.module.css116 lines
/*
Masked Reveal. Each line sits in a mask that clips it; the line starts below the mask and rises
into place. Pure CSS: one keyframe for every line, and a line's delay is calc()'d from its index.
Set by the component:
--mr-dur one full cycle (5s at speed 1)
--mr-stagger delay between two lines (0.12s)
--mr-rise how far below the mask a line starts, in % of its own height (105%)
--i the line's index, inline on each line
On the page this runs once, on a view() timeline, as the text scrolls in. The stage does not
scroll, so here the same move loops on a clock: rise, hold, leave, wait.
*/
/* A size container, so .frame below can ask how tall the stage is. */
.root {
container-type: size;
}
/*
The caption pill and the pause button own the bottom 76px of the stage. The frame is a size
container too: the type is sized from what is left with cqw and cqh.
*/
.frame {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 16px 16px 76px;
container-type: size;
}
/* A tall stage has room to spare: equal padding puts the text on the true centre. */
@container (min-height: 560px) {
.frame {
padding-block: 76px;
}
}
/*
Lines are given, not measured: no splitting at runtime, so no layout thrash and no flash of
unsplit text. The longest line is about 5.6em wide and the block 4.1em tall; whichever of the
stage's width and height runs out first sets the size.
*/
.text {
font-size: min(15cqw, 20cqh, 7.5rem);
line-height: 1;
font-weight: 500;
white-space: nowrap;
}
.mask {
display: block;
overflow: hidden;
/* Room for descenders, which the mask edge would cut; the negative margin gives the space back. */
padding-bottom: 0.12em;
margin-bottom: -0.12em;
}
/* The base state is the finished state. Without the animation the text is simply there. */
.line {
display: block;
}
/*
560 ms in and 560 ms out of a 5 s cycle: 11.2% each. The hold is 2.84 s and the last 1.04 s is
empty, so at the widest stagger (0.3 s, 0.9 s for line 4) the last line is gone before the first
one comes back.
Both moves use the site's one easing, --ease-out-expo, written out: Chrome drops a var() in a
keyframe's timing function and falls back to linear.
*/
@keyframes mr-cycle {
0% {
transform: translateY(var(--mr-rise, 105%));
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
11.2% {
transform: translateY(0);
animation-timing-function: linear;
}
68% {
transform: translateY(0);
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
}
79.2%,
100% {
transform: translateY(calc(var(--mr-rise, 105%) * -1));
}
}
/*
The site's global reduced-motion rule leaves stage pieces alone, so the piece answers for itself:
with reduced motion there is no animation at all and the lines are shown.
*/
@media (prefers-reduced-motion: no-preference) {
.line {
/* `both`: during its delay a line waits below its mask, not in place. */
animation: mr-cycle var(--mr-dur, 5s) linear infinite both;
animation-delay: calc(var(--mr-stagger, 0.12s) * var(--i, 0));
will-change: transform;
}
/*
A piece that arrives already paused (Save-Data, or the visitor paused earlier) would freeze at
0% and show an empty stage. It starts 40% into the cycle instead: every line is in its hold.
*/
.still .line {
animation-delay: calc(var(--mr-dur, 5s) * -0.4 + var(--mr-stagger, 0.12s) * var(--i, 0));
}
/* The stage's pause button: the lines stop where they are, mid-move included. */
.paused .line {
animation-play-state: paused;
}
}piece.tsx49 lines
"use client";
import { useState, type CSSProperties } from "react";
import type { DomPieceProps } from "@/entries/types";
import { cn } from "@/lib/utils";
import styles from "./masked-reveal.module.css";
/**
* A masked line reveal: each line rises out from behind a clip, one after another. Measured on
* phobos.com.au, which spends GSAP, SplitText, ScrollTrigger and Lenis on it (198 split elements,
* 17 triggers). This one is a keyframe and a CSS variable per line. From
* clients/alignscale/alignscale-web, components/Reveal.tsx.
*
* The lines are written out, not split at runtime: where display type breaks is a decision, not
* something to leave to the viewport.
*/
const LINES = ["Designed", "and built", "by the same", "hands."] as const;
/** Seconds for one loop at speed 1: 0.56 in, 2.84 held, 0.56 out, 1.04 empty. */
const CYCLE = 5;
export default function MaskedReveal({ values, paused }: DomPieceProps) {
const speed = typeof values.speed === "number" ? values.speed : 1;
const stagger = typeof values.stagger === "number" ? values.stagger : 0.12;
const rise = typeof values.rise === "number" ? values.rise : 105;
// Read once: if the piece arrives paused it starts inside its hold, so the still frame has text in it.
const [still] = useState(paused);
// Speed 0 is a stop, not a division by zero. The stagger scales with the speed so the choreography keeps its shape.
const pace = speed > 0 ? speed : 1;
const vars = { "--mr-dur": `${CYCLE / pace}s`, "--mr-stagger": `${stagger / pace}s`, "--mr-rise": `${rise}%` } as CSSProperties;
return (
<div aria-hidden="true" className={cn(styles.root, "h-full w-full", still && styles.still, (paused || speed <= 0) && styles.paused)} style={vars}>
<div className={styles.frame}>
<p className={cn(styles.text, "display text-ink")}>
{LINES.map((line, i) => (
<span key={line} className={styles.mask}>
<span className={styles.line} style={{ "--i": i } as CSSProperties}>
{line}
</span>
</span>
))}
</p>
</div>
</div>
);
}