Cobra Developers
Blog, Engineering

Building a working terminal UI in the browser

The short answer

A browser terminal needs a modal container, a text input, and a scrolling output area. Use the native dialog element for the container, map command names to functions in a plain object, keep history in an array, and run each command through a promise queue so slow commands cannot print their output out of order.

Press the backtick key on any page of this site and a terminal opens. Type help and it lists what it can do: ls shows our projects, cat prints one, grep searches them, stack draws a bar chart of the technologies we use, and snake starts a game. It has history, tab completion, and a theme command that changes the page behind it.

We build command-line tools as well as pages. WP Ripper, our migration tool, ships as both a WordPress plugin and a CLI. A terminal seemed like an honest way to show the same project data to the kind of visitor who would rather type than scroll.

It is one file of about 200 lines. To be clear about what it is: a command interpreter with a text box, not a terminal emulator. There is no shell behind it and no pipes. Here is how it works.

What should the terminal window be made of?

The terminal window should be a native dialog element opened with showModal(). The browser then supplies the hard parts of a modal for free: it renders above the page, traps focus inside, dims the background through a ::backdrop pseudo-element you can style, and closes on the Escape key.

The markup is written once by the build script into every page:

<dialog class="term" aria-label="Terminal">
  <div class="term-bar"><span>guest@cobra: ~</span><button type="button" aria-label="Close the terminal">close</button></div>
  <div class="term-out" role="log" aria-live="polite"></div>
  <label class="term-in"><span>$</span><input type="text" autofocus autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="Command"></label>
</dialog>

Three things in there are easy to skip. The output area has role="log" and is a live region, so a screen reader announces new lines. The input turns off autocomplete, capitalisation, and spellcheck, which otherwise fight with commands on phones. And the input is a real text field, so typing, pasting, selection, and mobile keyboards all behave normally.

That last choice is the main shortcut. A terminal emulator draws its own cursor and handles every keystroke. We let the browser's input do it and only listen for the keys that mean something special.

How do you keep the terminal from slowing down every page?

Keep the terminal's code out of the initial page load and fetch it the first time someone opens it. A dynamic import() does this in one line. Visitors who never open the terminal download only the empty dialog markup and a few lines that listen for the shortcut.

This is the opener from our main site script:

const dlg = $('.term');
let term;
async function openTerm() {
  if (!dlg || dlg.open) return;
  dlg.showModal();
  term ??= (await import('/js/term.js')).mount(dlg);
  term.focus();
}
$$('.term-open').forEach((b) => b.addEventListener('click', openTerm));
addEventListener('keydown', (e) => {
  const typing = /^(INPUT|TEXTAREA|SELECT)$/.test(document.activeElement?.tagName);
  if ((e.key === '`' && !typing) || (e.key === 'k' && (e.ctrlKey || e.metaKey))) { e.preventDefault(); openTerm(); }
});

The dialog is shown before the module arrives, so the window appears at once. The ??= means the module is mounted once and reused after that. The typing check matters: without it, nobody could type a backtick into the contact form.

The project data is lazy in the same way. Nothing is fetched until a command needs it, and then only once:

let projects = null;
const data = async () => (projects ??= await fetch('/projects.json').then((r) => r.json()));

How do you structure the commands?

Structure the commands as a plain object whose keys are command names and whose values are functions. Running a line means splitting it on whitespace, looking up the first word, and calling the function with the rest. Adding a command is adding a property, and an alias is one assignment.

const commands = {
  async cat(arg) {
    if (!arg) return print('cat: which project?', 'e');
    const p = find(await data(), arg);
    if (!p) return print(`cat: ${esc(arg)}: not found. Try ls.`, 'e');
    print(/* name, sector, year, status, line, stack, url */);
  },
  clear() { out.innerHTML = ''; },
  exit() { dlg.close(); },
};
commands.dir = commands.ls;
commands.cd = commands.open;

The dispatcher is short:

async function run(line) {
  print(esc(line), 'cmd');
  const [name, ...rest] = line.trim().split(/\s+/);
  if (!name) return;
  const fn = commands[name.toLowerCase()];
  if (!fn) return print(`${esc(name)}: command not found. Try help.`, 'e');
  try { await fn(rest.join(' ').toLowerCase()); } catch { print('Something went wrong running that.', 'e'); }
}

Note the esc() calls. Output is written as HTML so that commands can print coloured text and links. That makes escaping mandatory for anything the visitor typed, or the terminal would let a pasted line inject markup into the page. Every place user input is echoed goes through the escape function first.

The terminal never reaches into the rest of the page. It is handed its dialog element and works inside it. Lookups are forgiving: cat wp finds WP Ripper because a slug or name prefix is enough.

Why do commands need a queue?

Commands need a queue because some of them are asynchronous. The first ls waits for a network request while whoami prints immediately. If a visitor types both quickly, the second would print before the first. Chaining each command onto a single promise makes them finish in the order they were typed.

let queue = Promise.resolve(); // commands finish in the order they were typed

// on Enter
const v = input.value;
input.value = '';
if (v.trim()) { history.push(v); hi = history.length; sessionStorage.setItem('term-h', JSON.stringify(history.slice(-50))); }
queue = queue.then(() => run(v));

That is the whole mechanism. Each new command starts when the previous one has resolved. The input is cleared straight away, so the visitor can keep typing while a command is still working, and nothing is lost or interleaved.

It is the smallest version of a pattern that turns up in any interface where actions can overlap: do not let responses race, put them in a line.

How do history and tab completion work?

History is an array of past lines plus an index into it. The up and down arrows move the index and copy that line into the input. Tab completion takes the last word typed, filters a list of candidates by prefix, and fills in the match when exactly one remains.

KeyWhat it does
EnterSaves the line to history and queues the command
Up and DownWalk backward and forward through history
TabCompletes a command name, or a project slug after the first word
Ctrl and LClears the output
EscapeCloses the dialog, handled by the browser

Completion is context aware in the simplest possible way. The first word completes against command names, and anything after it completes against project slugs:

const parts = input.value.split(/\s+/);
const last = parts.at(-1).toLowerCase();
const pool = parts.length === 1 ? Object.keys(commands) : (await data()).map((p) => p.slug);
const hits = pool.filter((s) => s.startsWith(last));
if (hits.length === 1) { parts[parts.length - 1] = hits[0]; input.value = parts.join(' ') + ' '; }
else if (hits.length > 1) print(hits.join('  '), 'd');

When several candidates match, they are printed and the input is left alone, as a shell would do. History is stored in sessionStorage, limited to the last 50 lines, so it survives moving between pages and is gone when the tab closes. Nothing typed is sent anywhere.

What are the steps to build one?

Building a browser terminal is a matter of assembling small parts in a sensible order. Start with the container and the command loop, since those prove the idea, then add the conveniences that make it feel like a terminal. Each step below is a few lines of code.

  1. Add a dialog with an output area and a text input, and open it with showModal().
  2. Write a print function that appends a line and scrolls to the bottom.
  3. Create the command table and a run function that looks up and calls a command.
  4. Escape every piece of user input before printing it.
  5. Queue commands on a promise chain so output stays in order.
  6. Add history on the arrow keys and completion on Tab.
  7. Move the module behind a dynamic import so it loads on first use.

The data the commands print is the same projects.json file our build writes for the rest of the site, described in building a static site generator in one file. The snake that underlines our blog titles is loaded the same way, only when a page needs it; see animating a snake that underlines text.

Frequently asked questions

Is this a real terminal emulator?

No. A terminal emulator interprets control sequences and connects to a shell process. Ours is a text input and a table of JavaScript functions that print HTML. For a real shell in the browser, such as a remote console, use an established emulator library.

Why use a dialog element and not a styled div?

A modal dialog gives you focus trapping, Escape to close, a backdrop, and correct behaviour for assistive technology without extra code. A div needs all of that written and tested by hand, and it is easy to get wrong.

Can a terminal like this run real commands on a server?

It could send commands to an API, but be careful. Anything the terminal can do, any visitor can do. Ours only reads public project data and changes the visitor's own theme. Never forward typed text to a server shell.

Does it work on phones?

Yes, with limits. Because the input is a normal text field, the on-screen keyboard works and there is a button in the header to open it. Phone keyboards have no Tab or arrow keys, so completion and history are desktop conveniences.

Is a terminal on a website good for accessibility?

It should be an extra, never the only route. The project information ours prints is also on ordinary pages. The output is a live region and the input is labelled.

If you want an interface that suits the people who will use it, tell us about the project.

Who wrote this

The Cobra Developers team. We are a software studio in Arizona that builds and runs its own products and builds websites, web apps, and automation for clients. This post draws on our work on WP Ripper. Everything here comes from projects we shipped; see all 30 of them.

guest@cobra: ~