GODRICH175

ターミナル風 UI 9選 — コピペで動くCLIパーツ

ターミナル風の UI とは、Web ページの中に黒いコマンド画面のような部品を組み込んだ見た目のことです。ここに並ぶ9つは、インストール行のコピー箱からログ窓・文字で描く進捗バー・サイズを変えられる窓・チュートリアル窓まで、貼り付ければそのまま動きます。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

並べた順番は技法の難しさではなく、読者が開発ドキュメントを眺めるときにこれらの部品と出会う順です。最初はいちばん先に手が伸びるもの — インストール行を自分の道具の文法に切り替えてコピーします(01)。次は「で、何が出るのか」を見せるもの2つ — コマンドを打って出力が積まれるのを見て(02)、プロンプトの形を選んでみます(03)。続いて放っておいても流れるもの2つ — ログが最終行を追いかけ(04)、3つのジョブの進捗が文字で描いたバーで埋まります(05)。ふたたび手が入るのが Tab 補完(06)と、読む側が折りたためる diff 出力(07)です。最後の2つは窓そのものを扱うもの — 角をドラッグして窓を広げ(08)、3ステップをたどってコマンドを覚えます(09)。コードブロック自体のコピーボタン・行番号・言語タブはコードブロック UI 9選が扱い、文字が1つずつ打たれる効果だけが欲しいならCSS タイピング アニメーション 9選のほうが近道です。キーキャップ表記やショートカット案内はショートカットキー 表示 UI 9選にあります。ステージ色はインク6・ペーパー2・イエロー1 にしました — ターミナルは暗い地が定石なので番号順のローテーションには従わず、明るいテーマのターミナル(07)とページの上に載る窓(08・09)だけを分けています。

01インストールコマンドのコピー箱

npm・pnpm・yarn のタブを押すと、同じパッケージのインストール行がその道具の文法に切り替わります。3行は同じグリッドのマスに重ねて visibility だけで選ぶので、文法が変わっても行の高さがぶれません。コピーボタンは navigator.clipboard で本当にクリップボードへ入れます。プレビューのループはタブのカットとボタンの塗りを混ぜ、累積面積 3.31%・動いたフレーム 11/23 を記録しました。

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();
  }
});

02コマンド実行と出力行

入力欄にコマンドを打って Enter を押すと出力行が順に積まれ、回っていたスピナーが完了表示に変わってから要約行が出ます。行ごとに現れる時刻をずらしているのは animation-delay ですが、animation-fill-mode: backwards を一緒に付けないと、遅延のあいだに完成後の行が0フレーム目で一度だけちらつきます。スピナーが回り続けるおかげで動いたフレームは 17/23 まで上がりました。

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;
  // 行ごとに --i の分だけ遅れて始まる。周期は2秒のままなので gif 一周とずれない
  animation-delay: calc(var(--i) * 80ms);
}
// 行の登場はフェードではなくカット — 半透明の中間フレームができると、その場面が poster に選ばれる
@keyframes rnRise {
  0% { opacity: 0; }
  6%, 100% { opacity: 1; }
}

03プロンプトテーマの切り替え

プロンプトの形が決める ターミナル UI の第一印象

同じ一行プロンプトを標準・波・セグメントの3つの形に差し替えます。セグメントの折れた継ぎ目は三角形を別に重ねるのではなく、clip-path で右端を削って作り、次のセグメントがその切り欠きに margin-left: -8px で噛み合います。選んだ形は aria-pressed で伝え、プレビューのループは累積面積 3.63%・フレーム間の変化の強度 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; }

04ログ追従ウィンドウ

新しい行が付くたびに scrollTopscrollHeight まで押し、画面が最終行を追いかけ続けます。追従を切ると、行が増え続けても画面は読んでいた位置に止まり、もう一度入れると最下部へ戻ります — 実測では切っているあいだ scrollTop は 0 のままで、入れ直したあとは最下部と同じ 90 になりました。ログレベルは色だけでなく行頭の INFO・WARN・ERR の文字でも見分けられます。

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); }
  // 追従が入っているときだけ最下部へ押す — 切っていれば読んでいた位置に留まる
  if (follow.getAttribute('aria-pressed') === 'true') { box.scrollTop = box.scrollHeight; }
}

05文字で描く進捗バー

バーは図形ではなく塗り文字16個で描き、塗った側だけを clip-path: inset で切り出して見せます。切る幅に steps(12, end) を掛けると一歩がちょうど一文字になるので、バーは文字単位でしか伸びません。パーセントの数字は @property で登録した --pcounter() で打ち、バーと同じ歩幅で動きます。登録していないカスタムプロパティは文字列なので、0 から目標値へ一気に飛んでしまいます。

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 補完の候補

Tab を押すと preventDefault がフォーカス移動を止め、候補が2列のグリッドで開きます。矢印キーで選ぶあいだは入力欄の aria-activedescendant が今の候補の id を指すのでスクリーンリーダーも同じ行を読み、Enter は選んだ名前を入力欄に入れて一覧を閉じます。実測では Tab を押してもフォーカスは入力欄(cmd)に残り、矢印キーを2回押すと c0 から 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');
  }
});

07diff 出力の折りたたみ

ターミナルが打ち出した diff のテキストを、変更ブロックの見出し単位で折りたたみます。折りたたみは grid-template-rows0fr1fr のあいだで動かして高さを補間しますが、内側の箱に min-height: 0overflow: hidden の両方がないと 0fr で実際にはつぶれません — 実測では開いているとき 88px だった内側が、折りたたむと 0px になりました。追加行と削除行は背景の色味と行頭の記号で見分けます。

grid-template-rowsaria-expandedfont-variant-numeric
// 折りたたむマス: 0fr でつぶれるには内側に 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); }

08サイズ変更できるターミナル窓

コンソール UI では窓そのものも部品になる

角のつまみをドラッグすると窓が大きくなったり小さくなったりします。setPointerCapture がポインタをつまみに結び付けるのでカーソルが窓の外に出ても pointermove は途切れず、限界は JS ではなく CSS の clamp() 一か所が決めます — 二か所で切ると値がずれていきます。実測では右下へ 60×20 だけ引くと窓は 300×104 から 360×124 に伸び、窓の中の表示も同じ数値を示しました。

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();
});

09ステップ式チュートリアル窓

3つのステップが順に課題を出し、正しいコマンドを打つと次へ進みます。打った文字は前後の空白を落とし、語間の空白を1つに詰めてから比べるので ls -a のように空けても通り、進捗バーは scaleX で左の辺から伸びます。正解か不正解かは色だけでなく文字でも伝え、aria-live で一度読み上げられます。

aria-livescaleXtransform-origin
form.addEventListener('submit', function (e) {
  e.preventDefault();
  tu.classList.remove('is-demo');
  // 語間の空白が何個でも同じコマンドとして見る
  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));
  }
});

どこで壊れるか — 落とし穴

いちばん時間を取られたのは、暗いステージの上でターミナルらしい色が本文の基準に届かないことでした。ターミナルと聞いて思い浮かぶ青・緑・赤は、そのまま使うとコントラスト比がぎりぎりです。03 のプロンプトのパス文字を #2f6df6 にしたところ #232025 の地では 3.57:1 で、本文基準の 4.5:1 に届きませんでした。水色の #6ec8ff に替えて 8.74:1 になっています。07 の削除行の記号は逆向きでした — 明るい #f6e3df の上の #c2361a が 4.42:1 で 0.08 だけ足りず、記号の色をインクに戻して 14.74:1 にし、行を分ける仕事は背景の色味と行頭の - に任せました。05 の空きマスのグリフは rgba(255, 255, 255, .18) で 1.79:1 となり、バーの終わりがどこか見えなかったので .38 へ上げて 3.51:1 にしています。画面文字52か所の実測比率表は run/294/_probe/_대비실측.json に、直す前の値と測り方は run/294/_수리전실측.json に残しました。

2つめは、代表画像がいつも最も大きく変わったフレームから選ばれることです。02 の出力行を実使用どおり opacity で浮かせたところ、24フレームのうち3行目だけが半透明の場面が最大の変化になり、代表画像がいつも「半分だけ描かれたターミナル」になりました。行の登場を steps(1, end) のカットに替えると、同じ測定で完了表示まで写った一枚が代表になりました。07 はもっと厄介でした — ループの中で変更ブロックをまた折りたたむと、閉じる側のほうが開く側より変わる面積が大きく、空の箱が代表画像を占めてしまいました(累積面積 25.92%・動いたフレーム 8/23)。ループから折りたたみを外し、足りないフレームは追加行の左バーとチップの跳ねで埋めて 18.23%・13/23 になりました。実際のクリックは今も transition でなめらかに閉じます。

3つめは、プレビューが CSS だけで回るため、数字を文字のまま置くとずれることです。09 のステップ番号を <span>1</span> と書いていたら、課題の文は2つめに進んだのに番号は1のまま止まっていました。番号を @property で登録した --stepcounter() に替えて課題と同じパーセントで進むようにし、08 の窓サイズ表示も同じやり方で --wn--hn を読ませ、プレビューと実際のドラッグが一つの値を使うようにしました。ここまで直した結果がそのまま入った9組の原本を zip にまとめてあり、その zip を解凍するパスワードは <span class="pw-inline" id="pw">n85asnxb</span> です。

3つのバリエーション

名前 変えた値 感じ
20マスのバー 05 塗り文字16個 → 20個、steps(12, end)steps(15, end) 目盛りが細かくなり、同じ75%でも進み具合を細かく読めます
ゆっくりログ 04 setInterval(append, 700)1200 行がゆっくり増え、流れる文字を目で追えます
深い窓 08 height: clamp(88px, var(--h), 132px)clamp(88px, var(--h), 180px) タイトル行の下に 139px の余地ができ、出力行が8行入ります

AIプロンプト

バイブコーディングでターミナル部品を新しく作るときは、見えるもの・動き・禁止の3つを一度に決め打ちすると書き直しの回数が減ります。下は 05 の文字進捗バーを作るときに使った断片です。

文字で描く CLI 進捗バーを作れ。
見えるもの: 暗いカードの中にジョブ3行。各行は名前、角かっこ内の塗りマス16個、パーセント、残り時間。
動き: 塗った側だけを clip-path inset で切り、steps(マス数, end) で刻んで一歩が一文字になるようにせよ。
パーセントの数字は @property で登録した整数カスタムプロパティと counter() で打ち、バーと同じ歩幅にせよ。
制約: 数字の欄は font-variant-numeric: tabular-nums で幅を固定。周期は2秒の約数にせよ。
禁止: バーを div の幅で描くこと。パーセントを JS で毎フレーム書き直すこと。

steps(マス数, end)linear に替えるとバーが文字の途中で半マスずつ切れ、文字のバーには見えなくなります。動きがぬるっとするときは、まずこの一語を見てください。

アクセシビリティ

画面の動きを減らす prefers-reduced-motion: reduce の設定では9つのアニメーションをすべて止め、何が入っているか読み取れる一枚を残します。01 は npm のタブが選ばれた状態で、02 は4行が出そろい完了表示が点いた画面で止まります。03 は標準プロンプトだけを表示し、04 は流れないログの行で、05 は3本のバーが目標値に立ったまま、06 は候補が開いた状態で止まります。07 はブロックを開いたままにし、08 は最初の大きさの窓で、09 は1ステップ目の課題で止まります。状態を言葉でも伝えるため、01 のステータス行と 09 の判定チップは aria-live、02 の出力欄と 04 のログ欄は role="log"、03・08 の選択は aria-pressedaria-selected、06 の入力欄は aria-activedescendant、07 の見出しは aria-expanded、05・09 の進捗は role="progressbar"aria-valuenow で書いています。04 のログレベルは色だけに任せず行頭の文字を添え、07 の追加行・削除行も背景の色味と +- の記号を並べます。マウスを使わずキーボードだけで操作する人のために、08 のつまみはフォーカスを受けて矢印キーでも窓を広げられます — 実測では右矢印を1回押すと幅が 360 から 376 に伸びました。06 の Tab はその欄がフォーカスを持っているあいだだけ横取りするので、一覧を閉じれば Tab は元どおり次の要素へ移ります。

FAQ

navigator.clipboard はどのブラウザまで使えますか

caniuse を2026年9月に見直した値で全体 96.39% です — Chrome 66、Edge 79、Firefox 63、Safari 13.1(iOS 13.4)以上で使えます。そのため 01 は navigator.clipboard があるかを先に見て、無ければコピーせずに完了表示だけを点ける経路へ切り替えます。なおこの API はクリックなどのユーザー操作の中でしか許されないので、ページが開いた直後に呼ぶと拒否されます。仕様は MDN Clipboard.writeText にあります。

@property なしでパーセントの数字を動かせませんか

登録していないカスタムプロパティはブラウザが文字列として扱うので補間されず、0 から 75 へそのまま飛びます。@propertysyntax: "<integer>" を宣言してはじめて整数として補間され、そのとき counter() が毎フレーム違う数字を打ちます。対応範囲は同じ日の caniuse で全体 95.01%(Chrome・Edge 85、Safari 16.4、Firefox 128 以上)なので 05・08・09 でそのまま使い、使えない環境では initial-value がそのまま見えて、バーだけが動き数字は止まります。

ログ窓に行が積もり続けても重くなりませんか

04 は track.children.length が 40 を超えると先頭の行から消します。ノード数に上限を置かないと、長く開いたままのタブに数千行が残ってスクロールの計算が重くなります。流れる一覧をもっと長く扱いたいときは、サイドバー向けの活動フィードを集めた通知 ui 9選の折りたたみ・まとめ方が参考になります。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。