Classic CSS Typewriter Line

A single line of monospace type that writes itself out, holds, erases and starts again, driven only by a width animation in ch units and the steps() timing function. A block caret blinks at the end of the line. The box is sized to the finished sentence, so the button and copy below never move, and the font scales with its container so the line fits a 360 pixel phone.

Code

import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "framer-motion";

// The typed line. Its length sets the steps() count and the ch width below.
const LINE = "Open until the last loaf sells out.";

export default function ClassicCssTypewriterLine() {
  const rootRef = useRef<HTMLElement>(null);
  const [paused, setPaused] = useState(false);
  const reduce = useReducedMotion();

  useEffect(() => {
    const el = rootRef.current;
    if (!el || typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver(([entry]) => setPaused(!entry.isIntersecting));
    io.observe(el);
    return () => io.disconnect();
  }, []);

  return (
    <section
      ref={rootRef}
      data-paused={paused ? "true" : "false"}
      data-still={reduce ? "true" : "false"}
      className="ctw-root bg-white px-4 py-20 sm:py-28"
      style={{ ["--ctw-n" as string]: LINE.length }}
    >
      <style>{`
        .ctw-root { container-type: inline-size; }
        .ctw-line {
          display: inline-block; position: relative;
          font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
          font-size: min(2.75rem, 4.3cqi); font-weight: 600; letter-spacing: 0;
          color: #1e293b; width: calc(var(--ctw-n) * 1ch + 0.6ch); text-align: left; white-space: nowrap;
        }
        .ctw-typed {
          display: inline-block; overflow: hidden; vertical-align: bottom;
          width: calc(var(--ctw-n) * 1ch);
          animation: ctw-type 7s infinite; animation-delay: -1.3s;
        }
        .ctw-caret {
          display: inline-block; width: 0.55ch; height: 1.1em; margin-left: 0.05ch; vertical-align: -0.18em;
          background: #c2410c; animation: ctw-blink 1s steps(1) infinite;
        }
        @keyframes ctw-type {
          0% { width: 0; animation-timing-function: steps(${LINE.length}, end); }
          42% { width: calc(var(--ctw-n) * 1ch); animation-timing-function: linear; }
          86% { width: calc(var(--ctw-n) * 1ch); animation-timing-function: steps(${LINE.length}, end); }
          98%, 100% { width: 0; }
        }
        @keyframes ctw-blink { 0%, 55% { opacity: 1; } 56%, 100% { opacity: 0; } }
        .ctw-root[data-paused="true"] .ctw-typed, .ctw-root[data-paused="true"] .ctw-caret { animation-play-state: paused; }
        .ctw-root[data-still="true"] .ctw-typed, .ctw-root[data-still="true"] .ctw-caret { animation: none; }
        @media (prefers-reduced-motion: reduce) { .ctw-typed, .ctw-caret { animation: none; } }
      `}</style>
      <div className="mx-auto max-w-4xl text-center">
        <p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-600">Marlow Lane Bakery</p>
        <h1 className="mt-6">
          <span className="sr-only">{LINE}</span>
          <span className="ctw-line" aria-hidden="true">
            <span className="ctw-typed">{LINE}</span>
            <span className="ctw-caret" />
          </span>
        </h1>
        <p className="mx-auto mt-6 max-w-md text-base text-slate-600">Doors at seven, rye on Tuesdays, and the oven stays warm until the shelves are empty.</p>
        <a href="#" className="mt-8 inline-flex h-11 items-center rounded-full bg-slate-900 px-6 text-sm font-semibold text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-700 focus-visible:ring-offset-2">See today&apos;s bake</a>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/classic-css-typewriter-line-with-steps-and-blinking-caret.json

When to use it

How to customize it

Accessibility

Questions

Why does the width use ch units?

In a monospace font every character is one ch wide, so a width of 35ch shows exactly 35 characters. Paired with steps(35), each step of the animation reveals exactly one whole character instead of sliding through half letters.

Can I use this with a normal, proportional font?

Not reliably. Letters like i and m have different widths, so equal steps would cut through glyphs. Use the clip-path version in this collection, which reveals each letter on its own, or a small script that adds characters one at a time.

How do I make it type once and stop instead of looping?

Replace the keyframes with a single from width 0 to the full width, give it steps matching the character count, and set animation-iteration-count to 1 with animation-fill-mode forwards. Keep the caret blinking so the line still feels finished.

Why does the line start halfway through on page load?

The negative animation-delay starts the loop part of the way in, so a visitor sees words straight away instead of an empty heading. Set the delay to 0s if you want every visit to begin from the first letter.

Will the text wrap on small screens?

No, the line is set to nowrap so the steps stay accurate. Instead, the font size follows the container width through cqi units, shrinking the type until all 35 characters fit inside the padding.

Related: 112 CSS Text Effects and Hover Animations, CSS Animated Background Set: Gradient Mesh, Dot Grid Spotlight, Aurora and Grain.

More text effects or all free UI components. Start for free to have a full site built around it.

More in Text Effects

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap