Building a Terminal UI That Is More Than a Developer Gimmick #
There is a particular kind of developer website that is very easy to get wrong.
Black background. Monospace font. Blinking cursor. Maybe some green text.
Technically, it looks like a terminal.
Functionally, it is still six cards and a contact button wearing a hacker costume.
I have nothing against the visual language of command-line interfaces. My own homepage, https://akava.site/, is built around a console concept. But while looking through recent web design work, I found another interpretation that made me reconsider where this pattern becomes genuinely useful.
Minimal Gallery added The Content Architecture on August 10, 2026. The project itself is a production-focused starter architecture for Next.js/Astro and Sanity, and its website naturally borrows from the language of development environments.
What caught my attention was not simply that the interface looked technical.
The interesting part was the alignment between product, content and interaction.
A developer-oriented product is being explained through an interface developers already understand.
That is a much stronger reason to use a terminal than “monospace looks cool.”
The Site That Triggered the Idea #
The Content Architecture is currently presented as an architecture for production projects, with Next.js or Astro editions, Sanity, TypeScript and related tooling. Its own project documentation exposes familiar concepts such as project structure, scripts, feature modules and files.
Source:
https://www.contentarchitecture.dev/
Gallery entry:
https://minimal.gallery/the-content-architecture/
The important design lesson for me is broader than this particular website.
When the interface metaphor belongs to the product, you can use the metaphor to organize information instead of merely decorating it.
A terminal can expose:
> services
> stack
> performance
> analytics
> contact
Those commands are not fundamentally different from navigation links.
The difference is that the user feels as though they are interrogating a system rather than moving between cards.
That changes the tone of the interaction.
Two Ways to Build a Console Website #
I already use a console interface on my own homepage at:
The architecture there goes further.
The console is effectively the primary interface of the site. The current page exposes commands including help, about, skills, experience, projects, contact, social, clear, language switching and a couple of secret commands.
Conceptually, it looks like this:
alexey_kovalevsky@resume:~$
help
about
skills
experience
projects
contact
social
lang [en|ru|he]
That approach makes sense for a developer portfolio because using the command line is part of the identity of the site.
For the new experiment, however, I wanted almost the opposite architecture.
The page should remain a normal website.
The terminal should be one component inside it.
That distinction is useful:
| Approach | AKAVA homepage | Terminal Explorer |
| Terminal role | Primary interface | Individual component |
| Navigation | Command driven | Normal page + commands |
| User expectation | Exploration is central | Exploration is optional |
| Best context | Developer portfolio | Product/service section |
| Fallback | Command list | Visible buttons |
| Learning curve | Intentional | Almost zero |
Neither architecture is automatically better.
They solve different problems.
A Real CodePen Terminal Reference #
There are much more elaborate browser terminal implementations available in the wild.
One example I like is Jakub T.’s Vintage (Retro) Fake Terminal Emulator in JavaScript on CodePen:
https://codepen.io/jcubic/pen/BwBYOZ
It uses the jQuery Terminal ecosystem and goes much further than the component I am building here: interactive shell behavior, CRT treatment, scan lines, commands and additional terminal effects. The Pen was originally created years ago and has continued to be updated, which also makes it an interesting example of how far the terminal metaphor can be pushed in the browser.
And here is my version, which I’ll explain in more detail below:
I would not reproduce that implementation for this component.
It solves a bigger problem.
Mine needs to answer six or seven predictable questions and then go back to sleep.
So I deliberately kept it vanilla.
My Terminal Explorer #
The idea is straightforward.
Users can either click:
about
services
stack
performance
analytics
contact
or type commands themselves.
That gives us two layers.
The first is obvious and conventional.
The second rewards exploration.
No library is required.
HTML #
I would start with accessibility in the markup rather than trying to retrofit it later:
<section class="terminal-section" role="region" aria-labelledby="system-title">
<div class="terminal-section__intro">
<span class="terminal-section__eyebrow">SYSTEM / 01</span>
<h2 id="system-title">
Everything you need.<br>
<span>Without searching through menus.</span>
</h2>
<p>
Choose a command or type your own to discover
how our system works.
</p>
</div>
<div class="terminal" data-terminal>
<div class="terminal__topbar">
<div class="terminal__lights" aria-hidden="true">
<span></span>
<span></span>
<span></span>
</div>
<span class="terminal__title">
client-project
</span>
<span class="terminal__status">
● ONLINE
</span>
</div>
<div
class="terminal__screen"
data-terminal-screen
aria-live="polite"
>
<div class="terminal__welcome">
<span>// AKAVA PROJECT SYSTEM v1.0</span>
<span>// Type "help" for available commands or click quick commands below</span>
</div>
<div
class="terminal__history"
data-terminal-history
role="log"
aria-label="Command history"
></div>
<form class="terminal__prompt" data-terminal-form>
<label
for="terminal-command"
class="terminal__path"
aria-label="Command prompt"
>
~/project<span>$</span>
</label>
<input
id="terminal-command"
class="terminal__input"
data-terminal-input
type="text"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
aria-label="Terminal command"
>
</form>
</div>
<div
class="terminal__commands"
aria-label="Quick commands"
>
<button type="button" data-command="about">
about
</button>
<button type="button" data-command="services">
services
</button>
<button type="button" data-command="stack">
stack
</button>
<button type="button" data-command="performance">
performance
</button>
<button type="button" data-command="analytics">
analytics
</button>
<button type="button" data-command="contact">
contact
</button>
</div>
</div>
</section>
Accessibility Should Start While You Are Writing the Component #
This is something I increasingly prefer to treat as part of production code rather than a cleanup task:
do not forget the accessibility attributes. It is much easier to design them into the component while you are building it than to return later and search through the DOM trying to work out where every label, role and state belongs.
A terminal-style UI is a good example because the visual metaphor can tempt us into replacing ordinary HTML controls with custom elements.
There is no reason to do that here.
I use:
<button type="button">
instead of:
<div class="command">
and:
<input type="text">
instead of building a fake text field.
Native controls already provide keyboard behavior and semantics that custom elements would otherwise require us to recreate. MDN explicitly recommends preferring built-in controls such as <button> and <input> instead of faking them with generic elements and ARIA.
The history also has:
role="log"
That choice is not arbitrary. In WAI-ARIA, a log is intended for a live region where information is appended in a meaningful sequence — a useful semantic match for command history.
And decorative window controls are hidden:
aria-hidden="true"
because three fake traffic-light dots do not need to become three mysterious objects in an accessibility tree.
One warning, though: more ARIA is not automatically better ARIA.
Live regions should be tested with actual assistive technology. aria-live="polite" causes dynamic updates to be announced without the urgency of an alert, while role="alert" is intended for genuinely important and time-sensitive messages.
I would therefore test the final combination of the screen live region and command log rather than blindly adding accessibility attributes everywhere.
Accessibility is architecture, not seasoning.
CSS #
The visual layer is deliberately independent from the command logic.
:root {
--terminal-bg: #10110f;
--terminal-panel: #161815;
--terminal-line: rgba(255, 255, 255, 0.11);
--terminal-text: #e8eadf;
--terminal-muted: #858b7e;
--terminal-accent: #c6ff4a;
--terminal-error: #ff746c;
--terminal-font: "Assistant", "Heebo", system-ui, sans-serif;
}
.terminal-section {
display: grid;
grid-template-columns: minmax(0, 0.75fr) minmax(500px, 1.25fr);
gap: clamp(50px, 8vw, 130px);
align-items: center;
max-width: 1500px;
margin-inline: auto;
padding:
clamp(70px, 9vw, 150px)
clamp(20px, 5vw, 80px);
}
.terminal-section__intro {
max-width: 560px;
font-family: var(--terminal-font);
}
.terminal-section__eyebrow {
display: block;
margin-bottom: 28px;
font-family: monospace;
font-size: 12px;
letter-spacing: 0.12em;
color: #666;
}
.terminal-section__intro h2 {
margin: 0;
font-size: clamp(42px, 5vw, 84px);
line-height: 0.95;
letter-spacing: -0.055em;
}
.terminal-section__intro h2 span {
color: #999;
}
.terminal-section__intro p {
max-width: 440px;
margin: 30px 0 0;
font-size: clamp(17px, 1.4vw, 21px);
line-height: 1.6;
color: #777;
}
.terminal {
overflow: hidden;
border: 1px solid var(--terminal-line);
border-radius: 18px;
background: var(--terminal-bg);
box-shadow:
0 30px 80px rgba(0, 0, 0, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.terminal__topbar {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
min-height: 54px;
padding: 0 18px;
border-bottom: 1px solid var(--terminal-line);
}
.terminal__lights {
display: flex;
gap: 7px;
}
.terminal__lights span {
width: 9px;
aspect-ratio: 1;
border-radius: 50%;
background: #3b3d38;
}
.terminal__title,
.terminal__status {
font-family: monospace;
font-size: 11px;
}
.terminal__title {
color: var(--terminal-muted);
}
.terminal__status {
justify-self: end;
color: var(--terminal-accent);
}
.terminal__screen {
min-height: 430px;
max-height: 520px;
overflow-y: auto;
padding: clamp(22px, 4vw, 38px);
font-family:
"SFMono-Regular",
Consolas,
"Liberation Mono",
monospace;
font-size: clamp(13px, 1.15vw, 15px);
line-height: 1.7;
color: var(--terminal-text);
scrollbar-width: thin;
}
.terminal__welcome {
display: grid;
gap: 3px;
margin-bottom: 30px;
color: var(--terminal-muted);
}
.terminal__prompt {
display: flex;
align-items: center;
gap: 9px;
margin-top: 8px;
}
.terminal__path {
flex: 0 0 auto;
color: var(--terminal-accent);
}
.terminal__path span {
margin-inline-start: 5px;
color: var(--terminal-muted);
}
.terminal__input {
width: 100%;
min-width: 0;
padding: 0;
border: 0;
outline: 0;
background: transparent;
color: inherit;
font: inherit;
caret-color: var(--terminal-accent);
}
.terminal-line {
display: block;
}
.terminal-line--command {
margin-top: 24px;
color: var(--terminal-text);
}
.terminal-line--command::before {
content: "~/project $ ";
color: var(--terminal-accent);
}
.terminal-output {
margin-top: 8px;
padding-inline-start: 15px;
border-inline-start: 1px solid var(--terminal-line);
color: var(--terminal-muted);
}
.terminal-output strong {
color: var(--terminal-text);
font-weight: 500;
}
.terminal-output__row {
display: grid;
grid-template-columns: minmax(120px, 0.5fr) 1fr;
gap: 20px;
}
.terminal-output__row + .terminal-output__row {
margin-top: 4px;
}
.terminal-output__value {
color: var(--terminal-text);
}
.terminal-output__success {
color: var(--terminal-accent);
}
.terminal-output__error {
color: var(--terminal-error);
}
.terminal__commands {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 14px 16px;
border-top: 1px solid var(--terminal-line);
background: var(--terminal-panel);
}
.terminal__commands button {
appearance: none;
padding: 8px 12px;
border: 1px solid var(--terminal-line);
border-radius: 100px;
background: transparent;
color: var(--terminal-muted);
font-family: monospace;
font-size: 11px;
cursor: pointer;
transition:
color 160ms ease,
border-color 160ms ease,
background 160ms ease;
}
.terminal__commands button:hover,
.terminal__commands button:focus-visible {
border-color: var(--terminal-accent);
background: rgba(198, 255, 74, 0.06);
color: var(--terminal-accent);
outline: none;
}
.terminal__commands button:active {
transform: scale(0.95);
background: rgba(198, 255, 74, 0.12);
}
@media (max-width: 900px) {
.terminal-section {
grid-template-columns: 1fr;
}
.terminal-section__intro {
max-width: 700px;
}
.terminal__screen {
min-height: 380px;
}
}
@media (max-width: 520px) {
.terminal {
border-radius: 12px;
}
.terminal__topbar {
grid-template-columns: 1fr 1fr;
}
.terminal__title {
display: none;
}
.terminal-output__row {
grid-template-columns: 1fr;
gap: 0;
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
.terminal__commands button {
transition: none;
}
}
I also keep :focus-visible as a first-class state.
Hover is not a keyboard interaction model.
JavaScript #
The JavaScript contains no continuous animation loop and no framework state.
<script>
(() => {
const terminal = document.querySelector('[data-terminal]');
if (!terminal) {
return;
}
const form = terminal.querySelector('[data-terminal-form]');
const input = terminal.querySelector('[data-terminal-input]');
const history = terminal.querySelector('[data-terminal-history]');
const screen = terminal.querySelector('[data-terminal-screen]');
const buttons = terminal.querySelectorAll('[data-command]');
const commands = {
help: {
type: 'list',
rows: [
['services', 'Show available services'],
['stack', 'Show development stack'],
['performance', 'Run performance check'],
['analytics', 'Show tracking setup'],
['contact', 'Start a project'],
['about', 'About this system'],
['status', 'System status'],
['clear', 'Clear terminal']
]
},
about: {
type: 'html',
html: `
<div class="terminal-output">
<strong>AKAVA PROJECT SYSTEM</strong><br>
Version 1.0 | Built with modern web tech<br>
<br>
<strong>Features:</strong><br>
• Interactive command interface<br>
• Real-time service display<br>
• Performance monitoring<br>
• Analytics integration<br>
<br>
Type "help" for all commands.
</div>
`
},
services: {
type: 'list',
rows: [
['WEB', 'Custom WordPress development'],
['UX', 'Responsive interface systems'],
['SEO', 'Technical SEO architecture'],
['AUTO', 'Automation & API integrations']
]
},
stack: {
type: 'list',
rows: [
['Frontend', 'HTML / SCSS / JavaScript'],
['CMS', 'WordPress + ACF'],
['Forms', 'Contact Form 7'],
['Backend', 'PHP / REST API'],
['Frameworks', 'None required']
]
},
performance: {
type: 'status',
rows: [
['HTML', 'OPTIMIZED'],
['CSS', 'CRITICAL FIRST'],
['JavaScript', 'DEFERRED'],
['Images', 'RESPONSIVE'],
['Caching', 'READY']
]
},
analytics: {
type: 'list',
rows: [
['GA4', 'Custom events'],
['GTM', 'Data layer ready'],
['Forms', 'Lead tracking'],
['WhatsApp', 'Click attribution'],
['CTA', 'Interaction events']
]
},
contact: {
type: 'html',
html: `
<div class="terminal-output">
Ready to build.<br>
<strong>hello@example.com</strong>
</div>
`
},
status: {
type: 'status',
rows: [
['System', 'OPERATIONAL'],
['Database', 'CONNECTED'],
['API', 'RESPONDING'],
['Cache', 'ENABLED'],
['Uptime', '99.9%']
]
}
};
function escapeHTML(value) {
return value
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
function renderOutput(command) {
const data = commands[command];
if (!data) {
return `
<div class="terminal-output terminal-output__error" role="alert">
✗ "${escapeHTML(command)}" not recognized<br>
<span style="color: var(--terminal-muted);">Try: help, services, stack, performance, analytics, contact, about, status</span>
</div>
`;
}
if (data.type === 'html') {
return data.html;
}
const rows = data.rows.map(([label, value]) => {
const valueClass = data.type === 'status'
? 'terminal-output__success'
: 'terminal-output__value';
return `
<div class="terminal-output__row">
<span>${escapeHTML(label)}</span>
<span class="${valueClass}">
${escapeHTML(value)}
</span>
</div>
`;
}).join('');
return `
<div class="terminal-output">
${rows}
</div>
`;
}
function runCommand(rawCommand) {
const command = rawCommand
.trim()
.toLowerCase();
if (!command) {
return;
}
if (command === 'clear') {
history.innerHTML = '';
return;
}
const block = document.createElement('div');
block.innerHTML = `
<span class="terminal-line terminal-line--command">
${escapeHTML(command)}
</span>
${renderOutput(command)}
`;
history.append(block);
requestAnimationFrame(() => {
screen.scrollTo({
top: screen.scrollHeight,
behavior: 'smooth'
});
});
}
form.addEventListener('submit', event => {
event.preventDefault();
runCommand(input.value);
input.value = '';
input.focus();
});
buttons.forEach(button => {
button.addEventListener('click', () => {
runCommand(button.dataset.command);
button.setAttribute('aria-pressed', 'true');
setTimeout(() => {
button.setAttribute('aria-pressed', 'false');
}, 200);
input.focus();
});
});
screen.addEventListener('click', event => {
if (!event.target.closest('button, a')) {
input.focus();
}
});
})();
</script>
The implementation wakes up in response to events.
There is no permanent animation engine running just because the component exists.
Why I Prefer Visible Commands #
The quick-command buttons are probably the most important UX decision in the entire experiment.
A developer might immediately type:
help
Someone else might not know what a terminal expects at all.
Both users should succeed.
So:
[ about ] [ services ] [ stack ] [ performance ] [ analytics ] [ contact ]
is not a compromise.
It is the interface.
Typing is the enhancement.
This is the distinction I would keep if I adapted the same idea for a non-technical website.
A real-estate project could expose:
> location
> availability
> timeline
> specifications
A creative agency could use:
> work
> services
> process
> contact
A SaaS product:
> features
> integrations
> status
> pricing
The user should never be required to guess the vocabulary.
Easter Eggs Without Breaking Navigation #
Once the useful commands are visible, hidden commands become fun.
For example:
coffee
could return:
Coffee dependency detected.
Developer productivity +37%
Not a real statistic, obviously.
Just an Easter egg.
Or:
sudo hire
could return:
Permission granted.
Opening contact form...
That is the right place for mystery.
The contact page itself should not be the mystery.
WordPress and ACF #
This structure maps cleanly to an ACF repeater:
terminal_commands
command
label
rows
name
value
WordPress can output the data as JSON:
<script type="application/json" id="terminal-data">
<?php
echo wp_json_encode(
get_field( 'terminal_commands' ),
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
);
?>
</script>
Then JavaScript reads it:
const data = JSON.parse(
document.querySelector('#terminal-data').textContent
);
This separation is more important than it looks.
The CMS controls:
services
projects
team
numbers
contact
The component controls how those commands behave.
Editors do not need access to JavaScript.
JavaScript does not need hardcoded marketing copy.
What I Would Build Next #
The same research trail also brought me back to another browser feature I think is becoming increasingly useful: CSS Anchor Positioning combined with the native Popover API.
That is almost the opposite kind of experiment.
A terminal UI deliberately adds an interaction metaphor.
Anchor positioning can remove interaction plumbing by letting browsers handle relationships between positioned UI elements that previously required JavaScript geometry calculations.
That is probably a better subject for a separate article.
Frequently Asked Questions #
What is a terminal UI in web design? #
A terminal UI is a web interface inspired by command-line applications. Users may type commands, select command-like controls, or navigate information presented as console output.
The strongest implementations use the metaphor as an interaction model rather than applying terminal styling to conventional content.
Can I build a terminal interface without React? #
Yes.
The component above uses standard HTML, CSS and vanilla JavaScript. A command dictionary, form submit handler and DOM output function are sufficient for predictable command-based interfaces.
Do I need a terminal JavaScript library? #
Not for a small controlled interface.
If you need command parsing, advanced shell behavior, keyboard handling, formatting, terminal emulation or a much richer API, a dedicated project such as jQuery Terminal may be more appropriate. Jakub T.’s CodePen demonstrates how much further that approach can go.
How do I make a terminal UI accessible? #
Start with native controls.
Use actual buttons for clickable commands, a real input for text entry, visible keyboard focus, appropriate accessible names and carefully chosen live-region semantics for dynamic output.
Do not wait until the interface is finished and then attempt to reconstruct accessibility from a collection of div elements.
What does role="log" do? #
The ARIA log role identifies a live region where new information is added in a meaningful sequence. Command history is conceptually similar because new command/output entries are appended in order.
Should terminal output use aria-live? #
Potentially, but it should be used intentionally and tested.
aria-live="polite" allows assistive technology to announce changes without immediately interrupting the user. Too many overlapping live regions can create a worse experience rather than a better one.
Is a console interface good for a developer portfolio? #
It can be.
My homepage at https://akava.site/ uses the terminal concept as the primary interface, including commands for projects, experience, skills, contact details and language switching. That works differently from the component in this article, where the console is intentionally only one part of a conventional page.
Conclusion #
The thing I took away from this experiment is not:
websites should look like terminals.
It is almost the opposite.
A terminal is useful when its interaction model explains the content better than another row of cards.
The Content Architecture demonstrates why the metaphor can make sense around a technical product. My own AKAVA homepage takes it further and makes the console the navigation model. The component above goes in another direction again: normal website first, exploratory console second.
That last approach is probably the most reusable.
Keep the primary commands visible.
Keep the HTML semantic.
Use native controls before recreating them.
Treat dynamic announcements deliberately.
And do not leave accessibility until the end.
It is much easier to add the right label, focus behavior, role or reduced-motion rule while you still understand why the component is structured the way it is than to return months later and ask:
> where_the_hell_does_aria_go
Build accessibility into the component.
Then add the Easter eggs.