Design and implement retro/cyberpunk/hacker-style terminal UIs. Covers React (Tuimorphic), SwiftUI (Metal shaders), and CSS approaches...
Expert guidance for designing and implementing text-based user interfaces with authentic retro computing aesthetics: CRT monitors, phosphor glow, scanlines, and cyberpunk neon.
Use this skill when:
This aesthetic draws from:
| Role | Hex | Usage |
|---|---|---|
| Bright | #00ff00 |
Primary text, highlights |
| Medium | #00cc00 |
Secondary text |
| Dark | #009900 |
Dimmed elements |
| Background | #001100 |
Main background |
| Deep BG | #000800 |
Panel backgrounds |
| Role | Hex | Usage |
|---|---|---|
| Cyan | #00ffff |
Primary accent |
| Magenta | #ff00ff |
Secondary accent |
| Electric Blue | #0066ff |
Tertiary |
| Hot Pink | #ff1493 |
Warnings |
| Background | #0a0a1a |
Main background |
| Role | Hex | Usage |
|---|---|---|
| Bright | #ffb000 |
Primary text |
| Medium | #cc8800 |
Secondary |
| Dark | #996600 |
Dimmed |
| Background | #1a1000 |
Main background |
See color-palettes.md for complete specifications.
Web:
font-family: 'GNU Unifont', 'IBM Plex Mono', 'JetBrains Mono',
'SF Mono', 'Consolas', monospace;
SwiftUI:
.font(.system(size: 14, weight: .regular, design: .monospaced))
Light: ─ │ ┌ ┐ └ ┘ ├ ┤ ┬ ┴ ┼
Heavy: ━ ┃ ┏ ┓ ┗ ┛ ┣ ┫ ┳ ┻ ╋
Double: ═ ║ ╔ ╗ ╚ ╝ ╠ ╣ ╦ ╩ ╬
Rounded: ╭ ╮ ╰ ╯
See typography-guide.md for complete reference.
Terminal interfaces have a distinct voice: terse, technical, authoritative. Use these defaults unless the project specifies otherwise.
| Element | Case | Example |
|---|---|---|
| Headers/Titles | UPPERCASE | SYSTEM STATUS |
| Labels | UPPERCASE | CPU USAGE: |
| Status indicators | UPPERCASE | ONLINE, OFFLINE |
| Commands/Input | lowercase | > run diagnostic |
| Body text | Sentence case | Connection established |
[SYS] System message [ERR] Error
[USR] User action [WRN] Warning
[INF] Information [NET] Network
| Action | Terminal Verbs |
|---|---|
| Start | INITIALIZE, BOOT, LAUNCH, ACTIVATE |
| Stop | TERMINATE, HALT, ABORT, KILL |
| Save | WRITE, COMMIT, STORE, PERSIST |
| Load | READ, FETCH, RETRIEVE, LOAD |
| Delete | PURGE, REMOVE, CLEAR, WIPE |
| State | Terminal Words |
|---|---|
| Working | PROCESSING, EXECUTING, RUNNING |
| Done | COMPLETE, SUCCESS, FINISHED |
| Failed | ERROR, FAULT, ABORTED |
| Ready | ONLINE, AVAILABLE, ARMED |
> INITIALIZING SYSTEM...
> LOADING MODULES [████████░░] 80%
> AUTHENTICATION COMPLETE
> SYSTEM READY
ERROR: ACCESS DENIED
ERR_CONNECTION_REFUSED: Timeout after 30s
WARNING: Low disk space (< 10%)
CONFIRM DELETE? [Y/N]
SELECT OPTION [1-5]:
See copywriting-guide.md for complete voice and tone reference.
Tuimorphic is a React component library providing 37 terminal-styled, accessible UI components.
npm install tuimorphic
import { Button, Card, Input } from 'tuimorphic';
import 'tuimorphic/styles.css';
function App() {
return (
<div className="theme-dark tint-green">
<Card>
<h1>SYSTEM ACCESS</h1>
<Input placeholder="Enter command..." />
<Button variant="primary">EXECUTE</Button>
</Card>
</div>
);
}
Apply themes via CSS classes on a parent element:
// Dark mode with green tint
<div className="theme-dark tint-green">
// Light mode with cyan tint
<div className="theme-light tint-blue">
Available tints: tint-green, tint-blue, tint-red, tint-yellow, tint-purple, tint-orange, tint-pink
| Component | Usage |
|---|---|
Button |
Actions with variant="primary|secondary|ghost" |
Input |
Text input with terminal styling |
Card |
Container with box-drawing borders |
Dialog |
Modal dialogs |
Menu |
Dropdown menus |
CodeBlock |
Syntax-highlighted code |
Table |
Data tables |
Tabs |
Tabbed navigation |
TreeView |
File tree display |
See tuimorphic-reference.md for complete API.
Enhance Tuimorphic with CSS glow effects:
/* Neon text glow */
.neon-text {
color: #0ff;
text-shadow:
0 0 5px #fff,
0 0 10px #fff,
0 0 20px #0ff,
0 0 40px #0ff,
0 0 80px #0ff;
}
/* Neon border glow */
.neon-border {
border-color: #0ff;
box-shadow:
0 0 5px #0ff,
0 0 10px #0ff,
inset 0 0 5px #0ff;
}
/* Flickering animation */
@keyframes flicker {
0%, 100% { opacity: 1; }
50% { opacity: 0.95; }
52% { opacity: 1; }
54% { opacity: 0.9; }
}
.flicker {
animation: flicker 3s infinite;
}
iOS 17+ supports Metal shaders directly in SwiftUI via .colorEffect(), .distortionEffect(), and .layerEffect().
CRT.metal:
#include <metal_stdlib>
#include <SwiftUI/SwiftUI.h>
using namespace metal;
[[stitchable]] half4 crtEffect(
float2 position,
SwiftUI::Layer layer,
float time,
float2 size,
float scanlineIntensity,
float distortionStrength
) {
float2 uv = position / size;
// Barrel distortion
float2 center = uv - 0.5;
float dist = length(center);
float2 distorted = center * (1.0 + distortionStrength * dist * dist);
float2 samplePos = (distorted + 0.5) * size;
// Bounds check
if (samplePos.x < 0 || samplePos.x > size.x ||
samplePos.y < 0 || samplePos.y > size.y) {
return half4(0, 0, 0, 1);
}
// Sample color
half4 color = layer.sample(samplePos);
// Scanlines
float scanline = sin(position.y * 3.14159 * 2.0) * scanlineIntensity;
color.rgb *= 1.0 - scanline;
// Subtle color shift (chromatic aberration)
color.r *= 1.0 + 0.02 * sin(time * 2.0);
color.b *= 1.0 - 0.02 * sin(time * 2.0);
// Slight flicker
color.rgb *= 1.0 + 0.01 * sin(time * 60.0);
return color;
}
SwiftUI View Modifier:
struct CRTEffectModifier: ViewModifier {
@State private var startTime = Date()
var scanlineIntensity: Float = 0.1
var distortionStrength: Float = 0.1
func body(content: Content) -> some View {
TimelineView(.animation) { timeline in
let time = Float(timeline.date.timeIntervalSince(startTime))
GeometryReader { geo in
content
.layerEffect(
ShaderLibrary.crtEffect(
.float(time),
.float2(geo.size),
.float(scanlineIntensity),
.float(distortionStrength)
),
maxSampleOffset: .init(width: 10, height: 10)
)
}
}
}
}
extension View {
func crtEffect(
scanlines: Float = 0.1,
distortion: Float = 0.1
) -> some View {
modifier(CRTEffectModifier(
scanlineIntensity: scanlines,
distortionStrength: distortion
))
}
}
Usage:
Text("SYSTEM ONLINE")
.font(.system(size: 24, weight: .bold, design: .monospaced))
.foregroundColor(.green)
.crtEffect(scanlines: 0.15, distortion: 0.08)
extension View {
func neonGlow(color: Color, radius: CGFloat = 10) -> some View {
self
.shadow(color: color.opacity(0.8), radius: radius / 4)
.shadow(color: color.opacity(0.6), radius: radius / 2)
.shadow(color: color.opacity(0.4), radius: radius)
.shadow(color: color.opacity(0.2), radius: radius * 2)
}
}
// Usage
Text("NEON")
.font(.system(size: 48, design: .monospaced))
.foregroundColor(.cyan)
.neonGlow(color: .cyan, radius: 15)
See metal-shaders-ios.md for complete shader code.
.crt-container {
position: relative;
background: #000800;
}
.crt-container::after {
content: '';
position: absolute;
inset: 0;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.15),
rgba(0, 0, 0, 0.15) 1px,
transparent 1px,
transparent 2px
);
pointer-events: none;
}
.neon-text {
color: #0ff;
text-shadow:
/* White core */
0 0 5px #fff,
0 0 10px #fff,
/* Colored glow layers */
0 0 20px #0ff,
0 0 30px #0ff,
0 0 40px #0ff,
0 0 55px #0ff,
0 0 75px #0ff;
}
.crt-screen {
border-radius: 20px;
transform: perspective(1000px) rotateX(2deg);
box-shadow:
inset 0 0 50px rgba(0, 255, 0, 0.1),
0 0 20px rgba(0, 255, 0, 0.2);
}
<script type="module">
import { CRTFilterWebGL } from 'crtfilter';
const canvas = document.getElementById('crt-canvas');
const crt = new CRTFilterWebGL(canvas, {
scanlineIntensity: 0.15,
glowBloom: 0.3,
chromaticAberration: 0.002,
barrelDistortion: 0.1,
staticNoise: 0.03,
flicker: true,
retraceLines: true
});
crt.start();
</script>
See crt-effects-web.md for complete techniques.
| Platform | Implementation |
|---|---|
| CSS | repeating-linear-gradient pseudo-element |
| SwiftUI | Metal shader with sin(position.y * frequency) |
| WebGL | Fragment shader brightness modulation |
| Platform | Implementation |
|---|---|
| CSS | Multiple text-shadow with increasing blur |
| SwiftUI | Multiple .shadow() modifiers |
| WebGL | Gaussian blur pass + additive blend |
| Three.js | UnrealBloomPass with luminanceThreshold |
| Platform | Implementation |
|---|---|
| CSS | Three overlapping elements with color channel offset |
| SwiftUI | Sample texture at offset positions per RGB channel |
| WebGL | Sample UV with slight offset per channel |
| Platform | Implementation |
|---|---|
| CSS | @keyframes animation varying opacity 0.9-1.0 |
| SwiftUI | Timer-driven opacity or shader time-based |
| WebGL | Time-based noise multiplier |
box-shadow and text-shadow are GPU-accelerated but expensive with many layerswill-change: transform for animated elementsprefers-reduced-motion media query.layerEffect() processes every pixel - keep shaders simple.compile() (iOS 18+)Ready-to-use starter files:
This section documents real failure modes and edge cases that break TUI implementations. These are non-obvious gotchas discovered through practical use across platforms.
Problem: Escape sequences vary across terminal emulators. What works in iTerm2 may fail in older xterm, SSH terminals, or Windows Terminal.
Common failures:
#rgb hex colors works in modern terminals but falls back to 16-color ANSI on older systems.\x1b[0m or \x1b[m) can poison the terminal state for subsequent output.─ │ ┌ ┐ correctly. Test on Windows before shipping.Prevention:
+, -, | as fallback\x1b[0m is your friendTERM env var (TERM=xterm-256color vs TERM=xterm)Example safe wrapper:
const supportsUnicode = process.env.TERM && process.env.TERM.includes('256');
const border = supportsUnicode ? '─' : '-';
Problem: Fixed-width layouts collapse when terminal resizes. Common in web-based TUI where aspect ratio changes unexpectedly.
Common failures:
display: grid with grid-template-columns: 100px 200px 100px fails to adapt to small screens.Prevention:
vw, vh, or calc(100% - Xpx)overflow: hidden; text-overflow: ellipsis; white-space: nowrap;@media or JS ResizeObserver to adapt layout dynamicallyScanline example that fails:
/* ❌ Breaks on narrow screens */
background: repeating-linear-gradient(0deg, transparent, transparent 20px, black 20px, black 21px);
Better approach:
/* ✅ Scales with viewport height */
background: repeating-linear-gradient(0deg, transparent, transparent calc(2% of height), black calc(2% of height), black calc(2% of height + 1px));
Problem: User input isn't just alphanumeric. Real-world input includes multi-byte UTF-8, special keys, paste buffers, and autocomplete.
Common failures:
string.length counts UTF-16 code units, not characters. A 4-byte emoji 🎮 has length: 2, breaking cursor positioning and validation.Prevention:
Intl.Segmenter (modern), or graphemesplitter npm packagecompositionend, not compositionupdateCorrect UTF-8 cursor positioning:
// ❌ Wrong: counts UTF-16 code units
const cursorPos = input.value.length; // "🎮🎮" = length: 4
// ✅ Correct: counts grapheme clusters
const segmenter = new Intl.Segmenter();
const cursorPos = [...segmenter.segment(input.value)].length; // "🎮🎮" = 2
Problem: TUI effects (scanlines, glow, flicker) are expensive. Naive implementations cause jank and CPU/battery drain.
Common failures:
text-shadow: 0 0 5px, 0 0 10px, 0 0 20px, ... requires a separate GPU pass. 10+ shadows = measurable lag.setInterval(..., 16ms) for flicker ties to animation frame time, causing stutter if main thread is busy.repeating-linear-gradient on every resize (without debounce) causes jank.Prevention:
@keyframes for continuous effects (GPU-accelerated)ResizeObserver with throttle, or window.requestAnimationFramewill-change: transform, filter sparingly on elements that animateprefers-reduced-motion: reduceFlicker that performs well:
/* ✅ GPU-accelerated, smooth */
@keyframes flicker {
0%, 100% { opacity: 1; }
50% { opacity: 0.95; }
}
.flicker { animation: flicker 3s infinite; }
Flicker that causes jank:
// ❌ Main thread blocked, stutter
setInterval(() => {
element.style.opacity = Math.random() > 0.5 ? 1 : 0.9;
}, 50);
Problem: Terminal UIs often ignore accessibility. Screen readers, keyboard navigation, color contrast all suffer.
Common failures:
prefers-reduced-motion support = legal/safety risk.Prevention:
aria-label="Start system", aria-describedby="help-text"prefers-reduced-motion alternative: disable flicker, scanlines, animations:focus { outline: 2px solid #0ff; } is mandatoryAccessible neon button:
<button
aria-label="Execute diagnostic"
className="neon-button"
onClick={handleClick}
onKeyDown={(e) => e.key === 'Enter' && handleClick()}
>
EXECUTE
</button>
<style>
.neon-button:focus {
outline: 2px solid #0ff;
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.neon-button {
text-shadow: none; /* Remove glow */
animation: none; /* Remove flicker */
}
}
</style>
Quick reference for platform-specific behaviors:
| Emulator | Notable Quirks | Fix |
|---|---|---|
| iTerm2 (macOS) | Renders all Unicode correctly; true-color support | None needed |
| Terminal.app (macOS) | Limited color palette; older xterm behavior | Test with 256-color mode |
| Alacritty | Ultra-fast; true-color; may not render some Unicode | Works reliably if iTerm2 works |
| Windows Terminal | Supports true-color; WSL integration works well | None needed for modern code |
| PowerShell | Old versions render box-drawing as ?; use fallback |
Check $PSVersionTable |
| SSH/xterm | VT100 only; no true-color; no mouse events | Fall back to ASCII + ANSI colors |
| Tmux | Passthrough mode required for true-color: set -g default-terminal "tmux-256color" |
Configure tmux.conf |
| Screen | Even older terminal support; avoid fancy effects | Use ASCII-only mode |
Platform detection example:
if [[ "$TERM" == "xterm" ]]; then
# Old terminal: use ASCII
BORDER="+"
COLORS="ANSI"
elif [[ "$TERM" == *"256color"* ]]; then
# Modern terminal: use box-drawing + 256 colors
BORDER="─"
COLORS="256"
else
# Unknown: default to safe
BORDER="+"
COLORS="ANSI"
fi
Problem: Scanlines, glow, and distortion look cool in mockups but cause problems in practice.
Common failures:
text-shadow with 10+ layers) blurs text edges, reducing legibility.Prevention:
prefers-reduced-motion escape hatch