Go to menu

Building "terminal" UIs with HTML and CSS

October 2024

You might have nostalgia for the age of DOS

The core of all terminal user interfaces is their strict conformance to the terminal’s character grid. You could create a TUI-inspired website without this grid, maybe to have varying font sizes, but it would be like drawing pixel art with “sub-pixel” positioning.

We can start by sizing <body> to enact this grid:

html {
    display: flex;
    justify-content: center;
    align-items: center;
    min-height: 100vh;
}

body {
    font-family: monospace;
    --line-height-mult: 1.2;
    line-height: var(--line-height-mult);
    --line-height: calc(1em * var(--line-height-mult));

    --body-width: calc(round(down, 100vw, 1ch));
    width: var(--body-width);
    --body-height: calc(round(down, 100%, 1em));
    min-height: calc(round(down, 100vh, 1em));
    height: var(--body-height);
    box-sizing: border-box;
    margin: 0;
}

First we use flexbox to centre <body> in <html>, then we use calc() and round() to make the size of <body> an exact multiple of the character size. This uses the ch unit (width of one character).

Sadly, we can’t use 1em directly for line height, since it measures the character only and doesn’t include inter-line spacing. To work around this, we set a static line height multiplier and create --line-height.

To make the effect more clear, we can add some light styling:

html {
    background-color: black;
}

body {
    padding: var(--line-height) 1ch;

    background-color: #00a;
    color: white;
    font-size: 14pt;
}
Hi, all!<br>


<pre>
This is a table:

+----------+-----------+
| Column 1 | Column 2  |
+----------+-----------+
| See how  | the cells |
| line up? |           |
+----------+-----------+
</pre>

We can already do a lot with just using Unicode and some <pre> tags, just like if we were writing a UNIX command. But the generating code will be hairy coordinate manipulation and it won’t resize to fit the screen. Can we use more HTML and CSS in this style?

Let’s start with some basic 90s elements. All we really need to do is to get rid of any default styles that would break our grid. And to add some TurboVision-esque visual flair:

p, pre {
    margin: 0;
}

ul {
    margin: 0;
    padding: 0;
    list-style: none;
}
ul > li::before {
    content: "-";
    padding-right: 1ch;
}

ol {
    margin: 0;
    padding: 0;
    list-style: none;
    counter-reset: ol;
}
ol > li {
    counter-increment: ol;
}
ol > li::before {
    content: counter(ol) ".";
    padding-right: 1ch;
}

button {
    all: unset;
    background-color: #0a0;
    padding: 0 3ch;
    box-shadow: 1ch calc(var(--line-height)/2) black;
}
button:active {
    box-shadow: none;
    margin-left: 1ch;
}
button:focus {
    background-color: #a00;
}

For any element we use, we first need to strip it of its default styling, so it fits into our character grid. <p> didn’t need anything more than that.

Lists are more complicated, and we have a choice: We can use ::marker, which positions itself to the left of <li>s. This would mean setting padding-left on <li> to compensate for the new content; easy for <ul>, less so for <li> and its lariable-length list marker. Instead we disable the default marker and use ::before, which “properly” moves everything to accomodate its content.

The <button> style demonstrates some period-accurate effects.

<p>This is a paragraph.</p>
<br>
<ul>
    <li>And this...
    <li>is a list!
</ul>
<ol>
    <li>Number one
    <li>Number two
    <li>Number... three!
</ol>
<br>
We even have <button>buttons!</button>

What about layout? Just using CSS grid or flexbox without care would break our carefully crafted grid. Let’s start with something simple, two equal-width columns:

.columns-2 {
    columns: 2;
}
.columns-2 > .col {
    width: round(down, 100%, 1ch);
}
<!-- Make width purposely not a multiple of 1ch -->
<div class=columns-2 style="max-width: 450px">
    <div class=col>This is a standard two-column layout. But it forces its
    contents to render with 1ch-multiple widths.</div>
</div>

Now we can try something more complex and interactive: Forms! Specifically, a two-column form with labels in one and inputs in the other.

input:not([type=submit]), select {
    all: unset;
    background-color: #bbb;
    color: black;
    width: round(down, 100%, 1ch);
}

:is(input:not([type=submit]), select):focus {
    background-color: #fff;
}

.form-2col {
    display: grid;
    grid-template-columns: auto 1fr;
    column-gap: 1ch;
    width: round(down, 100%, 1ch);
}
.form-2col > label {
    display: contents;
}
.form-2col > :is(input[type=submit], button) {
    grid-column: 2;
    justify-self: end;
    margin-top: var(--line-height);
    margin-right: 4ch;
}
<pre>_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _</pre>
<div style="max-width: 600px">
    <form class=form-2col>
        <label>
            <span>Name:</span>
            <input type=text>
        </label>
        <label>
            <span>Date of birth:</span>
            <input type=date>
        </label>
        <label>
            <span>Type:</span>
            <select>
                <option>Individual</option>
                <option>Company</option>
            </select>
        </label>
        <button>Save</button>
    </form>
</div>

Getting a two-column layout is pretty simple, almost the same as in a “normal” GUI. The only <D-;>

I’ve added a small underline ruler at the top to show that the elements are really aligned to the grid.