GODRICH175

Terminal UI Design: 9 Copy-Paste CLI Parts

Terminal UI design is the craft of putting a command-line window inside a web page.

Auto-plays · click a tile to jump to its section · all nine in one zip

The order follows how a reader meets these parts while skimming developer docs, not how hard they are to build. The opening part is where a hand lands first: rewrite the install line into your own package manager's syntax and copy it (01). Then two that answer "so what does it print?" — run a command and watch output stack up (02), and try on a prompt shape (03). Then two that move on their own while you read — a log chasing its last line (04), and three jobs filling bars drawn out of characters (05). The hand comes back for Tab completion (06), and then the eye does the work, folding diff output (07). The last two are about the window itself: drag a corner to grow it (08), and walk three steps of a guided shell (09). Copy buttons, line numbers, and language tabs that belong to a code block are covered in 9 Code Block UI Patterns; if all you want is letters appearing one at a time, 9 CSS Typing Animation is closer. Keycap notation and shortcut hints live in Keyboard Shortcuts UI Design. Stage colors are six ink, two paper, and one yellow — a terminal belongs on a dark ground, so this set skips the usual rotation and only splits off the light-theme terminal (07) and the windows that sit on a page (08, 09).

01Install command copy box

Tap npm, pnpm, or yarn and the install line rewrites itself in that tool's syntax. The three lines sit stacked in one grid cell and are chosen with visibility alone, so the row height never shifts when the syntax changes, and the copy button really writes to the clipboard through navigator.clipboard. The preview loop mixes tab cuts with a button fill and measured 3.31% changed area over 11 of 23 frames.

navigator.clipboardaria-livesteps(1, end)
copyBtn.addEventListener('click', function () {
  ic.classList.remove('is-demo');
  var text = currentCommand();
  if (navigator.clipboard && navigator.clipboard.writeText) {
    navigator.clipboard.writeText(text).then(showDone, showDone);
  } else {
    showDone();
  }
});

02Command run and output lines

Type a command, press Enter, and the output lines stack up one by one; the summary line only prints after the spinner has turned into a done mark. The lines are staggered with animation-delay, and without animation-fill-mode: backwards alongside it, the finished line flashes once at frame zero during the delay. Because the spinner keeps turning, the movement count climbed to 17 of 23 frames.

animation-delayanimation-fill-modearia-live
.rn.is-demo .rn__line {
  animation-name: rnRise; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
  animation-iteration-count: infinite; animation-fill-mode: backwards;
  // each row starts --i later. the cycle stays 2s so it never drifts from one gif turn
  animation-delay: calc(var(--i) * 80ms);
}
// rows cut in, they do not fade — a half-transparent middle frame is the one that becomes the poster
@keyframes rnRise {
  0% { opacity: 0; }
  6%, 100% { opacity: 1; }
}

03Prompt theme switcher

Prompt shape is the cheapest win in cli ui design

One prompt line, swapped between three shapes: plain, wave, and segment. The angled seam between segments is not a stacked triangle — clip-path shaves the right edge and the next segment bites into that notch with margin-left: -8px. The chosen shape is announced with aria-pressed, and the preview loop measured 3.63% changed area at a frame-to-frame intensity of 112.4.

clip-patharia-pressedsteps(1, end)
.pt__seg {
  display: inline-flex; align-items: center;
  box-sizing: border-box; height: 22px; padding: 0 $sp-3 0 $sp-2;
  color: $stage-ink; font-weight: 700;
  clip-path: polygon(0 0, calc(100% - #{$notch}) 0, 100% 50%, calc(100% - #{$notch}) 100%, 0 100%);
}
.pt__seg--a { background: $subject-sky; padding-left: $sp-2; }
.pt__seg--b { background: $stage-yellow; margin-left: -$notch; padding-left: $sp-3; }
.pt__seg--c { background: $color; margin-left: -$notch; padding-left: $sp-3; padding-right: $sp-2; }

04Log follow window

Every new row pushes scrollTop up to scrollHeight, so the pane keeps chasing the last line. Switch following off and rows keep arriving while the view stays where you were reading; switch it back on and it snaps to the bottom — when measured, scrollTop held at 0 while it was off and read 90, the exact bottom, once it was on again. Levels are told apart by the leading INFO, WARN, and ERR text, not by color alone.

scrollToparia-livetranslateY
function append() {
  var item = FEED[at % FEED.length];
  at += 1;
  var p = document.createElement('p');
  p.className = 'lg__row is-new' + (CLS[item[0]] || '');
  p.innerHTML = '<span class="lg__tag"></span>';
  p.querySelector('.lg__tag').textContent = item[0];
  p.appendChild(document.createTextNode(item[1]));
  track.appendChild(p);
  while (track.children.length > 40) { track.removeChild(track.firstChild); }
  // push to the bottom only while following is on — off, it stays where you were reading
  if (follow.getAttribute('aria-pressed') === 'true') { box.scrollTop = box.scrollHeight; }
}

05Progress bars drawn with characters

The bar is not a shape but sixteen fill characters, and only the filled copy is cut open with clip-path: inset. Put steps(12, end) on that cut and one step is exactly one glyph, so the bar can only grow character by character, while the percent number comes from a --p registered with @property and printed by counter() on the same step. An unregistered custom property is just a string, so it would jump straight from 0 to the target.

aria-valuenowfont-variant-numericanimation-fill-mode
@property --p {
  syntax: "<integer>";
  inherits: true;
  initial-value: 0;
}
.pg__pct::after { counter-reset: p var(--p); content: counter(p) "%"; }
.pg.is-demo .pg__row--1, .pg.is-run .pg__row--1 { animation-name: pgNum1; animation-timing-function: steps(12, end); }
@keyframes pgNum1 {
  0% { --p: 0; }
  80%, 100% { --p: 75; }
}
@keyframes pgFill1 {
  0% { clip-path: inset(0 100% 0 0); }
  80%, 100% { clip-path: inset(0 25% 0 0); }
}

06Tab completion candidates

Press Tab and preventDefault holds focus in place while the candidates open as a two-column grid. As arrows move the choice, aria-activedescendant on the input points to the current candidate's id so a screen reader reads the same row, and Enter drops the chosen name into the input and closes the list. When measured, Tab left focus on the input (cmd), and two arrow presses walked the pointer from c0 to c2.

preventDefaultaria-activedescendantgrid-template-columns
cmd.addEventListener('keydown', function (e) {
  if (e.key === 'Tab' && !e.shiftKey) { e.preventDefault(); openList(); return; }
  if (!open) { return; }
  if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); mark(at + 1); }
  else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); mark(at - 1); }
  else if (e.key === 'Enter') {
    e.preventDefault();
    cmd.value = 'git ' + cands[at].textContent;
    open = false;
    tc.classList.remove('is-open');
    cmd.setAttribute('aria-expanded', 'false');
    cmd.removeAttribute('aria-activedescendant');
  }
});

07Folding diff output

This folds diff text the terminal has already printed, one hunk header at a time. The fold slides grid-template-rows between 0fr and 1fr to interpolate height, and the inner box needs both min-height: 0 and overflow: hidden before 0fr actually squeezes it — when measured, an inner box 88px tall when open went to 0px when folded. Added and removed lines are told apart by the row tint and the leading sign.

grid-template-rowsaria-expandedfont-variant-numeric
// the folding cell: 0fr squeezes only if the inner box carries min-height: 0
.dh__body {
  display: grid; grid-template-rows: 0fr;
  transition: grid-template-rows $duration $easing;
}
.dh__head[aria-expanded="true"] + .dh__body { grid-template-rows: 1fr; }
.dh__inner { min-height: 0; overflow: hidden; }
.dh__row--add { position: relative; background: rgba(43, 157, 116, .22); }
.dh__row--del { background: rgba(194, 54, 26, .14); }

08Resizable terminal window

The window itself, in command line ui design

Drag the corner grip and the window grows or shrinks. setPointerCapture ties the pointer to the grip so pointermove keeps arriving even when the cursor leaves the window, and the limits live in one place — CSS clamp() — because clamping in two places lets the numbers drift apart. When measured, a 60 by 20 drag toward the bottom right took the window from 300 by 104 to 360 by 124, and the readout inside it showed the same pair.

setPointerCapturepointermoveclamp
grip.addEventListener('pointerdown', function (e) {
  wc.classList.remove('is-demo');
  grip.setPointerCapture(e.pointerId);
  from = { x: e.clientX, y: e.clientY, w: win.offsetWidth, h: win.offsetHeight };
  readOut();
});
grip.addEventListener('pointermove', function (e) {
  if (!from) { return; }
  win.style.setProperty('--w', (from.w + (e.clientX - from.x)) + 'px');
  win.style.setProperty('--h', (from.h + (e.clientY - from.y)) + 'px');
  readOut();
});

09Step-by-step tutorial shell

Three steps hand out one task at a time, and the right command moves you on. What you typed is trimmed and collapsed to single spaces before it is compared, so ls -a passes too, and the progress bar grows from its left edge with scaleX. Right or wrong is stated in words as well as in color, and announced once through aria-live.

aria-livescaleXtransform-origin
form.addEventListener('submit', function (e) {
  e.preventDefault();
  tu.classList.remove('is-demo');
  // however many spaces sit between the words, read it as the same command
  var typed = ans.value.trim().replace(/\s+/g, ' ');
  if (typed !== WANT[step - 1]) { show('is-no'); return; }
  show('is-ok');
  ans.value = '';
  if (step < WANT.length) {
    step += 1;
    tu.setAttribute('data-step', String(step));
    rail.setAttribute('aria-valuenow', String(step));
  }
});

Where it breaks — the trap

The longest fight here was terminal colors falling short of the body-text ratio on a dark stage. The blue, green, and red everyone pictures when they think of a terminal sit right on the edge. The path text in 03 started as #2f6df6, which is 3.57:1 on a #232025 ground and short of the 4.5:1 body minimum; switching to sky #6ec8ff took it to 8.74:1. The removed-line sign in 07 failed in the other direction — #c2361a on a bright #f6e3df row measured 4.42:1, missing by 0.08, so the sign went back to ink at 14.74:1 and the job of separating rows was left to the tint and the leading -. The empty glyphs in 05 sat at 1.79:1 with rgba(255, 255, 255, .18), which hid where the bar ended, and .38 brought them to 3.51:1. The measured table for all fifty-two pieces of on-screen text is in run/294/_probe/_대비실측.json, and the before values with the method are in run/294/_수리전실측.json.

The second is that the poster image is always taken from the frame that changed most. Letting the output lines in 02 rise on opacity, the way they do in real use, made the biggest frame the one where only the third line is half transparent, so the poster was forever a half-drawn terminal. Cutting the line entrance with steps(1, end) handed the same measurement a frame that reaches the done mark. Item 07 was worse: folding the hunk back inside the loop made the closing side change more area than the opening side, so an empty box owned the poster at 25.92% changed area over 8 of 23 frames. Dropping the fold-back from the loop and filling the missing frames with the left bar on added rows and a pop on the count chips gave 18.23% over 13 of 23. A real click still folds smoothly through transition.

The third is that the preview runs on CSS alone, so any number left as text drifts out of step. The step badge in 09 was a plain <span>1</span>, and while the task text moved on to step two, the badge stayed at one. Swapping the badge for a --step registered with @property and printed by counter() advances it at the same keyframe percentage as the task, and the size readout in 08 reads --wn and --hn the same way, so the preview and a real drag share one value. The nine originals with all of this already fixed are zipped up, and the password that opens the archive is <span class="pw-inline" id="pw">n85asnxb</span>.

Three variations

Name Changed value Feel
Twenty-cell bar 05 sixteen fill characters to twenty, steps(12, end) to steps(15, end) Finer notches, so the same 75% reads as a denser climb
Slow log 04 setInterval(append, 700) to 1200 Rows arrive slowly enough to read the stream as it goes by
Deeper window 08 height: clamp(88px, var(--h), 132px) to clamp(88px, var(--h), 180px) A taller window: 139px of room under the title bar, which is eight output rows

AI prompt

When you build a new terminal part with vibe coding, nailing down what shows, what moves, and what is banned in one go cuts the number of rewrites. Here is the fragment used for the character progress bars in 05.

Build a CLI progress bar drawn out of characters.
Shows: a dark card with three job rows; each row is a name, sixteen fill cells inside brackets, a percent, and an ETA.
Moves: cut only the filled copy with clip-path inset and step it with steps(cells, end) so one step equals one glyph.
Print the percent from an integer custom property registered with @property plus counter(), on that same step.
Constraints: lock the number columns with font-variant-numeric: tabular-nums; the cycle must divide two seconds.
Banned: drawing the bar as a div width; rewriting the percent from JS on every frame.

Swap steps(cells, end) for linear and the bar starts cutting glyphs in half, which stops it from reading as characters at all — so when the result feels slippery, that is the one word to check first.

Accessibility

Under prefers-reduced-motion: reduce, all nine animations stop and each part keeps one frame that still says what is switched on. 01 rests with the npm tab chosen; 02 rests with all four lines printed and the done mark lit. 03 shows only the plain prompt, 04 shows log rows that do not flow, 05 leaves the three bars at their target values, and 06 rests with the candidates open. 07 stays unfolded, 08 sits at its starting size, and 09 rests on the first task. To put state into words as well, the status line in 01 and the verdict chips in 09 use aria-live, the output pane in 02 and the log pane in 04 use role="log", the choices in 03 and 08 use aria-pressed and aria-selected, the input in 06 uses aria-activedescendant, the hunk header in 07 uses aria-expanded, and progress in 05 and 09 is written as role="progressbar" with aria-valuenow. Levels in 04 are never carried by color alone — the leading word rides along with it — and the added and removed rows in 07 pair their tint with a + or -. For keyboard-only users, the grip in 08 takes focus and the arrow keys resize the window: when measured, one press of the right arrow took the width from 360 to 376. Tab in 06 is only intercepted while that input holds focus, so once the list closes, Tab moves on to the next element as usual.

FAQ

Which browsers support navigator.clipboard?

Rechecked on caniuse in September 2026, it is 96.39% globally — Chrome 66, Edge 79, Firefox 63, and Safari 13.1 (iOS 13.4) and up. That is why 01 tests for navigator.clipboard first and falls back to lighting the done mark without a copy. Note that the API is only allowed inside a user gesture such as a click, so calling it as the page loads is rejected. The full spec is on MDN Clipboard.writeText.

Can a percent number animate without @property?

An unregistered custom property is treated as a string, so it jumps from 0 to 75 with nothing in between. Declaring syntax: "<integer>" through @property is what makes it interpolate as a number, and only then does counter() print a different figure each frame. Support checked the same day sits at 95.01% globally (Chrome and Edge 85, Safari 16.4, Firefox 128 and up), which is why 05, 08, and 09 use it directly; where it is missing, the initial-value stays put, so the bar moves and the number holds.

Will the log pane slow down as rows pile up?

04 drops rows from the front once track.children.length passes 40. Without a ceiling on node count, a tab left open all day keeps thousands of rows around, and the scroll math gets heavy. If you need to handle a longer stream, the grouping and collapsing in 9 Activity Feed UI Widgets is a useful reference.

Enter the archive password

The password is inside this article. You will find it as you read.