← Blog9 min read#cyberpunk#terminal#css

Cyberpunk Terminal UI: Hacker Aesthetic Shell, Scanlines, Glitch

Build a full cyberpunk terminal UI in React - scanlines, CRT flicker, glitch text, and that unmistakable hacker-green shell aesthetic. Here's how it actually works.

Dark terminal screen with green glitch text and CRT scanlines

What You're Actually Building

A cyberpunk terminal UI isn't just slapping green text on a black background and calling it a day. What makes it feel genuinely hacker-core is the combination of CRT artifacts - scanlines, phosphor glow, occasional flicker - layered on top of a monospace shell that feels like it's running on hardware from 1994 but processing at 2080 speeds. That tension is the whole point.

You're looking at four distinct visual layers working together: the base shell with its prompt and output lines, a CSS scanline overlay sitting at around 30% opacity, a glitch animation that fires on certain text elements, and an ambient phosphor-green glow using text-shadow and box-shadow. Miss any one of these and it reads as 'dark mode' rather than 'cyberpunk'. There's a difference.

Worth noting: the cyberpunk style hub on Empire UI has production-ready components that handle most of this for you. But understanding the underlying mechanics means you can customize them - and you absolutely will need to once your PM asks for 'the glitch thing but like, more subtle'.

Honestly, the most important decision you'll make is font choice. JetBrains Mono, IBM Plex Mono, or the classic Courier New at 14px all give you that authentic terminal weight. Anything with ligatures is going to undercut the effect immediately.

The CRT Scanline Effect

Scanlines are the backbone of this whole aesthetic. The technique is a pseudo-element overlay with a repeating linear gradient - dead simple in theory, finicky in execution. The key is that you want the lines to be barely perceptible at 1x zoom but clearly visible when someone looks directly at the screen. Too strong and it's distracting. Too weak and you might as well not bother.

.terminal-container {
  position: relative;
  background: #0a0a0a;
  color: #00ff41;
  font-family: 'JetBrains Mono', 'Courier New', monospace;
  font-size: 14px;
  line-height: 1.6;
  padding: 24px;
  overflow: hidden;
}

.terminal-container::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: repeating-linear-gradient(
    0deg,
    transparent,
    transparent 2px,
    rgba(0, 0, 0, 0.15) 2px,
    rgba(0, 0, 0, 0.15) 4px
  );
  pointer-events: none;
  z-index: 10;
}

That pointer-events: none on the pseudo-element is non-negotiable - skip it and your entire terminal becomes unclickable. The 4px total height per scanline pair gives you the classic CRT band spacing. You can push it to 3px/6px for a heavier look if you're going for a retro 1980s vibe rather than 2049 dystopian.

Quick aside: if you add animation: flicker 0.15s infinite to the ::before pseudo-element that alternates opacity between 1 and 0.98, you get that subtle CRT power fluctuation. Very light touch. Most users won't consciously notice it, but they'll feel it.

One more thing - on high-DPI retina displays the scanlines can look too fine and disappear. Use a media query to double the gradient repeat size at @media (-webkit-min-device-pixel-ratio: 2). Otherwise your carefully crafted aesthetic just vanishes for half your audience.

Glitch Text Animation: The CSS-Only Approach

The glitch effect is what turns a dark terminal into something that feels genuinely broken in an interesting way. There are JavaScript libraries that do this - most of them are overkill. CSS keyframes with clip-path and transform: translate will get you 90% of the visual result with zero runtime overhead.

@keyframes glitch {
  0%, 100% {
    transform: translate(0);
    text-shadow: -2px 0 #ff003c, 2px 0 #00fff9;
    clip-path: inset(0 0 100% 0);
  }
  10% {
    transform: translate(-3px, 1px);
    text-shadow: 3px 0 #ff003c, -3px 0 #00fff9;
    clip-path: inset(10% 0 60% 0);
  }
  20% {
    transform: translate(3px, -1px);
    clip-path: inset(40% 0 30% 0);
  }
  30% {
    transform: translate(0);
    clip-path: inset(0 0 100% 0);
  }
  60% {
    transform: translate(-2px, 0);
    text-shadow: 2px 0 #ff003c;
    clip-path: inset(70% 0 5% 0);
  }
}

.glitch {
  position: relative;
  animation: glitch 4s steps(1) infinite;
}

.glitch::before,
.glitch::after {
  content: attr(data-text);
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

.glitch::before {
  left: 2px;
  text-shadow: -2px 0 #ff003c;
  clip-path: inset(20% 0 50% 0);
  animation: glitch 3.5s steps(1) infinite reverse;
}

.glitch::after {
  left: -2px;
  text-shadow: 2px 0 #00fff9;
  clip-path: inset(60% 0 20% 0);
  animation: glitch 4.5s steps(1) infinite;
}

The steps(1) timing function is what gives you that digital snap-cut feel rather than smooth interpolation. Glitch effects should feel wrong - if they ease in and out smoothly they look like a loading spinner, not data corruption. Use attr(data-text) on the pseudo-elements so the content mirrors the actual text automatically.

In practice, you don't want glitch on everything. Apply it to headers, status messages, or the terminal prompt itself - not body output text. A terminal where every line glitches is exhausting to look at and will tank your UX. Think of it like a warning light: it needs to mean something.

Look, the red/cyan color pair (#ff003c and #00fff9) is almost universal in cyberpunk glitch work because it mirrors chromatic aberration from physical CRT misalignment. You can swap in other complementary pairs, but this combination has been the genre standard since 2019 and your users will recognize it instantly.

Building the React Terminal Component

Let's put the pieces together into an actual component. The key design decision here is that the terminal needs to manage its own line history, handle input at a prompt, and display output with a typewriter effect - all without turning into a 400-line state management nightmare.

import { useState, useEffect, useRef } from 'react';

interface TerminalLine {
  type: 'input' | 'output' | 'error' | 'system';
  text: string;
  id: number;
}

export function CyberpunkTerminal() {
  const [lines, setLines] = useState<TerminalLine[]>([
    { type: 'system', text: 'EMPIRE_OS v2.4.1 - NEURAL LINK ESTABLISHED', id: 0 },
    { type: 'system', text: 'Uptime: 847:23:11 | Threat Level: ELEVATED', id: 1 },
  ]);
  const [input, setInput] = useState('');
  const [lineCounter, setLineCounter] = useState(2);
  const bottomRef = useRef<HTMLDivElement>(null);

  const commands: Record<string, string[]> = {
    help: ['Available commands: help, status, clear, whoami'],
    whoami: ['ghost_protocol', 'clearance: ULTRAVIOLET', 'location: [REDACTED]'],
    status: ['firewall: ACTIVE', 'vpn: CASCADED (7 nodes)', 'trace: BLOCKED'],
    clear: [],
  };

  const handleCommand = (cmd: string) => {
    const trimmed = cmd.trim().toLowerCase();
    const newLines: TerminalLine[] = [
      { type: 'input', text: `> ${cmd}`, id: lineCounter },
    ];

    if (trimmed === 'clear') {
      setLines([]);
      setLineCounter(c => c + 1);
      return;
    }

    const output = commands[trimmed];
    if (output) {
      output.forEach((line, i) => {
        newLines.push({ type: 'output', text: line, id: lineCounter + i + 1 });
      });
    } else {
      newLines.push({ type: 'error', text: `command not found: ${cmd}`, id: lineCounter + 1 });
    }

    setLines(prev => [...prev, ...newLines]);
    setLineCounter(c => c + newLines.length);
  };

  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
  }, [lines]);

  return (
    <div className="terminal-container">
      <div className="terminal-header">
        <span className="terminal-title" data-text="HACK_TERM_0x1A">HACK_TERM_0x1A</span>
      </div>
      <div className="terminal-body">
        {lines.map(line => (
          <div key={line.id} className={`terminal-line terminal-line--${line.type}`}>
            {line.text}
          </div>
        ))}
        <div className="terminal-input-row">
          <span className="terminal-prompt">ghost@empire:~$</span>
          <input
            className="terminal-input"
            value={input}
            onChange={e => setInput(e.target.value)}
            onKeyDown={e => {
              if (e.key === 'Enter') {
                handleCommand(input);
                setInput('');
              }
            }}
            autoFocus
          />
        </div>
        <div ref={bottomRef} />
      </div>
    </div>
  );
}

The data-text attribute on .terminal-title is what powers the glitch pseudo-element content trick from the previous section - wire up the CSS and it just works. That's the kind of clean separation between markup and style that makes this pattern actually maintainable.

That said, you'll want to throttle the auto-scroll if you're pumping a lot of output lines fast. Calling scrollIntoView on every single line update during a rapid-fire output sequence causes visible jitter. Batch the output into a single state update and scroll once.

Worth noting: for accessibility, the <input> here needs a aria-label like "Terminal input". Screen readers will otherwise announce nothing useful when focus lands on it. Cyberpunk doesn't have to mean inaccessible.

Phosphor Glow and Color System

The phosphor glow on terminal text is the detail that separates a convincing build from a half-baked one. Real CRT phosphors bloom outward from the emission point - you want to replicate that with layered text-shadow values that go from a tight bright core to a wider dim halo. Two layers minimum, three is better.

:root {
  --cyber-green: #00ff41;
  --cyber-green-dim: #00b32c;
  --cyber-red: #ff003c;
  --cyber-cyan: #00fff9;
  --cyber-yellow: #ffe600;
  --terminal-bg: #050505;
}

.terminal-line--output {
  color: var(--cyber-green);
  text-shadow:
    0 0 4px var(--cyber-green),
    0 0 10px rgba(0, 255, 65, 0.5),
    0 0 20px rgba(0, 255, 65, 0.2);
}

.terminal-line--error {
  color: var(--cyber-red);
  text-shadow:
    0 0 4px var(--cyber-red),
    0 0 12px rgba(255, 0, 60, 0.4);
}

.terminal-line--system {
  color: var(--cyber-cyan);
  opacity: 0.7;
  font-size: 12px;
  text-shadow: 0 0 8px rgba(0, 255, 249, 0.6);
}

.terminal-prompt {
  color: var(--cyber-yellow);
  text-shadow: 0 0 6px rgba(255, 230, 0, 0.7);
  margin-right: 8px;
  white-space: nowrap;
}

.terminal-input {
  background: transparent;
  border: none;
  color: var(--cyber-green);
  font-family: inherit;
  font-size: inherit;
  outline: none;
  caret-color: var(--cyber-green);
  width: 100%;
  text-shadow: 0 0 4px var(--cyber-green);
}

The cursor blink is handled by caret-color matching your phosphor green - that one CSS property is doing more work aesthetically than you'd think. On Chrome 105+ you can also use caret-shape: block to get a proper terminal block cursor instead of the default thin line. Do it.

Honestly, most cyberpunk implementations use too much color. The hacker-green-on-black combo works because it's focused - if you're throwing in purple, orange, and teal everywhere you've crossed into vaporwave territory. Keep your accent colors functional: green for normal output, red for errors, yellow for the prompt, cyan for system messages. Done.

For the terminal header bar - the title area - a 1px bottom border in var(--cyber-green) with a matching box-shadow: 0 1px 8px rgba(0, 255, 65, 0.4) below it gives you that light-strip-under-glass feel. Compare it to how glassmorphism components use border glow - same principle, different aesthetic register.

Cursor Blink, Typewriter Output, and Timing Details

A static terminal feels dead. The cursor needs to blink - not fade, blink. Steps function, 1s interval, forever. Separately, when the terminal is printing output (like from a simulated command response), a typewriter effect at around 30-50ms per character is fast enough to feel 'computer fast' while still being readable.

@keyframes cursor-blink {
  0%, 100% { opacity: 1; }
  50% { opacity: 0; }
}

.terminal-cursor {
  display: inline-block;
  width: 8px;
  height: 1.1em;
  background: #00ff41;
  margin-left: 2px;
  animation: cursor-blink 1s steps(1) infinite;
  box-shadow: 0 0 6px rgba(0, 255, 65, 0.8);
  vertical-align: text-bottom;
}
function useTypewriter(text: string, speed = 35) {
  const [displayed, setDisplayed] = useState('');

  useEffect(() => {
    setDisplayed('');
    if (!text) return;
    let i = 0;
    const interval = setInterval(() => {
      setDisplayed(text.slice(0, i + 1));
      i++;
      if (i >= text.length) clearInterval(interval);
    }, speed);
    return () => clearInterval(interval);
  }, [text, speed]);

  return displayed;
}

Use the typewriter hook on system boot messages and command output. Don't use it on user input - that's already real-time by definition and adding artificial delay would make the whole thing feel sluggish. The speed param at 35ms is a good baseline; you can drop to 15ms for short one-liners that should snap in.

One more thing - if you want the terminal to feel alive even when idle, add a subtle CRT flicker on the whole container. Randomly toggle a very slight brightness change (filter: brightness(0.97)) using a setInterval at irregular intervals. Humans notice perfectly regular cycles as artificial, but irregular ones read as 'electrical'. You can generate random jitter with Math.random() * 5000 + 3000 as your next timeout delay.

Integrating with Empire UI and Going Further

If you're building this into a larger project, the cyberpunk components on Empire UI give you a solid foundation to extend from - the CSS custom property system is already wired up so you can override the color tokens without fighting specificity wars. That's worth more than it sounds when you're theming across a full dashboard.

The terminal pattern also pairs naturally with other dark-mode-heavy styles. Check out what happens when you layer the glitch text technique over the aurora background style - you get this interesting effect where the northern-light gradients bleed through the scanlines. It's not strictly 'cyberpunk' anymore but it's striking. Sometimes happy accidents become design decisions.

For tooling around colors and shadows, the gradient generator and box shadow generator both support manual hex input so you can dial in your phosphor values exactly - export the CSS and drop it straight into your terminal component variables. Faster than eyeballing it in DevTools.

Where does this go from here? Custom cursor styles that match the terminal aesthetic (a crosshair or block pointer rather than the default arrow) is a low-effort high-impact addition - cursors has exactly what you need. Beyond that, if you're building an actual interactive shell for something like a portfolio site, consider adding command history with the up/down arrow keys, tab completion for known commands, and a loading spinner made from cycling ASCII characters. Those three additions together make a toy terminal feel like a real tool.

The full effect - scanlines, glitch headers, phosphor glow, typewriter output, blinking block cursor - takes maybe 150 lines of CSS and 80 lines of React. You don't need a library for this. You need patience with the animation timing, and the willingness to stare at green text on a black background for long enough that you start to question your life choices. That's the job.

FAQ

How do I make CSS scanlines work on retina/HiDPI displays?

Double the gradient repeat size at @media (-webkit-min-device-pixel-ratio: 2) - change your 4px pattern to 8px so the lines stay visible. On 1x displays use the standard 2px/4px split.

What's the best font for a cyberpunk terminal UI?

JetBrains Mono or IBM Plex Mono at 14px are the cleanest options. Courier New works too if you want maximum retro weight. Avoid anything with ligatures - they break the fixed-width character grid feel.

Can I use the glitch animation on SVG or image elements?

The clip-path technique works on any positioned element including SVGs. The attr(data-text) pseudo-element trick is text-only though - for images you'd use ::before/::after with a background-image clone instead.

How do I prevent the scanline overlay from blocking click events?

Add pointer-events: none to the ::before pseudo-element that holds your scanlines. Without it the overlay intercepts all mouse events and your terminal becomes non-interactive.

Free components in 41 styles
React & Tailwind, copy-paste ready.
Browse β†’

Read next

The Ultimate CSS UI Styles Guide: All 41 Visual Styles Ranked (2026) β†’Cyberpunk Button Design in CSS: Neon Glow, Glitch and Clip-Path β†’Cyberpunk Form Design: Glitch Inputs, Neon Labels, Scanline Fields β†’Tailwind Gradient Text: bg-clip-text in Under 2 Minutes β†’