터미널 UI 9종 — 설치 명령 상자부터 튜토리얼 창까지
터미널 UI는 웹 화면 안에 검은 명령창처럼 보이도록 짜 넣는 조각입니다. 여기 아홉 가지는 설치 명령 복사 상자부터 로그 창·문자 진행 막대·크기를 바꾸는 창·튜토리얼 창까지, 붙여 넣으면 바로 도는 형태로 만들어 두었습니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 설치 명령 복사 상자
- 02 명령 실행과 출력 줄
- 03 프롬프트 테마 전환
- 04 로그 따라가기 창
- 05 문자로 그린 진행 막대
- 06 Tab 자동완성 후보
- 07 diff 출력 접기
- 08 터미널 창 크기 조절
- 09 단계별 튜토리얼 창
늘어놓은 기준은 기법의 난이도가 아니라 독자가 개발자 문서를 훑을 때 이 조각들을 만나는 차례입니다. 맨 위에서 손이 먼저 닿는 쪽 하나 — 설치 줄을 자기 도구의 문법으로 바꿔 복사합니다(01). 다음은 「그래서 뭐가 나오나」를 보여 주는 쪽 둘 — 명령을 쳐 출력이 쌓이는 것을 보고(02), 프롬프트 생김새를 골라 봅니다(03). 그다음은 가만히 두면 저절로 흐르는 쪽 둘 — 로그가 마지막 줄을 따라 내려가고(04), 작업 세 개가 문자로 그린 막대를 채웁니다(05). 손이 다시 들어가는 쪽 하나는 Tab 자동완성이고(06), 읽어 내는 쪽 하나는 접었다 펴는 diff 출력입니다(07). 마지막 둘은 창 자체를 다루는 쪽 — 모서리를 끌어 창을 늘리고(08), 세 단계를 따라가며 명령을 익힙니다(09). 코드 블록 자체의 복사 버튼·줄 번호·언어 탭은 코드 블록 UI 9종이 다루고, 글자가 한 자씩 찍히는 효과만 필요하다면 CSS 타이핑 효과 9종이 더 가깝습니다. 키캡 표기와 단축키 안내는 키보드 단축키 UI 9종에 있습니다. 무대 색은 잉크 여섯·종이 둘·노랑 하나로 잡았습니다 — 터미널은 어두운 바탕이 정석이라 번호순 순환을 따르지 않고, 밝은 테마 터미널(07)과 페이지 위에 얹힌 창(08·09)만 갈라 두었습니다.
01설치 명령 복사 상자
npm·pnpm·yarn 탭을 누르면 같은 패키지의 설치 줄이 그 도구의 문법으로 갈립니다. 세 줄은 같은 격자 칸에 겹쳐 두고 visibility 로만 고르기 때문에 문법이 바뀌어도 줄 높이가 흔들리지 않고, 복사 버튼은 그 줄을 navigator.clipboard 로 진짜 클립보드에 넣습니다. 미리보기 루프는 탭 전환 컷과 버튼 채움을 섞어 누적면적 3.31%·움직인 프레임 11/23 을 기록했습니다.
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 를 묶지 않으면 지연 구간에 줄이 애니메이션 시작 모습이 아니라 원래 모습으로 남아 한 번 번쩍입니다. 스피너가 계속 도는 덕에 움직인 프레임이 17/23 까지 올라갔습니다.
.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 로 뽑힌다 (248 04·09 실측)
@keyframes rnRise {
0% { opacity: 0; }
6%, 100% { opacity: 1; }
}
03프롬프트 테마 전환
터미널 디자인에서 프롬프트 한 줄이 하는 일
같은 한 줄 프롬프트를 기본·물결·조각 세 모양으로 갈아 끼웁니다. 조각 모양의 꺾인 이음매는 삼각형을 따로 겹치지 않고 clip-path 로 오른쪽 끝을 깎아 만들고, 다음 조각이 그 홈에 margin-left: -8px 로 물립니다. 고른 모양은 aria-pressed 로 알리고, 미리보기 루프는 누적면적 3.63%·프레임 사이 변화 강도 112.4 를 기록했습니다.
.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로그 따라가기 창
새 줄이 붙을 때마다 scrollTop 을 scrollHeight 로 밀어 화면이 마지막 줄을 계속 따라갑니다. 따라가기를 끄면 줄은 계속 붙되 화면은 읽던 자리에 멈추고, 다시 켜면 바닥으로 돌아옵니다 — 실측에서 끈 동안 scrollTop 은 0 에 머물렀고 켠 뒤에는 90 으로 바닥값과 같아졌습니다. 등급은 색뿐 아니라 줄 맨 앞 INFO·WARN·ERR 글자로도 갈립니다.
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문자로 그린 진행 막대
막대를 도형이 아니라 채움 문자 열여섯 개로 그리고, 채운 쪽만 clip-path: inset 으로 잘라 보여 줍니다. 자르는 폭에 steps(12, end) 를 걸면 한 걸음이 정확히 한 글자라 막대가 문자 단위로만 늘어나고, 퍼센트 숫자는 @property 로 등록한 --p 를 counter() 로 찍어 막대와 같은 걸음으로 움직입니다. 등록하지 않은 커스텀 속성은 문자열이라 0 과 목표값 사이를 그냥 뛰어넘습니다.
@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 가 초점 이동을 막고 후보가 두 열 격자로 펼쳐집니다. 화살표로 고르는 동안 입력칸의 aria-activedescendant 가 고른 후보의 id 를 가리켜 스크린리더도 같은 줄을 읽고, Enter 는 고른 이름을 입력칸에 넣고 목록을 닫습니다. 실측에서 Tab 을 눌러도 초점은 입력칸(cmd)에 그대로 남았고 두 번 누른 화살표는 c0 에서 c2 로 옮겨 갔습니다.
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-rows 를 0fr 과 1fr 사이에서 옮겨 높이를 보간하는데, 안쪽 상자에 min-height: 0 과 overflow: hidden 이 함께 있어야 0fr 에서 실제로 눌립니다 — 실측에서 펼쳤을 때 88px 이던 안쪽이 접으면 0px 이 됐습니다. 더한 줄과 뺀 줄은 배경 색조와 맨 앞 기호로 갈립니다.
// 접히는 칸: 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터미널 창 크기 조절
터미널 화면을 손잡이로 늘릴 때
모서리 손잡이를 끌면 창이 커지고 줄어듭니다. setPointerCapture 가 포인터를 손잡이에 묶어 두므로 커서가 창 밖으로 나가도 pointermove 가 끊기지 않고, 한계는 JS 가 아니라 CSS clamp() 한 곳에서 정합니다 — 두 곳에서 자르면 값이 어긋납니다. 실측에서 오른쪽 아래로 60×20 만큼 끌자 창은 300×104 에서 360×124 로 늘었고 창 안 표시 숫자도 같은 값을 읽었습니다.
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단계별 튜토리얼 창
세 단계가 차례로 과제를 내고, 맞는 명령을 치면 다음 단계로 넘어갑니다. 친 글자는 앞뒤 공백을 떼고 사이 공백을 한 칸으로 고른 뒤 비교하므로 ls -a 처럼 띄어 써도 통과하고, 진행 막대는 scaleX 로 왼쪽 변에서 자랍니다. 맞았는지 틀렸는지는 색만이 아니라 글자로도 알리고 aria-live 로 한 번 읽힙니다.
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 을 만들었습니다. 화면 글자 쉰두 곳의 실측 대비표는 run/294/_probe/_대비실측.json 에, 고치기 전 값과 재는 방법은 run/294/_수리전실측.json 에 남겨 두었습니다.
두 번째는 대표 이미지가 언제나 가장 크게 변한 프레임에서 뽑힌다는 점입니다. 02 의 출력 줄을 실사용처럼 opacity 로 떠오르게 했더니 찍은 24장 중 셋째 줄만 반투명한 장면이 가장 크게 변한 프레임이 되어, 대표 이미지가 늘 「반쯤 그려진 터미널」로 나왔습니다. 줄 등장을 steps(1, end) 컷으로 바꾸니 같은 측정에서 완료 표시까지 찍힌 장이 대표로 잡혔습니다. 07 은 더 심했습니다 — 루프 안에서 묶음을 다시 접으니 접히는 쪽이 펼치는 쪽보다 변한 면적이 커서 빈 상자가 대표 이미지를 차지했고(누적면적 25.92%·움직인 프레임 8/23), 루프에서 접기를 아예 빼고 모자란 프레임은 더한 줄 왼쪽 막대와 칩 튕김으로 채워 18.23%·13/23 이 됐습니다. 실제 클릭은 여전히 transition 으로 부드럽게 접힙니다.
세 번째는 미리보기가 CSS 만으로 도는 탓에 숫자가 글자로 남아 있으면 어긋난다는 것입니다. 09 의 단계 배지를 <span>1</span> 로 써 두었더니 과제 글은 2단계로 넘어갔는데 배지는 1 에 멈춰 있었습니다. 배지 번호를 @property 로 등록한 --step 과 counter() 로 바꿔 과제와 같은 퍼센트에 함께 넘어가게 했고, 08 의 창 크기 표시도 같은 방식으로 --wn·--hn 을 읽게 해 미리보기와 실제 드래그가 한 값을 씁니다. 여기까지 손본 결과가 그대로 담긴 아홉 벌 원본을 zip 으로 묶어 두었고, 그 압축을 푸는 비밀번호는 <span class="pw-inline" id="pw">n85asnxb</span> 입니다.
변형 3개
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| 스무 칸 막대 | 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) |
창이 더 깊어져 출력 줄을 여덟 줄까지 담습니다 |
AI 프롬프트
바이브코딩으로 터미널 조각을 새로 만들 때는 보일 것·움직임·금지 셋을 한 번에 못 박아야 고쳐 쓰는 횟수가 줄어듭니다. 아래는 05 문자 진행 막대를 만들 때 쓴 조각입니다.
문자로 그린 CLI 진행 막대를 만든다.
보일 것: 어두운 카드 안에 작업 세 줄, 각 줄은 이름 + 대괄호 안 채움문자 16칸 + 퍼센트 + 남은 시간.
움직임: 채운 쪽만 clip-path inset 으로 자르고 steps(칸수, end) 로 끊어 한 걸음이 한 글자가 되게.
퍼센트 숫자는 @property 로 등록한 정수 커스텀 속성 + counter() 로 찍어 막대와 같은 걸음으로.
제약: 숫자 칸은 font-variant-numeric: tabular-nums 로 폭 고정, 주기는 2초의 약수.
금지: 막대를 div 너비로 그리기, 퍼센트를 JS 로 매 프레임 다시 쓰기.
steps(칸수, end) 자리를 linear 로 바꾸면 막대가 글자 사이에서 반 칸씩 잘려 문자 막대처럼 보이지 않으니, 결과가 미끄러지듯 움직일 때는 이 한 낱말부터 봅니다.
접근성
화면 움직임을 줄이는 prefers-reduced-motion: reduce 설정에서는 아홉 조각의 애니메이션을 모두 끄고 무엇이 켜져 있는지 읽히는 한 장을 남깁니다. 01 은 npm 탭이 골라진 상태로, 02 는 네 줄이 다 찍히고 완료 표시가 켜진 화면으로 멈춥니다. 03 은 기본 프롬프트만 세우고 04 는 로그가 흐르지 않는 화면으로, 05 는 세 막대가 목표값에 선 채로, 06 은 후보가 펼쳐진 상태로 섭니다. 07 은 묶음을 펼친 채 두고 08 은 처음 크기의 창으로, 09 는 1단계 과제로 멈춥니다. 상태를 말로도 전하려고 01 상태 줄과 09 판정 칸은 aria-live, 02 출력 칸과 04 로그 칸은 role="log", 03·08 의 고른 상태는 aria-pressed·aria-selected, 06 입력칸은 aria-activedescendant, 07 머리글은 aria-expanded, 05·09 의 진행은 role="progressbar" 와 aria-valuenow 로 적었습니다. 04 의 등급은 색만으로 나누지 않고 줄 맨 앞 글자를 함께 두었고, 07 의 더한 줄·뺀 줄도 배경 색조와 +·- 기호를 나란히 씁니다. 손 대신 키보드만 쓰는 사람을 위해 08 손잡이는 초점을 받을 수 있어 화살표로도 창을 넓힙니다 — 실측에서 오른쪽 화살표 한 번에 폭이 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 로 그냥 뜁니다. @property 로 syntax: "<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가지의 접기·묶기 방식이 참고가 됩니다.