ehsan.blog
~/blog/how-to-build-a-crt-terminal-aesthetic-with-css — zsh
cat how-to-build-a-crt-terminal-aesthetic-with-css.md

How to build a CRT / terminal aesthetic with pure CSS

·6 min read

I wanted my portfolio to feel like an old CRT monitor running a terminal — faint scanlines drifting over everything, headings that glitch for a split second, a blinking block cursor, and text that types itself out. The fun part is that none of it needs JavaScript. It’s all CSS, and once you see the four tricks that make it work you can drop them into any project.

Everything below is the real CSS from my src/index.css. Let’s build the look one layer at a time.

The layers we’re stacking

graph TD
  A[Page content] --> B[Scanline overlay ::after]
  A --> C[Glitch layers ::before / ::after]
  A --> D[Terminal cursor ::after]
  A --> E[Typing effect width animation]

Each effect is a small, self-contained utility class. You add the class to an element and the CSS does the rest.

1. Scanlines: a striped overlay on top of everything

A CRT screen draws horizontal lines, so the whole page gets a subtle repeating dark stripe. The trick is a full-screen ::after pseudo-element with a repeating-linear-gradient background. “Pseudo-element” just means an extra box CSS generates for you without extra HTML.

css
/* src/index.css */
.scanlines::after {
  content: "";
  position: fixed;
  inset: 0;
  background: repeating-linear-gradient(
    0deg,
    transparent,
    transparent 2px,
    oklch(0 0 0 / 0.04) 2px,
    oklch(0 0 0 / 0.04) 4px
  );
  pointer-events: none;
  z-index: 200;
}

The gradient repeats every 4px: 2px of transparent, then 2px of nearly-black at 4% opacity (oklch(0 0 0 / 0.04) — OKLCH is just a color format, and the number after the slash is alpha). position: fixed; inset: 0 pins it over the entire viewport. Two details matter a lot: pointer-events: none so the overlay never eats clicks, and a high z-index so it sits above the content. Put class="scanlines" on a wrapper element and the whole page gets the effect.

2. The glitch: two clip-path clones of the text

This is the showpiece. The idea: duplicate the element’s text twice with pseudo-elements, then for a brief moment slice each clone into horizontal bands with clip-path and shove them sideways. clip-path with a polygon() only shows the part of the element inside that shape — so a band appears while the rest vanishes.

First, how the clones get their text. Both pseudo-elements pull from a data-text attribute, so in your HTML you write the text twice: once as content, once as data-text="...".

css
/* src/index.css */
.glitch {
  position: relative;
}
.glitch::before {
  content: attr(data-text);
  position: absolute;
  inset: 0;
  color: oklch(0.85 0 0);
  animation: glitch-a 6s infinite;
}
.glitch::after {
  content: attr(data-text);
  position: absolute;
  inset: 0;
  color: oklch(0.6 0 0);
  animation: glitch-b 6s infinite;
}

content: attr(data-text) copies whatever you put in the attribute. The two clones stack exactly on top of the real text (position: absolute; inset: 0) in slightly different grays, so most of the time you don’t notice them.

Now the animation. Both keyframes sit still for 90% of the cycle, then flicker between 92% and 97%:

css
/* src/index.css */
@keyframes glitch-a {
  0%, 90%, 100% {
    clip-path: none;
    transform: none;
  }
  92% {
    clip-path: polygon(0 10%, 100% 10%, 100% 40%, 0 40%);
    transform: translate(-3px, 0);
  }
  94% {
    clip-path: polygon(0 60%, 100% 60%, 100% 80%, 0 80%);
    transform: translate(3px, 0);
  }
  96% {
    clip-path: polygon(0 20%, 100% 20%, 100% 50%, 0 50%);
    transform: translate(-2px, 0);
  }
}

Each polygon() describes a rectangle spanning the full width (0 to 100%) but only a slice of the height — for example 10% to 40% down. At that keyframe only that horizontal band of the clone shows, and translate nudges it left or right. Because the timing jumps quickly between different bands, your eye reads it as electrical interference. The second layer (glitch-b) uses different bands plus an opacity flicker so the two clones tear in opposite directions.

The 6s infinite means the whole thing loops every six seconds but only glitches for a fraction of it — rare and startling instead of constant and annoying.

3. The blinking terminal cursor

A terminal always has that steady blinking block. It’s a single pseudo-element with a solid block character and an opacity animation:

css
/* src/index.css */
@keyframes blink {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0; }
}
.terminal-cursor::after {
  content: "█";
  animation: blink 1s step-end infinite;
  margin-left: 2px;
  font-size: 0.85em;
}

The is the full-block Unicode character. The magic word is step-end: instead of smoothly fading, the animation snaps between the keyframe values, so the cursor jumps hard from visible to invisible — exactly like a real terminal. Add class="terminal-cursor" after any text.

4. Typing text with steps()

Finally, the typewriter reveal. It works by animating the element’s width from zero to full while clipping the overflow, so the text is uncovered character by character.

css
/* src/index.css */
@keyframes typing {
  from { width: 0; }
  to   { width: 100%; }
}
.typing-effect {
  overflow: hidden;
  white-space: nowrap;
  animation: typing 2s steps(40, end) forwards;
  border-right: 2px solid oklch(0.92 0 0);
}

overflow: hidden plus white-space: nowrap keep the text on one line and hide whatever hasn’t been “typed” yet. steps(40, end) is the key — instead of a smooth slide, the width jumps in 40 discrete steps, so the text appears in chunks like keystrokes. The border-right fakes a cursor sitting at the end of the line, and forwards makes the animation hold its final state so the full text stays visible when it’s done.

Putting it together

These classes are composable. A hero heading in my markup carries the glitch clones and a blinking cursor; a wrapper carries .scanlines; a tagline carries .typing-effect. Each does one thing, and layering them gives the full CRT terminal feel with zero JavaScript — the browser’s animation engine does all the work.

One caveat worth flagging now: constant motion can make some people ill or distracted. I gate all of this behind prefers-reduced-motion so it can be switched off — that’s a whole post of its own. The same CSS techniques power the animated skill bars and the blur-in image reveal elsewhere on the site, and the whole look is chronicled in building a CRT-terminal portfolio.

What to remember

  • Scanlines are one fixed ::after with a repeating-linear-gradient; always add pointer-events: none.
  • The glitch duplicates text via content: attr(data-text) and slices it with clip-path: polygon(...) for a few percent of the timeline.
  • step-end and steps(n, end) are what make the cursor blink and the text type instead of fading smoothly.
  • Use forwards so width/opacity animations hold their final frame.
  • Keep each effect a single-purpose class so you can mix and match them.
ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.