I built a playable terminal with almost no JavaScript
The homepage you're reading from is a fake terminal you can actually type into. ls posts, cat resume, open github — it all works. People assume something like this is a pile of JavaScript: a fake shell, an emulated filesystem, maybe an xterm.js dependency weighing more than the rest of the site combined.
It isn't. The chrome is pure CSS, and the brains of the thing is a single pure function I can describe in one line: runCommand(input, ctx) -> output. That's the whole trick. This post is how it fits together, and why I keep coming back to this shape — heavy CSS, light JS — for interactive UI.
Why a terminal
I wanted a homepage that rewarded poking at it without being a gimmick. A terminal is a perfect fit for an engineer's site: the interaction model is universally understood, it's keyboard-first, and — this is the part people miss — it degrades beautifully. A terminal is just text and a prompt. If JavaScript never loads, I still have a page full of readable text and real links. Compare that to a canvas-based hero animation, which is a blank rectangle the moment anything goes wrong.
The constraint I set myself: do as much as possible in CSS, and let JavaScript do only the one thing CSS genuinely can't — turn a typed string into a response.
The chrome is all CSS
Everything that makes it look like a terminal — the window frame, the rounded corners, the three traffic-light dots, the blinking cursor — is markup and CSS. No JavaScript touches any of it.
The cursor is the fun one. It's a green block that blinks, and the temptation is to drive it with setInterval toggling a class. Don't. A keyframe animation is smoother, runs off the main thread, and is one declaration:
@keyframes blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
.cursor {
display: inline-block;
width: 0.6em;
height: 1.05em;
background: var(--green);
animation: blink 1.1s steps(1) infinite;
}The detail that makes it read as a terminal cursor rather than a fading CSS demo is steps(1). The default animation timing function interpolates, so opacity would smoothly ramp between 1 and 0 and you'd get a soft pulse. steps(1) says "no in-between frames — snap from one keyframe to the next." That hard on/off is exactly how a real console cursor behaves. The traffic-light dots are three spans with border-radius: 50% and background colors; the window is a border and a border-radius. Nothing clever, and nothing that costs a single byte of script.
One more thing I get for free by doing this in CSS: motion preferences. A single media query kills the blink for anyone who asked their OS to reduce motion.
@media (prefers-reduced-motion: reduce) {
.cursor { animation: none; opacity: 1; }
}Try wiring that up correctly around a setInterval. You can, but you'll write more code and remember to tear it down. CSS already knows the answer.
The only JavaScript is a parser
Here's the core idea. Command handling is a pure function. It takes the raw input string plus a little context, and returns a plain description of what should happen. No DOM, no side effects, no this:
function runCommand(input) {
const [cmd, ...args] = input.trim().split(/\s+/);
switch (cmd) {
case "help": return { lines: ["help, ls, cat, open…"] };
case "ls": return { lines: ["posts/", "resume.txt"] };
default: return { lines: [`command not found: ${cmd}`] };
}
}The real version is bigger — it knows about posts, handles cat posts/<slug>, has a few easter eggs — but the shape never changes. Input goes in, a value comes out. The output isn't always lines of text; it's a small tagged union. A command can resolve to text to print, a navigation, opening an external URL, or clearing the screen:
// every command returns one of these shapes
{ type: "lines", lines: [...] }
{ type: "navigate", href: "/blog" } // cd posts
{ type: "open", url: "https://…" } // open github
{ type: "clear" } // clearBecause the parser is pure, it's trivially testable. There's no rendering to mock, no fake DOM, no React. The tests are just "given this string, assert this object," and they run in milliseconds:
expect(runCommand("ls posts", ctx).lines[0].href).toBe("/blog/hello-world");
expect(runCommand("frobnicate", ctx).lines[0].tone).toBe("red");That's the payoff of keeping logic pure: the part most likely to have bugs is the part easiest to test in isolation.
Rendering
So who actually runs runCommand and shows the result? One small React component. It holds the scrollback — the list of past commands and their output — in state, and the current input value. When you press Enter, it calls the parser, then interprets the returned object: push lines into scrollback, or call router.push(href) for a navigation, or window.open for an external link, or reset the list to empty for clear.
The component is deliberately dumb. It doesn't know what ls means or which posts exist; it just renders whatever the parser handed back and reacts to the type field. All the domain knowledge lives in the pure function. The React layer is plumbing.
Two accessibility details matter here. First, the scrollback region is wrapped in aria-live="polite", so when a command prints output, a screen reader announces it instead of leaving non-sighted users with a silent black box. Second — and this is the part I'm proudest of — there's a <noscript> fallback. If JavaScript doesn't run, visitors don't get a dead input box; they get a short line of text pointing them at the real /blog and /resume links via the normal nav. The interactive terminal is an enhancement, not a gate.
<noscript>
<p>
The interactive terminal needs JavaScript. Use the nav above to
reach <a href="/blog">writing</a> and <a href="/resume">resume</a>.
</p>
</noscript>Lessons
I've built a lot of UI over the years, and this little homepage is a clean miniature of the principles I trust most.
Push logic into pure functions. The single best decision here was making command handling a pure input -> output function with the component as a thin shell around it. It made the risky part testable and the component boring. Boring components are good components.
Let CSS do the heavy lifting. The cursor, the window, the motion preferences — all of it is declarative, runs without the main thread, and is shorter than the JavaScript equivalent. Reaching for setInterval or a class-toggling effect when a keyframe will do is a tax you pay forever.
Progressive enhancement is not nostalgia. The <noscript> fallback and the aria-live region cost me a few minutes. In exchange, the site is usable for someone on a flaky connection, someone with a screen reader, and a search crawler — all without a second code path to maintain. The "almost no JavaScript" in the title isn't a flex about bundle size. It's that the interesting part is one small, testable function, and everything else is the platform doing its job.