Pure CSS Text effects #text #loop #scene

Opening crawl in pure CSS

Paragraphs scroll away on a plane tipped back 55°. The fade into the distance is an overlay on top, because a mask on a 3D ancestor would flatten the scene.

  • 85 lines of CSS
  • 17 lines of HTML
  • No JavaScript
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. The camera is moved to the bottom edge with perspective-origin: 50% 100%, instead of the default centre, so everything converges toward a vanishing point above the text rather than the middle of the box.
  2. The "floor" is hinged on its own bottom edge (transform-origin: 50% 100%) and tipped away from the viewer with rotateX(55deg).
  3. Two copies of the text sit on that floor, half an animation apart (animation-delay -7s and -22s), so the crawl is never empty while one copy finishes and the other is only half way up. The 7s is where the loop starts: the first copy’s title and opening paragraph already in view, so a paused card shows the crawl’s opening, not its illegible far end.
  4. The fade into the distance is a plain gradient overlay on top of the tipped plane, not a mask on it — masking a 3D ancestor would flatten the whole scene.
  5. Only transform: translateY(...) animates the text, so the scroll runs on the compositor even though the paragraphs are long.
  6. The crawl fills the canvas (inset: 0); the plane, the text and the fade are multiples of one base unit, --u, tied to the canvas, so the crawl reads the same on a gallery card and on a full screen.

Key techniques

  • perspective-origin at the bottom edge
  • rotateX(55deg) hinged on the bottom
  • translateY loop, two copies half a loop apart
  • gradient overlay instead of mask

Copy-paste code

HTML 17 lines

<div class="crawl">
  <div class="plane">
    <div class="text">
      <h4>Episode 3D</h4>
      <p>It is a period of flat design. Rebel stylesheets, striking from a hidden folder, have won their first victory against the evil Canvas Empire.</p>
      <p>During the battle, a single rotateX managed to tip an entire paragraph back into the distance, using nothing but perspective and a parent that owns it.</p>
      <p>Pursued by heavy JavaScript bundles, the text now scrolls home along its plane, with no script on board at all...</p>
    </div>
    <div class="text" aria-hidden="true">
      <h4>Episode 3D</h4>
      <p>It is a period of flat design. Rebel stylesheets, striking from a hidden folder, have won their first victory against the evil Canvas Empire.</p>
      <p>During the battle, a single rotateX managed to tip an entire paragraph back into the distance, using nothing but perspective and a parent that owns it.</p>
      <p>Pursued by heavy JavaScript bundles, the text now scrolls home along its plane, with no script on board at all...</p>
    </div>
  </div>
  <div class="fade"></div>
</div>

CSS 85 lines

.crawl {
  /* one base unit: every length below is a multiple of it, so the crawl is the same share of a
     card, the editor, a full screen and a recording canvas */
  --u: 0.36vmin;
  position: fixed;
  inset: 0;
  overflow: hidden;
  background: #05060f;
  /* the camera sits at the bottom edge; with the plane tipped back 55deg the text converges on
     a vanishing line above the bottom, whatever the box's own size */
  perspective: calc(420 * var(--u));
  perspective-origin: 50% 100%;
}

/* hinged on the bottom edge and tipped away from the viewer; it clips its own flat content
   (fine here: nothing 3D lives inside it), so text never passes in front of the hinge */
.plane {
  position: absolute;
  right: 0;
  bottom: 0;
  left: 0;
  /* a fixed number of units, not a share of the canvas: a column that narrowed with a square or
     tall canvas broke its lines in other places, so the text ran longer and a different part of
     it was in view at the same moment. 240 units is 86vmin, inside even a 9:16 canvas */
  width: calc(240 * var(--u));
  height: calc(950 * var(--u));
  margin: 0 auto;
  overflow: hidden;
  transform: rotateX(55deg);
  transform-origin: 50% 100%;
}

/* two copies of the text, half a loop apart, so the floor is never empty: each one starts just
   below the near edge and leaves completely past the far edge, so both ends stay invisible. The
   first is 7s in at the first frame, its EPISODE 3D title mid-canvas, which a paused card shows */
.text {
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  margin: 0;
  color: #ffb547;
  font-size: calc(17 * var(--u));
  font-weight: 700;
  line-height: 1.45;
  text-align: justify;
  animation: crawl-roll 30s linear -7s infinite;
}

.text + .text {
  animation-delay: -22s;
}

.text h4 {
  margin: 0 0 0.6em;
  font-size: 1.5em;
  letter-spacing: 0.08em;
  text-align: center;
  text-transform: uppercase;
}

.text p {
  margin: 0 0 1em;
}

/* the fade is a plain overlay ABOVE the plane: a mask on the plane's ancestor would flatten it */
.fade {
  position: absolute;
  inset: 0;
  background:
    radial-gradient(circle at 12% 18%, #fff calc(0.7 * var(--u)), transparent calc(1.3 * var(--u))),
    radial-gradient(circle at 31% 9%, #cfd6ff calc(0.7 * var(--u)), transparent calc(1.3 * var(--u))),
    radial-gradient(circle at 55% 22%, #fff calc(1 * var(--u)), transparent calc(1.7 * var(--u))),
    radial-gradient(circle at 72% 12%, #ffe9c2 calc(0.7 * var(--u)), transparent calc(1.3 * var(--u))),
    radial-gradient(circle at 88% 27%, #fff calc(0.7 * var(--u)), transparent calc(1.3 * var(--u))),
    radial-gradient(circle at 22% 34%, #cfd6ff calc(0.7 * var(--u)), transparent calc(1.3 * var(--u))),
    radial-gradient(circle at 93% 6%, #fff calc(1 * var(--u)), transparent calc(1.7 * var(--u))),
    linear-gradient(to top, transparent calc(90 * var(--u)), #05060f calc(185 * var(--u)));
  pointer-events: none;
}

@keyframes crawl-roll {
  from { transform: translateY(calc(950 * var(--u))); }
  to   { transform: translateY(-100%); }
}
Standalone snippet · plain CSS, no build step, no dependencies.