Skip to content
Buse Turgay Sönmez

Design Engineer, Belgrade

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
1.00×
Stagger
0.12 s
Rise
105%

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>
  );
}
--ground
oklch(0.985 0.003 85)
--surface
oklch(0.958 0.005 85)
--raised
oklch(1 0 0)
--ink
oklch(0.2 0.006 85)
--muted
oklch(0.43 0.008 85)
--faint
oklch(0.5 0.008 85)
--line
oklch(0.9 0.004 85)
--line-strong
oklch(0.6 0.006 85)
--accent
oklch(0.66 0.22 38)
--accent-ink
oklch(0.52 0.19 38)
--stage
oklch(0.152 0.004 85)
--radius-stage
36px
--radius-stage-sm
28px
--radius-media
24px
--radius-panel
16px
--radius-field
10px
--ease-out-expo
cubic-bezier(0.16, 1, 0.3, 1)

This week · W39One WebGL context, three leaksGeometry · 2026