How to build a CRT / terminal aesthetic with pure CSS
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.
/* 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="...".
/* 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%:
/* 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:
/* 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.
/* 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
::afterwith arepeating-linear-gradient; always addpointer-events: none. - The glitch duplicates text via
content: attr(data-text)and slices it withclip-path: polygon(...)for a few percent of the timeline. step-endandsteps(n, end)are what make the cursor blink and the text type instead of fading smoothly.- Use
forwardsso width/opacity animations hold their final frame. - Keep each effect a single-purpose class so you can mix and match them.