MEMBLOG

Typewriter effect with pure CSS

- 2025-01-09

Jump to the bottom if you'd like to just read the code.

Our new loading screen for the Membrane editor includes a typewriter effect written using CSS animations.

My first instinct was to use a timer in a React effect hook to append each character one by one to a message stored in state.

That worked ok, but we noticed that the animation was a bit choppy, which in moderation is palatable for a typing animation (because typing is choppy in real life) but in excess is maddening. Here's what the animation looks like when throttling the CPU 4x in browser devtools on an M1 Mac:

While the loading screen renders, a whole lot of JavaScript (probably too much) is being executed in the background to power our vscode-based editor. All that JS was competing with our typewriter effect to produce a sluggish animation. The pure CSS alternative with the same 4x CPU throttling is noticeably smoother:

CSS animations are much more performant in this case and in general. Per MDN:

[CSS] animations run well, even under moderate system load. Simple animations can often perform poorly in JavaScript. The rendering engine can use frame-skipping and other techniques to keep the performance as smooth as possible.

To illustrate the performance difference, I created a contrived example program in Membrane, pete/css-animations, that does arbitrary computation while running both CSS and JavaScript versions of a typewriter animation.

Without the heavy competing work on the main thread, both animations have the exact same timing, like Olympic synchronized divers. You can see the live demo here.

Animating with JavaScript

Here's the initial implementation in React:

// Loading.tsx

import { useEffect, useState } from "react";
import styles from "./Loading.module.css";

export default function Loading() {
  const TEXT = "Membrane";
  const [message, setMessage] = useState("");

  useEffect(() => {
    if (message.length === TEXT.length) return;

    const timer = setTimeout(() => {
      setMessage(TEXT.slice(0, message.length + 1));
    }, 100);

    return () => clearTimeout(timer);
  }, [message]);

  return (
    <main>
      <svg>{/* M logo */}</svg>
      <h1 className={styles.typewriter}>{message}</h1>
    </main>
  );
}
/* Loading.module.css */

.typewriter::after {
  content: "";
  border-left: 2px solid #000;
  animation: blink 1s step-end infinite;
}

@keyframes blink {
  50% { opacity: 0; }
}

ANIMATING WITH PURE CSS

And here's the CSS version:

// Loading.tsx

import styles from "./Loading.module.css";

export default function Loading() {
  return (
    <main className={styles.main}>
      <svg>{/* M logo */}</svg>
      <h1 className={styles.typewriter}>Membrane</h1>
    </main>
  );
}
/* Loading.module.css */

.typewriter {
  overflow: hidden;
  white-space: nowrap;
  animation: typing 800ms steps(8, end);

  &::after {
    content: "";
    border-left: 2px solid #000;
    animation: blink 1s step-end infinite;
  }
}

@keyframes typing {
  from { width: 0; }
  to { width: 8ch; }
}

@keyframes blink {
  50% { opacity: 0; }
}

I also like that CSS animations keep the markup clean and leave styling to the stylesheet.

This was a fun, quick win (it took longer to write the blog post). We'll keep chipping away on making the editor smoother and snappier. On that note, we're always open to feedback—let us know if you have any CSS tips!


If you're a programmer building quick prototype websites, APIs, or automations for your engineering team or personal use, you should consider using Membrane. We provide instant deploy-on-save and easy connectivity to APIs, among many other features. Sign up at membrane.io or book a live demo.


- Pete Millspaugh (pete@membrane.io)

© 2024 Programmability, Inc.