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.

  • 78 lines of CSS
  • 17 lines of HTML
  • No JavaScript
  • No dependencies
  • MIT licensed

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: -15s on the second), so the crawl is never empty while one copy finishes and the other is only half way up.
  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.

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 78 lines

.crawl {
  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: 420px;
  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;
  width: min(74%, 310px);
  height: 950px;
  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 */
.text {
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  margin: 0;
  color: #ffb547;
  font-size: 17px;
  font-weight: 700;
  line-height: 1.45;
  text-align: justify;
  animation: crawl-roll 30s linear infinite;
}

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

.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 0.7px, transparent 1.3px),
    radial-gradient(circle at 31% 9%, #cfd6ff 0.7px, transparent 1.3px),
    radial-gradient(circle at 55% 22%, #fff 1px, transparent 1.7px),
    radial-gradient(circle at 72% 12%, #ffe9c2 0.7px, transparent 1.3px),
    radial-gradient(circle at 88% 27%, #fff 0.7px, transparent 1.3px),
    radial-gradient(circle at 22% 34%, #cfd6ff 0.7px, transparent 1.3px),
    radial-gradient(circle at 93% 6%, #fff 1px, transparent 1.7px),
    linear-gradient(to top, transparent 90px, #05060f 185px);
  pointer-events: none;
}

@keyframes crawl-roll {
  from { transform: translateY(950px); }
  to   { transform: translateY(-100%); }
}

Sass source 90 lines

$space: #05060f;
$len: 950px; // length of the floor the text travels along

// A fill scene, always "night": it is outer space in both themes.
.d-crawl {
  position: absolute;
  inset: 0;
  overflow: hidden; // fine here: this element OWNS the perspective, it is not between it and the plane
  background: $space;
  // The camera sits at the bottom edge. With the plane tipped back 55deg the text converges on
  // a vanishing line 420px * cot(55deg) = 294px above the bottom, whatever the stage size.
  perspective: 420px;
  perspective-origin: 50% 100%;

  // The floor: hinged on the bottom edge and tipped away from the viewer. It clips its own flat
  // content (allowed: nothing 3D lives inside it), so text never comes in front of the hinge,
  // where it would pass through the camera.
  &__plane {
    position: absolute;
    right: 0;
    bottom: 0;
    left: 0;
    width: min(74%, 310px);
    height: $len;
    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: both ends are invisible,
  // which makes the loop seamless.
  &__text {
    position: absolute;
    top: 0;
    right: 0;
    left: 0;
    margin: 0;
    color: #ffb547;
    font-size: 17px;
    font-weight: 700;
    line-height: 1.45;
    text-align: justify;
    animation: d-crawl-roll 30s linear infinite;

    & + & {
      animation-delay: -15s;
    }

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

    p {
      margin: 0 0 1em;
    }
  }

  // The fade into the distance is a plain overlay ABOVE the plane. A mask or opacity on the
  // plane's ancestor would flatten the 3D. Measured from the bottom, like the vanishing line.
  &__fade {
    position: absolute;
    inset: 0;
    background:
      radial-gradient(circle at 12% 18%, #fff 0.7px, transparent 1.3px),
      radial-gradient(circle at 31% 9%, #cfd6ff 0.7px, transparent 1.3px),
      radial-gradient(circle at 55% 22%, #fff 1px, transparent 1.7px),
      radial-gradient(circle at 72% 12%, #ffe9c2 0.7px, transparent 1.3px),
      radial-gradient(circle at 88% 27%, #fff 0.7px, transparent 1.3px),
      radial-gradient(circle at 22% 34%, #cfd6ff 0.7px, transparent 1.3px),
      radial-gradient(circle at 93% 6%, #fff 1px, transparent 1.7px),
      linear-gradient(to top, transparent 90px, $space 185px);
    pointer-events: none;
  }
}

@keyframes d-crawl-roll {
  from {
    transform: translateY($len);
  }

  to {
    transform: translateY(-100%);
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.