터미널 UI 9종 — 설치 명령 상자부터 튜토리얼 창까지
터미널 UI는 웹 화면 안에 검은 명령창처럼 보이도록 짜 넣는 조각입니다. 여기 아홉 가지는 설치 명령 복사 상자부터 로그 창·문자 진행 막대·크기를 바꾸는 창·튜토리얼 창까지, 붙여 넣으면 바로 도는 형태로 만들어 두었습니다.
01 terminal-install-copy-tabs
02 terminal-command-run-output
03 terminal-prompt-theme-switcher
04 terminal-log-follow-tail
05 terminal-ascii-progress
06 terminal-tab-completion
07 terminal-git-diff-output
08 terminal-window-chrome-resize
09 terminal-tutorial-steps
자동 재생 · 눌러서 해당 항목으로 · 전부 한 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 로 진짜 클립보드에 넣습니다.

<main class="stage">
<div class="ic is-demo" id="ic" data-mgr="npm">
<div class="ic__tabs" role="tablist" aria-label="패키지 관리자">
<button class="ic__tab" type="button" role="tab" id="tabNpm" aria-selected="true" data-mgr="npm">npm</button>
<button class="ic__tab" type="button" role="tab" id="tabPnpm" aria-selected="false" data-mgr="pnpm">pnpm</button>
<button class="ic__tab" type="button" role="tab" id="tabYarn" aria-selected="false" data-mgr="yarn">yarn</button>
</div>
<div class="ic__row">
<span class="ic__sign" aria-hidden="true">$</span>
<span class="ic__slot">
<code class="ic__cmd" data-mgr="npm">npm i <b>motionkit</b></code>
<code class="ic__cmd" data-mgr="pnpm">pnpm add <b>motionkit</b></code>
<code class="ic__cmd" data-mgr="yarn">yarn add <b>motionkit</b></code>
</span>
<button class="ic__copy" type="button" id="copyBtn">
<span class="ic__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg></span>
<span class="ic__word">명령 복사</span>
</button>
</div>
<p class="ic__note" id="note" aria-live="polite">클립보드에 복사했습니다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 설치 명령 복사 상자 — 같은 패키지를 npm·pnpm·yarn 문법으로 갈아 끼우고 그 줄을 클립보드에 넣는다.
// 명령 세 줄은 같은 격자 칸(grid-area: 1 / 1)에 겹쳐 두어 문법이 바뀌어도 줄 높이가 흔들리지 않는다.
// 격자 미리보기(.is-demo)는 npm → pnpm → yarn 을 steps(1, end) 로 컷 전환하고,
// 그 사이 복사 버튼이 한 번 채워졌다가 90% 안에 되돌아오는 2초 루프.
// 고정폭 서체는 font 단축이 아니라 font-family 낱개로 준다 (BD20).
$duration: $dur-quick;
$easing: $ease-spring;
$color: $chart-3;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(255, 255, 255, .16);
$dim: rgba(255, 255, 255, .58);
@include stage($stage-ink);
.ic {
display: flex; flex-direction: column; gap: $sp-2;
box-sizing: border-box; width: min(100%, 400px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: rgba(255, 255, 255, .05);
font-family: $mono;
}
// ── 관리자 탭: 인디케이터를 밀지 않고 탭마다 자기 밑줄을 켠다 (BD19 회피)
.ic__tabs { display: grid; grid-template-columns: repeat(3, 1fr); gap: $sp-1; }
.ic__tab {
box-sizing: border-box; width: 100%; padding: $sp-1 0;
border: 0; border-bottom: 2px solid transparent; border-radius: 0;
background: none; color: $dim; cursor: pointer;
font-family: inherit; font-weight: 700; font-size: clamp(11px, 3vw, 12px); line-height: 1.4;
transition: color $duration $easing, border-color $duration $easing;
}
.ic__tab[aria-selected="true"] { color: $subject-cream; border-bottom-color: $color; }
.ic__tab:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
// ── 명령 줄
.ic__row { display: flex; align-items: center; gap: $sp-2; min-width: 0; }
.ic__sign { flex: 0 0 auto; color: $color; font-weight: 700; font-size: clamp(12px, 3.2vw, 13px); line-height: 1.4; }
.ic__slot { display: grid; flex: 1 1 auto; min-width: 0; }
.ic__slot > * { grid-area: 1 / 1; }
.ic__cmd {
color: $subject-cream; font-family: inherit; font-weight: 500;
font-size: clamp(11px, 3vw, 13px); line-height: 1.4;
white-space: nowrap; overflow: hidden; visibility: hidden;
}
.ic__cmd b { color: $stage-yellow; font-weight: 700; }
.ic[data-mgr="npm"] .ic__cmd[data-mgr="npm"],
.ic[data-mgr="pnpm"] .ic__cmd[data-mgr="pnpm"],
.ic[data-mgr="yarn"] .ic__cmd[data-mgr="yarn"] { visibility: visible; }
// ── 복사 버튼: 채움은 배경 요소를 scaleX 로 늘려 왼쪽 변에서 자란다 (blog-demo 규칙 8 의 붙는 지점)
.ic__copy {
position: relative; overflow: hidden; flex: 0 0 auto;
display: inline-flex; align-items: center; gap: $sp-1;
box-sizing: border-box; min-height: 28px; padding: 0 $sp-2;
border: 1px solid $line; border-radius: $r-control;
background: rgba(255, 255, 255, .06); color: $subject-cream; cursor: pointer;
font-family: inherit; font-weight: 700; font-size: clamp(10px, 2.8vw, 11px); line-height: 1;
}
.ic__copy::before {
content: ""; position: absolute; inset: 0; background: $color;
transform: scaleX(0); transform-origin: left center;
transition: transform $duration $easing;
}
.ic.is-done .ic__copy::before { transform: scaleX(1); }
.ic.is-done .ic__copy { color: $stage-ink; }
.ic__copy:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.ic__ico, .ic__word { position: relative; }
.ic__ico { display: grid; place-items: center; font-size: 14px; }
// ── 상태 줄: 복사 뒤에만 보인다. 자리는 늘 차지해 줄 수가 흔들리지 않는다.
// aria-live="polite" 를 단 칸이라 글자가 다시 들어오는 순간 한 번 읽힌다
.ic__note {
margin: 0; min-height: 14px; color: $color; visibility: hidden;
font-family: inherit; font-weight: 500; font-size: clamp(10px, 2.6vw, 11px); line-height: 1.3;
transform: translateY(4px); transition: transform $duration $easing;
}
.ic.is-done .ic__note { visibility: visible; transform: translateY(0); }
// ── 격자 미리보기 루프 (2초)
// 0~33% npm · 34~66% pnpm · 67~100% yarn 을 컷으로 갈고, 8~30% 에 복사 채움이 지나간다.
// 되돌림은 0% 경계에서만 일어나 복귀 프레임이 poster 로 뽑히지 않는다 (blog-demo 「복귀 90%」)
.ic.is-demo .ic__cmd,
.ic.is-demo .ic__tab {
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.ic.is-demo .ic__cmd[data-mgr="npm"] { animation-name: icShow1; }
.ic.is-demo .ic__cmd[data-mgr="pnpm"] { animation-name: icShow2; }
.ic.is-demo .ic__cmd[data-mgr="yarn"] { animation-name: icShow3; }
@keyframes icShow1 {
0%, 33% { visibility: visible; }
34%, 100% { visibility: hidden; }
}
@keyframes icShow2 {
0%, 33% { visibility: hidden; }
34%, 66% { visibility: visible; }
67%, 100% { visibility: hidden; }
}
@keyframes icShow3 {
0%, 66% { visibility: hidden; }
67%, 100% { visibility: visible; }
}
.ic.is-demo .ic__tab:nth-child(1) { animation-name: icTab1; }
.ic.is-demo .ic__tab:nth-child(2) { animation-name: icTab2; }
.ic.is-demo .ic__tab:nth-child(3) { animation-name: icTab3; }
@keyframes icTab1 {
0%, 33% { color: $subject-cream; border-bottom-color: $color; }
34%, 100% { color: $dim; border-bottom-color: transparent; }
}
@keyframes icTab2 {
0%, 33% { color: $dim; border-bottom-color: transparent; }
34%, 66% { color: $subject-cream; border-bottom-color: $color; }
67%, 100% { color: $dim; border-bottom-color: transparent; }
}
@keyframes icTab3 {
0%, 66% { color: $dim; border-bottom-color: transparent; }
67%, 100% { color: $subject-cream; border-bottom-color: $color; }
}
// 채움은 연속 보간이라 steps 와 섞지 않는다 (266-09 실측: steps 짝은 clip-path·transform 을 컷으로 죽인다)
.ic.is-demo .ic__copy::before {
animation-name: icFill; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes icFill {
0%, 8% { transform: scaleX(0); }
20%, 74% { transform: scaleX(1); }
86%, 100% { transform: scaleX(0); }
}
.ic.is-demo .ic__copy {
animation-name: icBtn; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes icBtn {
0%, 19% { color: $subject-cream; }
20%, 79% { color: $stage-ink; }
80%, 100% { color: $subject-cream; }
}
.ic.is-demo .ic__note {
animation-name: icNote; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes icNote {
0%, 19% { visibility: hidden; transform: translateY(4px); }
20% { visibility: visible; transform: translateY(4px); }
30%, 79% { visibility: visible; transform: translateY(0); }
80%, 100% { visibility: hidden; transform: translateY(0); }
}
@media (max-width: 340px) {
.ic { padding: $sp-2; gap: $sp-1; }
.ic__copy { padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.ic.is-demo .ic__cmd,
.ic.is-demo .ic__tab,
.ic.is-demo .ic__copy,
.ic.is-demo .ic__copy::before,
.ic.is-demo .ic__note { animation: none !important; }
.ic.is-demo .ic__cmd[data-mgr="npm"] { visibility: visible !important; }
.ic.is-demo .ic__cmd[data-mgr="pnpm"],
.ic.is-demo .ic__cmd[data-mgr="yarn"] { visibility: hidden !important; }
.ic__tab, .ic__copy::before, .ic__note { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.ic {
display: flex;
flex-direction: column;
gap: 8px;
box-sizing: border-box;
width: min(100%, 400px);
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
}
.ic__tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
}
.ic__tab {
box-sizing: border-box;
width: 100%;
padding: 4px 0;
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
background: none;
color: rgba(255, 255, 255, 0.58);
cursor: pointer;
font-family: inherit;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.4;
transition: color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ic__tab[aria-selected=true] {
color: #fff7e6;
border-bottom-color: #2b9d74;
}
.ic__tab:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ic__row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.ic__sign {
flex: 0 0 auto;
color: #2b9d74;
font-weight: 700;
font-size: clamp(12px, 3.2vw, 13px);
line-height: 1.4;
}
.ic__slot {
display: grid;
flex: 1 1 auto;
min-width: 0;
}
.ic__slot > * {
grid-area: 1/1;
}
.ic__cmd {
color: #fff7e6;
font-family: inherit;
font-weight: 500;
font-size: clamp(11px, 3vw, 13px);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
visibility: hidden;
}
.ic__cmd b {
color: #ffd23f;
font-weight: 700;
}
.ic[data-mgr=npm] .ic__cmd[data-mgr=npm],
.ic[data-mgr=pnpm] .ic__cmd[data-mgr=pnpm],
.ic[data-mgr=yarn] .ic__cmd[data-mgr=yarn] {
visibility: visible;
}
.ic__copy {
position: relative;
overflow: hidden;
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 4px;
box-sizing: border-box;
min-height: 28px;
padding: 0 8px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 12px;
background: rgba(255, 255, 255, 0.06);
color: #fff7e6;
cursor: pointer;
font-family: inherit;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1;
}
.ic__copy::before {
content: "";
position: absolute;
inset: 0;
background: #2b9d74;
transform: scaleX(0);
transform-origin: left center;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ic.is-done .ic__copy::before {
transform: scaleX(1);
}
.ic.is-done .ic__copy {
color: #17141a;
}
.ic__copy:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.ic__ico, .ic__word {
position: relative;
}
.ic__ico {
display: grid;
place-items: center;
font-size: 14px;
}
.ic__note {
margin: 0;
min-height: 14px;
color: #2b9d74;
visibility: hidden;
font-family: inherit;
font-weight: 500;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1.3;
transform: translateY(4px);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ic.is-done .ic__note {
visibility: visible;
transform: translateY(0);
}
.ic.is-demo .ic__cmd,
.ic.is-demo .ic__tab {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ic.is-demo .ic__cmd[data-mgr=npm] {
animation-name: icShow1;
}
.ic.is-demo .ic__cmd[data-mgr=pnpm] {
animation-name: icShow2;
}
.ic.is-demo .ic__cmd[data-mgr=yarn] {
animation-name: icShow3;
}
@keyframes icShow1 {
0%, 33% {
visibility: visible;
}
34%, 100% {
visibility: hidden;
}
}
@keyframes icShow2 {
0%, 33% {
visibility: hidden;
}
34%, 66% {
visibility: visible;
}
67%, 100% {
visibility: hidden;
}
}
@keyframes icShow3 {
0%, 66% {
visibility: hidden;
}
67%, 100% {
visibility: visible;
}
}
.ic.is-demo .ic__tab:nth-child(1) {
animation-name: icTab1;
}
.ic.is-demo .ic__tab:nth-child(2) {
animation-name: icTab2;
}
.ic.is-demo .ic__tab:nth-child(3) {
animation-name: icTab3;
}
@keyframes icTab1 {
0%, 33% {
color: #fff7e6;
border-bottom-color: #2b9d74;
}
34%, 100% {
color: rgba(255, 255, 255, 0.58);
border-bottom-color: transparent;
}
}
@keyframes icTab2 {
0%, 33% {
color: rgba(255, 255, 255, 0.58);
border-bottom-color: transparent;
}
34%, 66% {
color: #fff7e6;
border-bottom-color: #2b9d74;
}
67%, 100% {
color: rgba(255, 255, 255, 0.58);
border-bottom-color: transparent;
}
}
@keyframes icTab3 {
0%, 66% {
color: rgba(255, 255, 255, 0.58);
border-bottom-color: transparent;
}
67%, 100% {
color: #fff7e6;
border-bottom-color: #2b9d74;
}
}
.ic.is-demo .ic__copy::before {
animation-name: icFill;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes icFill {
0%, 8% {
transform: scaleX(0);
}
20%, 74% {
transform: scaleX(1);
}
86%, 100% {
transform: scaleX(0);
}
}
.ic.is-demo .ic__copy {
animation-name: icBtn;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes icBtn {
0%, 19% {
color: #fff7e6;
}
20%, 79% {
color: #17141a;
}
80%, 100% {
color: #fff7e6;
}
}
.ic.is-demo .ic__note {
animation-name: icNote;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes icNote {
0%, 19% {
visibility: hidden;
transform: translateY(4px);
}
20% {
visibility: visible;
transform: translateY(4px);
}
30%, 79% {
visibility: visible;
transform: translateY(0);
}
80%, 100% {
visibility: hidden;
transform: translateY(0);
}
}
@media (max-width: 340px) {
.ic {
padding: 8px;
gap: 4px;
}
.ic__copy {
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.ic.is-demo .ic__cmd,
.ic.is-demo .ic__tab,
.ic.is-demo .ic__copy,
.ic.is-demo .ic__copy::before,
.ic.is-demo .ic__note {
animation: none !important;
}
.ic.is-demo .ic__cmd[data-mgr=npm] {
visibility: visible !important;
}
.ic.is-demo .ic__cmd[data-mgr=pnpm],
.ic.is-demo .ic__cmd[data-mgr=yarn] {
visibility: hidden !important;
}
.ic__tab, .ic__copy::before, .ic__note {
transition: none !important;
}
}var ic = document.getElementById('ic');
var copyBtn = document.getElementById('copyBtn');
var word = copyBtn.querySelector('.ic__word');
var note = document.getElementById('note');
var tabs = Array.prototype.slice.call(ic.querySelectorAll('.ic__tab'));
var doneTimer = 0;
function pick(mgr) {
ic.classList.remove('is-demo');
ic.setAttribute('data-mgr', mgr);
tabs.forEach(function (t) { t.setAttribute('aria-selected', String(t.dataset.mgr === mgr)); });
}
function currentCommand() {
var mgr = ic.getAttribute('data-mgr') || 'npm';
var line = ic.querySelector('.ic__cmd[data-mgr="' + mgr + '"]');
return line ? line.textContent : '';
}
function showDone() {
ic.classList.add('is-done');
word.textContent = '복사됨';
clearTimeout(doneTimer);
// 알림을 지웠다가 다시 넣어야 같은 문장이라도 aria-live 가 한 번 더 읽는다
note.textContent = '';
doneTimer = setTimeout(function () {
note.textContent = '클립보드에 복사했습니다';
doneTimer = setTimeout(function () { ic.classList.remove('is-done'); word.textContent = '명령 복사'; }, 1400);
}, 60);
}
tabs.forEach(function (t) { t.addEventListener('click', function () { pick(t.dataset.mgr); }); });
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();
}
});// InstallCopyTabs — vanilla 판(run/294/demo/01-terminal-install-copy-tabs/index.html) 의 React 포트.
// 같은 패키지를 npm·pnpm·yarn 문법으로 갈아 끼우고 그 줄을 클립보드에 넣는다.
// 탭이나 복사 버튼을 한 번 누르면 격자 미리보기 루프(demo)가 꺼지고 사람이 고른 상태만 남는다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./InstallCopyTabs.module.scss";
export type InstallCopyTabsProps = {
/** 격자 미리보기용 2초 자동 루프. 탭·복사 버튼을 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** React 이벤트 제네릭이 ambient 에 없어 쓰는 필드만 지역 타입으로 선언한다 */
type Box<T> = { current: T };
const MANAGERS = ["npm", "pnpm", "yarn"];
/** 관리자마다 설치 명령의 앞부분만 다르다 — 뒤에 붙는 패키지 이름은 같다 */
const VERBS: { [k: string]: string } = { npm: "npm i", pnpm: "pnpm add", yarn: "yarn add" };
const PACKAGE_NAME = "motionkit";
const NOTE_TEXT = "클립보드에 복사했습니다";
export default function InstallCopyTabs({
demo = true,
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2b9d74",
}: InstallCopyTabsProps) {
const [live, setLive] = useState(demo);
const [mgr, setMgr] = useState("npm");
const [done, setDone] = useState(false);
const [note, setNote] = useState(NOTE_TEXT);
const stepTimer = useRef<number>(0) as Box<number>;
const backTimer = useRef<number>(0) as Box<number>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
return () => {
window.clearTimeout(stepTimer.current);
window.clearTimeout(backTimer.current);
};
}, []);
function pick(next: string) {
setLive(false);
setMgr(next);
}
function showDone() {
setDone(true);
window.clearTimeout(stepTimer.current);
window.clearTimeout(backTimer.current);
// 알림을 지웠다가 다시 넣어야 같은 문장이라도 aria-live 가 한 번 더 읽는다
setNote("");
stepTimer.current = window.setTimeout(() => {
setNote(NOTE_TEXT);
backTimer.current = window.setTimeout(() => { setDone(false); }, 1400);
}, 60);
}
function copy() {
setLive(false);
const text = `${VERBS[mgr]} ${PACKAGE_NAME}`;
const clip = typeof navigator !== "undefined" ? navigator.clipboard : undefined;
if (clip) { clip.writeText(text).then(showDone, showDone); }
else { showDone(); }
}
const cls = [styles.root, live ? styles.isDemo : "", done ? styles.isDone : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage} style={vars}>
<div className={cls} data-mgr={mgr}>
<div className={styles.tabs} role="tablist" aria-label="패키지 관리자">
{MANAGERS.map((name) => (
<button
className={styles.tab}
type="button"
role="tab"
key={name}
data-mgr={name}
aria-selected={mgr === name}
onClick={() => pick(name)}
>
{name}
</button>
))}
</div>
<div className={styles.row}>
<span className={styles.sign} aria-hidden="true">$</span>
<span className={styles.slot}>
{MANAGERS.map((name) => (
<code className={styles.cmd} key={name} data-mgr={name}>
{VERBS[name]} <b>{PACKAGE_NAME}</b>
</code>
))}
</span>
<button className={styles.copy} type="button" onClick={copy}>
<span className={styles.ico} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2" /><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z" /></g></svg>
</span>
<span className={styles.word}>{done ? "복사됨" : "명령 복사"}</span>
</button>
</div>
<p className={styles.note} role="status">{note}</p>
</div>
</main>
);
}01 · 설치 명령 복사 상자 — 측정 누적면적 3.313% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
미리보기가 기록한 움직임
미리보기 루프는 탭 전환 컷과 버튼 채움을 섞어 누적면적 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 를 묶지 않으면 지연 구간에 줄이 애니메이션 시작 모습이 아니라 원래 모습으로 남아 한 번 번쩍입니다.

<main class="stage">
<div class="rn is-demo" id="rn">
<p class="rn__bar">
<span class="rn__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 56v144a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M128 128a8 8 0 0 1-3 6.25l-40 32a8 8 0 1 1-10-12.5L107.19 128L75 102.25a8 8 0 1 1 10-12.5l40 32a8 8 0 0 1 3 6.25m48 24h-40a8 8 0 0 0 0 16h40a8 8 0 0 0 0-16m56-96v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16m-16 144V56H40v144z"/></g></svg></span>
<span class="rn__name">빌드 터미널</span>
</p>
<div class="rn__out" id="out" role="log" aria-live="polite">
<p class="rn__line rn__line--echo" style="--i: 0">$ build</p>
<p class="rn__line" style="--i: 1">resolving 12 modules</p>
<p class="rn__line" style="--i: 2">bundling src/index.ts</p>
<p class="rn__line rn__line--work is-done" style="--i: 3" id="work">
<span class="rn__mark" aria-hidden="true"><span class="rn__spin"></span>
<span class="rn__tick"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span></span>
<span class="rn__work-text">writing dist</span>
</p>
<p class="rn__line rn__line--sum" style="--i: 4">done in 1.2s</p>
</div>
<form class="rn__form" id="form">
<span class="rn__sign" aria-hidden="true">$</span>
<input class="rn__input" id="input" type="text" autocomplete="off" placeholder="명령을 입력하고 Enter" aria-label="실행할 명령">
</form>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 명령 실행과 출력 줄 — 친 명령을 되울리고 출력 줄이 차례로 쌓인다.
// 시차는 animation-delay 로 주고 animation-fill-mode: backwards 를 함께 묶는다.
// backwards 가 없으면 지연 구간에 작성 상태(완성된 줄)가 0프레임에 한 번 번쩍인다 (252-08 실측).
// 스피너와 완료 표시는 같은 격자 칸에 겹쳐 두어 바뀌어도 줄 폭이 흔들리지 않는다.
$duration: $dur-enter;
$easing: $ease-spring;
$color: $chart-3;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(255, 255, 255, .16);
$dim: rgba(255, 255, 255, .62);
@include stage($stage-ink);
.rn {
display: flex; flex-direction: column;
box-sizing: border-box; width: min(100%, 400px);
border: 1px solid $line; border-radius: $r-card; overflow: hidden;
background: rgba(255, 255, 255, .05);
font-family: $mono;
}
.rn__bar {
display: flex; align-items: center; gap: $sp-2;
margin: 0; padding: $sp-2 $sp-3;
border-bottom: 1px solid $line; color: $dim;
font-weight: 700; font-size: clamp(10px, 2.8vw, 11px); line-height: 1;
}
.rn__ico { display: grid; place-items: center; font-size: 14px; color: $color; }
.rn__out {
display: flex; flex-direction: column; gap: $sp-1;
padding: $sp-2 $sp-3; min-height: 96px;
}
.rn__line {
display: flex; align-items: center; gap: $sp-2;
margin: 0; color: $subject-cream;
font-weight: 500; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.4;
white-space: nowrap; overflow: hidden;
}
.rn__line--echo { color: $stage-yellow; font-weight: 700; }
.rn__line--bad { color: $error-soft; }
.rn__line--sum { color: $color; font-weight: 700; }
// 스피너와 완료 표시는 같은 격자 칸에 겹쳐 둔다 — 바뀌어도 줄 폭이 그대로다
.rn__mark { display: grid; place-items: center; flex: 0 0 auto; width: 14px; height: 14px; }
.rn__mark > * { grid-area: 1 / 1; }
// 스피너는 테두리 한 변만 색을 주고 제 중심에서 돈다 (blog-demo 규칙 8)
.rn__spin {
width: 10px; height: 10px; border-radius: 50%;
border: 2px solid rgba(255, 255, 255, .22); border-top-color: $color;
transform-origin: 50% 50%;
animation-name: rnSpin; animation-duration: 500ms; animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes rnSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.rn__tick { display: grid; place-items: center; font-size: 13px; color: $color; visibility: hidden; }
.rn__line--work.is-done .rn__spin { visibility: hidden; }
.rn__line--work.is-done .rn__tick { visibility: visible; }
.rn__line--work.is-done .rn__work-text { color: $color; }
.rn__form { display: flex; align-items: center; gap: $sp-2; padding: $sp-2 $sp-3; border-top: 1px solid $line; }
.rn__sign { flex: 0 0 auto; color: $color; font-weight: 700; font-size: clamp(11px, 3vw, 12px); line-height: 1.4; }
.rn__input {
flex: 1 1 auto; min-width: 0; box-sizing: border-box;
border: 0; background: none; color: $subject-cream;
font-family: inherit; font-weight: 500; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.4;
}
.rn__input::placeholder { color: rgba(255, 255, 255, .46); }
.rn__input:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; border-radius: $r-xs; }
// ── 격자 미리보기 루프 (2초)
// 네 줄이 --i 만큼 늦게 떠오르고, 72% 에 스피너가 완료 표시로 컷 전환된다.
// 줄은 지우지 않는다 — 퇴장을 넣으면 빈 화면이 최대 변화 프레임이 되어 poster 로 뽑힌다 (255 실측)
.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; }
}
// 요약 줄만 시차에서 빼고 작업이 끝나는 72% 에 컷으로 찍는다 — 실제 실행에서도 스피너가 멎은 뒤에 나온다.
// 단축이 아닌 낱개 속성이라 animation-delay 를 0s 로 되돌려야 앞 규칙의 calc 가 남지 않는다
.rn.is-demo .rn__line--sum {
animation-name: rnSum; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards; animation-delay: 0s;
}
@keyframes rnSum {
0%, 71% { opacity: 0; }
72%, 100% { opacity: 1; }
}
// 돌기(rnSpin)와 멈추기(rnSpinOff)를 한 요소에 함께 건다 — 이름을 하나만 적으면 앞 선언이 통째로 밀린다
.rn.is-demo .rn__spin {
animation-name: rnSpin, rnSpinOff;
animation-duration: 500ms, $dur-loop;
animation-timing-function: linear, steps(1, end);
animation-iteration-count: infinite, infinite;
animation-fill-mode: none, backwards;
}
@keyframes rnSpinOff {
0%, 71% { visibility: visible; }
72%, 100% { visibility: hidden; }
}
.rn.is-demo .rn__tick {
animation-name: rnTickOn; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes rnTickOn {
0%, 71% { visibility: hidden; }
72%, 100% { visibility: visible; }
}
.rn.is-demo .rn__work-text {
animation-name: rnWorkText; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes rnWorkText {
0%, 71% { color: $subject-cream; }
72%, 100% { color: $color; }
}
@media (max-width: 340px) {
.rn__bar, .rn__out, .rn__form { padding-left: $sp-2; padding-right: $sp-2; }
.rn__out { min-height: 76px; gap: 0; }
}
@media (prefers-reduced-motion: reduce) {
.rn__spin { animation: none !important; }
.rn.is-demo .rn__line,
.rn.is-demo .rn__line--sum,
.rn.is-demo .rn__spin,
.rn.is-demo .rn__tick,
.rn.is-demo .rn__work-text { animation: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rn {
display: flex;
flex-direction: column;
box-sizing: border-box;
width: min(100%, 400px);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
}
.rn__bar {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
padding: 8px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.16);
color: rgba(255, 255, 255, 0.62);
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1;
}
.rn__ico {
display: grid;
place-items: center;
font-size: 14px;
color: #2b9d74;
}
.rn__out {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px 12px;
min-height: 96px;
}
.rn__line {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
color: #fff7e6;
font-weight: 500;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
}
.rn__line--echo {
color: #ffd23f;
font-weight: 700;
}
.rn__line--bad {
color: #ffe0d8;
}
.rn__line--sum {
color: #2b9d74;
font-weight: 700;
}
.rn__mark {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 14px;
height: 14px;
}
.rn__mark > * {
grid-area: 1/1;
}
.rn__spin {
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.22);
border-top-color: #2b9d74;
transform-origin: 50% 50%;
animation-name: rnSpin;
animation-duration: 500ms;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes rnSpin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.rn__tick {
display: grid;
place-items: center;
font-size: 13px;
color: #2b9d74;
visibility: hidden;
}
.rn__line--work.is-done .rn__spin {
visibility: hidden;
}
.rn__line--work.is-done .rn__tick {
visibility: visible;
}
.rn__line--work.is-done .rn__work-text {
color: #2b9d74;
}
.rn__form {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-top: 1px solid rgba(255, 255, 255, 0.16);
}
.rn__sign {
flex: 0 0 auto;
color: #2b9d74;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.4;
}
.rn__input {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
border: 0;
background: none;
color: #fff7e6;
font-family: inherit;
font-weight: 500;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.4;
}
.rn__input::placeholder {
color: rgba(255, 255, 255, 0.46);
}
.rn__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 4px;
}
.rn.is-demo .rn__line {
animation-name: rnRise;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 80ms);
}
@keyframes rnRise {
0% {
opacity: 0;
}
6%, 100% {
opacity: 1;
}
}
.rn.is-demo .rn__line--sum {
animation-name: rnSum;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: 0s;
}
@keyframes rnSum {
0%, 71% {
opacity: 0;
}
72%, 100% {
opacity: 1;
}
}
.rn.is-demo .rn__spin {
animation-name: rnSpin, rnSpinOff;
animation-duration: 500ms, 2s;
animation-timing-function: linear, steps(1, end);
animation-iteration-count: infinite, infinite;
animation-fill-mode: none, backwards;
}
@keyframes rnSpinOff {
0%, 71% {
visibility: visible;
}
72%, 100% {
visibility: hidden;
}
}
.rn.is-demo .rn__tick {
animation-name: rnTickOn;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes rnTickOn {
0%, 71% {
visibility: hidden;
}
72%, 100% {
visibility: visible;
}
}
.rn.is-demo .rn__work-text {
animation-name: rnWorkText;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes rnWorkText {
0%, 71% {
color: #fff7e6;
}
72%, 100% {
color: #2b9d74;
}
}
@media (max-width: 340px) {
.rn__bar, .rn__out, .rn__form {
padding-left: 8px;
padding-right: 8px;
}
.rn__out {
min-height: 76px;
gap: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.rn__spin {
animation: none !important;
}
.rn.is-demo .rn__line,
.rn.is-demo .rn__line--sum,
.rn.is-demo .rn__spin,
.rn.is-demo .rn__tick,
.rn.is-demo .rn__work-text {
animation: none !important;
}
}var rn = document.getElementById('rn');
var out = document.getElementById('out');
var form = document.getElementById('form');
var input = document.getElementById('input');
var SCRIPTS = {
build: ['resolving 12 modules', 'bundling src/index.ts', 'WORK:writing dist', 'SUM:done in 1.2s'],
ls: ['dist src package.json README.md'],
help: ['build bundle the project', 'ls list files', 'clear wipe the screen'],
clear: []
};
// 작업 줄은 스피너·완료 아이콘을 품고 있어 새로 짓지 않고 첫 줄을 본으로 복제한다
var WORK_TPL = document.getElementById('work').cloneNode(true);
function line(text, cls, index) {
var p = document.createElement('p');
p.className = 'rn__line' + (cls ? ' rn__line--' + cls : '');
p.style.setProperty('--i', String(index));
p.textContent = text;
out.appendChild(p);
return p;
}
function workLine(text, index) {
var p = WORK_TPL.cloneNode(true);
p.removeAttribute('id');
p.classList.remove('is-done');
p.style.setProperty('--i', String(index));
p.querySelector('.rn__work-text').textContent = text;
out.appendChild(p);
// 스피너가 한 바퀴 돌 시간을 주고 완료 표시로 바꾼다 — 실제 작업이라면 여기가 fetch 의 then 자리다
setTimeout(function () { p.classList.add('is-done'); }, 900);
}
function run(cmd) {
out.textContent = '';
if (cmd === 'clear') { return; }
var steps = SCRIPTS[cmd];
line('$ ' + cmd, 'echo', 0);
if (!steps) { line('command not found: ' + cmd, 'bad', 1); return; }
steps.forEach(function (text, i) {
if (text.indexOf('WORK:') === 0) { workLine(text.slice(5), i + 1); }
// 요약 줄은 작업이 끝난 뒤에 찍힌다 — 스피너가 도는 동안 먼저 나오면 거짓말이 된다
else if (text.indexOf('SUM:') === 0) { setTimeout(function () { line(text.slice(4), 'sum', i + 1); }, 900); }
else { line(text, '', i + 1); }
});
}
form.addEventListener('submit', function (e) {
e.preventDefault();
rn.classList.remove('is-demo');
var cmd = input.value.trim();
if (cmd) { run(cmd); }
input.value = '';
});// CommandRunOutput — vanilla 판(run/294/demo/02-terminal-command-run-output/index.html) 의 React 포트.
// 친 명령을 되울리고 출력 줄이 --i 만큼 시차를 두고 쌓인다. 작업 줄은 스피너가 한 바퀴 돈 뒤
// 완료 표시로 바뀌고, 요약 줄은 그 뒤에야 찍힌다 — 실제 실행에서도 스피너가 멎은 다음이다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CommandRunOutput.module.scss";
export type CommandRunOutputProps = {
/** 격자 미리보기용 2초 자동 루프. 명령을 한 번 실행하면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type LineVars = CSSProperties & { "--i"?: string };
/** React 이벤트 제네릭이 ambient 에 없어 쓰는 필드만 지역 타입으로 선언한다 */
type FormEvt = { preventDefault(): void };
type InputEvt = { target: { value: string } };
type Box<T> = { current: T };
type Line = { id: number; text: string; kind: string; index: number; done: boolean };
const SCRIPTS: { [k: string]: string[] } = {
build: ["resolving 12 modules", "bundling src/index.ts", "WORK:writing dist", "SUM:done in 1.2s"],
ls: ["dist src package.json README.md"],
help: ["build bundle the project", "ls list files", "clear wipe the screen"],
clear: [],
};
/** 작업이 끝나기까지 주는 시간 — 실제 작업이라면 여기가 fetch 의 then 자리다 */
const WORK_MS = 900;
const INITIAL: Line[] = [
{ id: 1, text: "$ build", kind: "echo", index: 0, done: false },
{ id: 2, text: "resolving 12 modules", kind: "", index: 1, done: false },
{ id: 3, text: "bundling src/index.ts", kind: "", index: 2, done: false },
{ id: 4, text: "writing dist", kind: "work", index: 3, done: false },
{ id: 5, text: "done in 1.2s", kind: "sum", index: 4, done: false },
];
function kindClass(kind: string) {
if (kind === "echo") { return styles.lineEcho; }
if (kind === "bad") { return styles.lineBad; }
if (kind === "sum") { return styles.lineSum; }
if (kind === "work") { return styles.lineWork; }
return "";
}
export default function CommandRunOutput({
demo = true,
duration = 600,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2b9d74",
}: CommandRunOutputProps) {
const [live, setLive] = useState(demo);
const [lines, setLines] = useState<Line[]>(INITIAL);
const [value, setValue] = useState("");
const timers = useRef<number[]>([]) as Box<number[]>;
const seq = useRef<number>(INITIAL.length) as Box<number>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
return () => { clearTimers(); };
}, []);
function clearTimers() {
timers.current.forEach((id) => { window.clearTimeout(id); });
timers.current = [];
}
function nextId() {
seq.current += 1;
return seq.current;
}
function run(cmd: string) {
clearTimers();
if (cmd === "clear") { setLines([]); return; }
const steps = SCRIPTS[cmd];
const next: Line[] = [{ id: nextId(), text: `$ ${cmd}`, kind: "echo", index: 0, done: false }];
if (!steps) {
next.push({ id: nextId(), text: `command not found: ${cmd}`, kind: "bad", index: 1, done: false });
setLines(next);
return;
}
steps.forEach((text, i) => {
if (text.indexOf("WORK:") === 0) {
const id = nextId();
next.push({ id: id, text: text.slice(5), kind: "work", index: i + 1, done: false });
// 스피너가 한 바퀴 돌 시간을 주고 완료 표시로 바꾼다
timers.current.push(window.setTimeout(() => {
setLines((prev) => prev.map((l) => (l.id === id ? { ...l, done: true } : l)));
}, WORK_MS));
} else if (text.indexOf("SUM:") === 0) {
// 요약 줄은 작업이 끝난 뒤에 찍힌다 — 스피너가 도는 동안 먼저 나오면 거짓말이 된다
const sum: Line = { id: nextId(), text: text.slice(4), kind: "sum", index: i + 1, done: false };
timers.current.push(window.setTimeout(() => {
setLines((prev) => prev.concat([sum]));
}, WORK_MS));
} else {
next.push({ id: nextId(), text: text, kind: "", index: i + 1, done: false });
}
});
setLines(next);
}
function submit(e: FormEvt) {
e.preventDefault();
setLive(false);
const cmd = value.trim();
if (cmd) { run(cmd); }
setValue("");
}
const cls = [styles.root, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage} style={vars}>
<div className={cls}>
<p className={styles.bar}>
<span className={styles.ico} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 56v144a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2" /><path d="M128 128a8 8 0 0 1-3 6.25l-40 32a8 8 0 1 1-10-12.5L107.19 128L75 102.25a8 8 0 1 1 10-12.5l40 32a8 8 0 0 1 3 6.25m48 24h-40a8 8 0 0 0 0 16h40a8 8 0 0 0 0-16m56-96v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16m-16 144V56H40v144z" /></g></svg>
</span>
<span className={styles.name}>빌드 터미널</span>
</p>
<div className={styles.out} role="log" aria-live="polite">
{lines.map((l) => {
const lineVars: LineVars = { "--i": String(l.index) };
const lineCls = [styles.line, kindClass(l.kind), l.done ? styles.isDone : ""].filter(Boolean).join(" ");
if (l.kind === "work") {
return (
<p className={lineCls} key={l.id} style={lineVars}>
<span className={styles.mark} aria-hidden="true">
<span className={styles.spin} />
<span className={styles.tick}>
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2" /><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32" /></g></svg>
</span>
</span>
<span className={styles.workText}>{l.text}</span>
</p>
);
}
return <p className={lineCls} key={l.id} style={lineVars}>{l.text}</p>;
})}
</div>
<form className={styles.form} onSubmit={submit}>
<span className={styles.sign} aria-hidden="true">$</span>
<input
className={styles.input}
type="text"
autoComplete="off"
placeholder="명령을 입력하고 Enter"
aria-label="실행할 명령"
value={value}
onChange={(e: InputEvt) => setValue(e.target.value)}
/>
</form>
</div>
</main>
);
}02 · 명령 실행과 출력 줄 — 측정 누적면적 1.423% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
스피너가 끌어올린 프레임
스피너가 계속 도는 덕에 움직인 프레임이 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 로 물립니다.

<main class="stage">
<div class="pt is-demo" id="pt" data-shape="plain">
<div class="pt__pick" role="group" aria-label="프롬프트 모양">
<button class="pt__btn" type="button" data-shape="plain" aria-pressed="true">기본<span class="pt__bar" aria-hidden="true"></span></button>
<button class="pt__btn" type="button" data-shape="wave" aria-pressed="false">물결<span class="pt__bar" aria-hidden="true"></span></button>
<button class="pt__btn" type="button" data-shape="seg" aria-pressed="false">조각<span class="pt__bar" aria-hidden="true"></span></button>
</div>
<div class="pt__screen">
<div class="pt__view pt__view--plain">
<span class="pt__path">~/work/site</span>
<span class="pt__git">git:(main)</span>
<span class="pt__caret">❯</span>
</div>
<div class="pt__view pt__view--wave">
<span class="pt__user">you</span><span class="pt__at">@</span><span class="pt__host">mac</span>
<span class="pt__path">~/work/site</span>
<span class="pt__caret pt__caret--wave">❯❯</span>
</div>
<div class="pt__view pt__view--seg">
<span class="pt__seg pt__seg--a">~/work/site</span><span class="pt__seg pt__seg--b">main</span><span class="pt__seg pt__seg--c">node 22</span>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 프롬프트 테마 전환 — 같은 한 줄 프롬프트를 기본·물결·조각 세 모양으로 갈아 끼운다.
// 세 모양은 같은 격자 칸(grid-area: 1 / 1)에 겹쳐 두어 모양이 바뀌어도 화면 높이가 안 흔들린다.
// 조각 모양의 꺾인 이음매는 clip-path 로 잘라 만든다 — 삼각형을 따로 겹치지 않는다.
// 갈아 끼우기는 steps(1, end) 컷이고, 프레임을 채우는 작은 움직임은 버튼 밑줄의 scaleX 다
// (컷만 있으면 움직인 프레임이 두세 장에 그친다 — 252 실측).
$duration: $dur-quick;
$easing: $ease-spring;
$color: $chart-3;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(255, 255, 255, .16);
$dim: rgba(255, 255, 255, .6);
$notch: 8px;
@include stage($stage-ink);
.pt {
display: flex; flex-direction: column; gap: $sp-3;
box-sizing: border-box; width: min(100%, 408px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: rgba(255, 255, 255, .05); font-family: $mono;
}
.pt__pick { display: grid; grid-template-columns: repeat(3, 1fr); gap: $sp-2; }
.pt__btn {
position: relative; box-sizing: border-box; width: 100%; padding: $sp-1 0 $sp-2;
border: 0; border-radius: 0; background: none; color: $dim; cursor: pointer;
font-family: inherit; font-weight: 700; font-size: clamp(11px, 3vw, 12px); line-height: 1.4;
}
.pt__btn[aria-pressed="true"] { color: $subject-cream; }
.pt__btn:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
// 밑줄은 버튼 아래 변에 붙은 요소라 왼쪽 변을 원점으로 자란다 (blog-demo 규칙 8 의 예외 항목)
.pt__bar {
position: absolute; left: 0; right: 0; bottom: 0; height: 2px; border-radius: $r-pill;
background: $color; transform: scaleX(0); transform-origin: left center;
transition: transform $duration $easing;
}
.pt__btn[aria-pressed="true"] .pt__bar { transform: scaleX(1); }
.pt__screen { display: grid; min-height: 26px; align-items: center; }
.pt__screen > * { grid-area: 1 / 1; }
.pt__view {
display: flex; align-items: center; gap: $sp-2; min-width: 0;
font-weight: 600; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.4;
white-space: nowrap; visibility: hidden;
}
.pt[data-shape="plain"] .pt__view--plain,
.pt[data-shape="wave"] .pt__view--wave,
.pt[data-shape="seg"] .pt__view--seg { visibility: visible; }
// 경로는 하늘색이다 — 판 바탕 #232025 위에서 $subject-blue 는 3.57:1 로 본문 기준 4.5:1 에 못 미친다.
// $subject-sky 로 올려 8.74:1 (이 편 실측, run/294/_probe/contrast.py)
.pt__path { color: $subject-sky; }
.pt__git { color: $stage-yellow; }
.pt__caret { color: $color; font-weight: 800; }
.pt__user { color: $color; }
.pt__at { color: $dim; }
.pt__host { color: $subject-cream; }
.pt__caret--wave { color: $subject-pink; letter-spacing: -1px; }
.pt__view--wave { gap: 0; }
.pt__view--wave .pt__path { margin-left: $sp-2; }
.pt__view--wave .pt__caret--wave { margin-left: $sp-2; }
// ── 조각 모양: 오른쪽 끝을 화살표로 깎고 다음 조각이 그 홈에 물린다
.pt__view--seg { gap: 0; }
.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; }
// ── 격자 미리보기 루프 (2초): 기본 → 물결 → 조각 을 컷으로, 밑줄은 그때마다 scaleX 로 자란다
.pt.is-demo .pt__view,
.pt.is-demo .pt__btn {
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.pt.is-demo .pt__view--plain { animation-name: ptShow1; }
.pt.is-demo .pt__view--wave { animation-name: ptShow2; }
.pt.is-demo .pt__view--seg { animation-name: ptShow3; }
@keyframes ptShow1 {
0%, 33% { visibility: visible; }
34%, 100% { visibility: hidden; }
}
@keyframes ptShow2 {
0%, 33% { visibility: hidden; }
34%, 66% { visibility: visible; }
67%, 100% { visibility: hidden; }
}
@keyframes ptShow3 {
0%, 66% { visibility: hidden; }
67%, 100% { visibility: visible; }
}
.pt.is-demo .pt__btn:nth-child(1) { animation-name: ptOn1; }
.pt.is-demo .pt__btn:nth-child(2) { animation-name: ptOn2; }
.pt.is-demo .pt__btn:nth-child(3) { animation-name: ptOn3; }
@keyframes ptOn1 {
0%, 33% { color: $subject-cream; }
34%, 100% { color: $dim; }
}
@keyframes ptOn2 {
0%, 33% { color: $dim; }
34%, 66% { color: $subject-cream; }
67%, 100% { color: $dim; }
}
@keyframes ptOn3 {
0%, 66% { color: $dim; }
67%, 100% { color: $subject-cream; }
}
// 밑줄만 연속 보간이다 — steps 와 한 요소에 섞으면 채움이 컷으로 죽는다 (266-09 실측)
.pt.is-demo .pt__btn:nth-child(1) .pt__bar { animation-name: ptBar1; }
.pt.is-demo .pt__btn:nth-child(2) .pt__bar { animation-name: ptBar2; }
.pt.is-demo .pt__btn:nth-child(3) .pt__bar { animation-name: ptBar3; }
.pt.is-demo .pt__bar {
animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes ptBar1 {
0% { transform: scaleX(0); }
12%, 33% { transform: scaleX(1); }
34%, 100% { transform: scaleX(0); }
}
@keyframes ptBar2 {
0%, 34% { transform: scaleX(0); }
46%, 66% { transform: scaleX(1); }
67%, 100% { transform: scaleX(0); }
}
@keyframes ptBar3 {
0%, 67% { transform: scaleX(0); }
79%, 100% { transform: scaleX(1); }
}
@media (max-width: 340px) {
.pt { padding: $sp-2; gap: $sp-2; }
.pt__seg { padding-right: $sp-2; }
.pt__seg--b, .pt__seg--c { padding-left: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.pt.is-demo .pt__view,
.pt.is-demo .pt__btn,
.pt.is-demo .pt__bar { animation: none !important; }
.pt.is-demo .pt__view--plain { visibility: visible !important; }
.pt.is-demo .pt__view--wave,
.pt.is-demo .pt__view--seg { visibility: hidden !important; }
.pt__bar { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pt {
display: flex;
flex-direction: column;
gap: 12px;
box-sizing: border-box;
width: min(100%, 408px);
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
}
.pt__pick {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.pt__btn {
position: relative;
box-sizing: border-box;
width: 100%;
padding: 4px 0 8px;
border: 0;
border-radius: 0;
background: none;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
font-family: inherit;
font-weight: 700;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.4;
}
.pt__btn[aria-pressed=true] {
color: #fff7e6;
}
.pt__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.pt__bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
border-radius: 999px;
background: #2b9d74;
transform: scaleX(0);
transform-origin: left center;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.pt__btn[aria-pressed=true] .pt__bar {
transform: scaleX(1);
}
.pt__screen {
display: grid;
min-height: 26px;
align-items: center;
}
.pt__screen > * {
grid-area: 1/1;
}
.pt__view {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
font-weight: 600;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.4;
white-space: nowrap;
visibility: hidden;
}
.pt[data-shape=plain] .pt__view--plain,
.pt[data-shape=wave] .pt__view--wave,
.pt[data-shape=seg] .pt__view--seg {
visibility: visible;
}
.pt__path {
color: #6ec8ff;
}
.pt__git {
color: #ffd23f;
}
.pt__caret {
color: #2b9d74;
font-weight: 800;
}
.pt__user {
color: #2b9d74;
}
.pt__at {
color: rgba(255, 255, 255, 0.6);
}
.pt__host {
color: #fff7e6;
}
.pt__caret--wave {
color: #ff9ebb;
letter-spacing: -1px;
}
.pt__view--wave {
gap: 0;
}
.pt__view--wave .pt__path {
margin-left: 8px;
}
.pt__view--wave .pt__caret--wave {
margin-left: 8px;
}
.pt__view--seg {
gap: 0;
}
.pt__seg {
display: inline-flex;
align-items: center;
box-sizing: border-box;
height: 22px;
padding: 0 12px 0 8px;
color: #17141a;
font-weight: 700;
clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 50%, calc(100% - 8px) 100%, 0 100%);
}
.pt__seg--a {
background: #6ec8ff;
padding-left: 8px;
}
.pt__seg--b {
background: #ffd23f;
margin-left: -8px;
padding-left: 12px;
}
.pt__seg--c {
background: #2b9d74;
margin-left: -8px;
padding-left: 12px;
padding-right: 8px;
}
.pt.is-demo .pt__view,
.pt.is-demo .pt__btn {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.pt.is-demo .pt__view--plain {
animation-name: ptShow1;
}
.pt.is-demo .pt__view--wave {
animation-name: ptShow2;
}
.pt.is-demo .pt__view--seg {
animation-name: ptShow3;
}
@keyframes ptShow1 {
0%, 33% {
visibility: visible;
}
34%, 100% {
visibility: hidden;
}
}
@keyframes ptShow2 {
0%, 33% {
visibility: hidden;
}
34%, 66% {
visibility: visible;
}
67%, 100% {
visibility: hidden;
}
}
@keyframes ptShow3 {
0%, 66% {
visibility: hidden;
}
67%, 100% {
visibility: visible;
}
}
.pt.is-demo .pt__btn:nth-child(1) {
animation-name: ptOn1;
}
.pt.is-demo .pt__btn:nth-child(2) {
animation-name: ptOn2;
}
.pt.is-demo .pt__btn:nth-child(3) {
animation-name: ptOn3;
}
@keyframes ptOn1 {
0%, 33% {
color: #fff7e6;
}
34%, 100% {
color: rgba(255, 255, 255, 0.6);
}
}
@keyframes ptOn2 {
0%, 33% {
color: rgba(255, 255, 255, 0.6);
}
34%, 66% {
color: #fff7e6;
}
67%, 100% {
color: rgba(255, 255, 255, 0.6);
}
}
@keyframes ptOn3 {
0%, 66% {
color: rgba(255, 255, 255, 0.6);
}
67%, 100% {
color: #fff7e6;
}
}
.pt.is-demo .pt__btn:nth-child(1) .pt__bar {
animation-name: ptBar1;
}
.pt.is-demo .pt__btn:nth-child(2) .pt__bar {
animation-name: ptBar2;
}
.pt.is-demo .pt__btn:nth-child(3) .pt__bar {
animation-name: ptBar3;
}
.pt.is-demo .pt__bar {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes ptBar1 {
0% {
transform: scaleX(0);
}
12%, 33% {
transform: scaleX(1);
}
34%, 100% {
transform: scaleX(0);
}
}
@keyframes ptBar2 {
0%, 34% {
transform: scaleX(0);
}
46%, 66% {
transform: scaleX(1);
}
67%, 100% {
transform: scaleX(0);
}
}
@keyframes ptBar3 {
0%, 67% {
transform: scaleX(0);
}
79%, 100% {
transform: scaleX(1);
}
}
@media (max-width: 340px) {
.pt {
padding: 8px;
gap: 8px;
}
.pt__seg {
padding-right: 8px;
}
.pt__seg--b, .pt__seg--c {
padding-left: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.pt.is-demo .pt__view,
.pt.is-demo .pt__btn,
.pt.is-demo .pt__bar {
animation: none !important;
}
.pt.is-demo .pt__view--plain {
visibility: visible !important;
}
.pt.is-demo .pt__view--wave,
.pt.is-demo .pt__view--seg {
visibility: hidden !important;
}
.pt__bar {
transition: none !important;
}
}var pt = document.getElementById('pt');
var btns = Array.prototype.slice.call(pt.querySelectorAll('.pt__btn'));
function pickShape(shape) {
pt.classList.remove('is-demo');
pt.setAttribute('data-shape', shape);
btns.forEach(function (b) { b.setAttribute('aria-pressed', String(b.dataset.shape === shape)); });
}
btns.forEach(function (b) { b.addEventListener('click', function () { pickShape(b.dataset.shape); }); });// PromptThemeSwitcher — vanilla 판(run/294/demo/03-terminal-prompt-theme-switcher/index.html) 의 React 포트.
// 같은 한 줄 프롬프트를 기본·물결·조각 세 모양으로 갈아 끼운다. 버튼을 한 번 누르면
// 격자 미리보기 루프(demo)가 꺼지고 사람이 고른 모양만 남는다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PromptThemeSwitcher.module.scss";
export type PromptThemeSwitcherProps = {
/** 격자 미리보기용 2초 자동 루프. 모양 버튼을 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 값은 data-shape 에 그대로 들어가는 영어 key, 이름은 화면에 찍히는 한국어 문구 */
const SHAPES = [
{ value: "plain", label: "기본" },
{ value: "wave", label: "물결" },
{ value: "seg", label: "조각" },
];
export default function PromptThemeSwitcher({
demo = true,
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2b9d74",
}: PromptThemeSwitcherProps) {
const [live, setLive] = useState(demo);
const [shape, setShape] = useState("plain");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function pickShape(next: string) {
setLive(false);
setShape(next);
}
const cls = [styles.root, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage} style={vars}>
<div className={cls} data-shape={shape}>
<div className={styles.pick} role="group" aria-label="프롬프트 모양">
{SHAPES.map((s) => (
<button
className={styles.btn}
type="button"
key={s.value}
data-shape={s.value}
aria-pressed={shape === s.value}
onClick={() => pickShape(s.value)}
>
{s.label}
<span className={styles.bar} aria-hidden="true" />
</button>
))}
</div>
<div className={styles.screen}>
<div className={`${styles.view} ${styles.viewPlain}`}>
<span className={styles.path}>~/work/site</span>
<span className={styles.git}>git:(main)</span>
<span className={styles.caret}>❯</span>
</div>
<div className={`${styles.view} ${styles.viewWave}`}>
<span className={styles.user}>you</span><span className={styles.at}>@</span><span className={styles.host}>mac</span>
<span className={styles.path}>~/work/site</span>
<span className={`${styles.caret} ${styles.caretWave}`}>❯❯</span>
</div>
<div className={`${styles.view} ${styles.viewSeg}`}>
<span className={`${styles.seg} ${styles.segA}`}>~/work/site</span><span className={`${styles.seg} ${styles.segB}`}>main</span><span className={`${styles.seg} ${styles.segC}`}>node 22</span>
</div>
</div>
</div>
</main>
);
}03 · 프롬프트 테마 전환 — 측정 누적면적 3.626% · 움직인 프레임 11/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
고른 모양을 알리는 자리와 측정값
고른 모양은 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 으로 바닥값과 같아졌습니다.

<main class="stage">
<div class="lg is-demo" id="lg">
<p class="lg__bar">
<span class="lg__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 56v144a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M128 128a8 8 0 0 1-3 6.25l-40 32a8 8 0 1 1-10-12.5L107.19 128L75 102.25a8 8 0 1 1 10-12.5l40 32a8 8 0 0 1 3 6.25m48 24h-40a8 8 0 0 0 0 16h40a8 8 0 0 0 0-16m56-96v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16m-16 144V56H40v144z"/></g></svg></span>
<span class="lg__name">배포 로그</span>
<button class="lg__follow" type="button" id="follow" aria-pressed="true">따라가기</button>
</p>
<div class="lg__box" id="box" role="log" aria-live="polite" tabindex="0" aria-label="배포 로그 줄">
<div class="lg__track" id="track">
<p class="lg__row"><span class="lg__tag">INFO</span>GET /api/orders 200 18ms</p>
<p class="lg__row"><span class="lg__tag">INFO</span>worker picked job 4821</p>
<p class="lg__row lg__row--warn"><span class="lg__tag">WARN</span>retrying upload 2 of 3</p>
<p class="lg__row"><span class="lg__tag">INFO</span>cache warm 1.2k keys</p>
<p class="lg__row lg__row--err"><span class="lg__tag">ERR</span>POST /pay 502 upstream</p>
<p class="lg__row"><span class="lg__tag">INFO</span>deploy 7f21c3 live</p>
<p class="lg__row" aria-hidden="true"><span class="lg__tag">INFO</span>GET /api/orders 200 18ms</p>
<p class="lg__row" aria-hidden="true"><span class="lg__tag">INFO</span>worker picked job 4821</p>
<p class="lg__row lg__row--warn" aria-hidden="true"><span class="lg__tag">WARN</span>retrying upload 2 of 3</p>
<p class="lg__row" aria-hidden="true"><span class="lg__tag">INFO</span>cache warm 1.2k keys</p>
<p class="lg__row lg__row--err" aria-hidden="true"><span class="lg__tag">ERR</span>POST /pay 502 upstream</p>
<p class="lg__row" aria-hidden="true"><span class="lg__tag">INFO</span>deploy 7f21c3 live</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 로그 따라가기 창 — 새 줄이 붙을 때마다 scrollTop 을 바닥으로 밀어 마지막 줄을 계속 따라간다.
// 격자 미리보기(.is-demo)는 실제 타이머 대신 줄 묶음을 두 벌 깔고 트랙을 translateY(-50%) 로 흘린다.
// 두 벌이 이어 붙어야 한 바퀴가 매끄럽다 — 줄 사이를 gap 으로 띄우면 2N 개 사이 간격이 2N-1 개라
// -50% 가 한 칸 모자라 바퀴마다 튄다 (028 실측). 그래서 간격은 줄의 margin-bottom 으로만 준다 (BD15).
// 등급은 색만으로 가르지 않는다 — 줄 맨 앞 INFO·WARN·ERR 글자가 같은 정보를 글자로도 준다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $chart-3;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(255, 255, 255, .16);
$dim: rgba(255, 255, 255, .62);
$row-h: 18px;
@include stage($stage-ink);
.lg {
display: flex; flex-direction: column;
box-sizing: border-box; width: min(100%, 408px);
border: 1px solid $line; border-radius: $r-card; overflow: hidden;
background: rgba(255, 255, 255, .05); font-family: $mono;
}
.lg__bar {
display: flex; align-items: center; gap: $sp-2;
margin: 0; padding: $sp-2 $sp-3; border-bottom: 1px solid $line; color: $dim;
font-weight: 700; font-size: clamp(10px, 2.8vw, 11px); line-height: 1;
}
.lg__ico { display: grid; place-items: center; font-size: 14px; color: $color; }
.lg__name { flex: 1 1 auto; }
.lg__follow {
flex: 0 0 auto; box-sizing: border-box; min-height: 22px; padding: 0 $sp-2;
border: 1px solid $line; border-radius: $r-pill;
background: none; color: $dim; cursor: pointer;
font-family: inherit; font-weight: 700; font-size: clamp(9px, 2.4vw, 10px); line-height: 1;
transition: color $duration $easing, border-color $duration $easing;
}
.lg__follow[aria-pressed="true"] { color: $color; border-color: $color; }
.lg__follow:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.lg__box {
height: calc(#{$row-h} * 5); overflow-y: auto; overscroll-behavior: contain;
padding: $sp-2 $sp-3;
}
.lg__box:focus-visible { outline: 2px solid $subject-blue; outline-offset: -2px; }
.lg__row {
display: flex; align-items: baseline; gap: $sp-2;
margin: 0; height: $row-h; color: $subject-cream;
font-weight: 500; font-size: clamp(9px, 2.6vw, 11px); line-height: $row-h;
white-space: nowrap; overflow: hidden;
}
.lg__tag {
flex: 0 0 auto; width: 34px; color: $dim; font-weight: 800;
font-size: clamp(8px, 2.2vw, 9px); letter-spacing: .04em;
}
.lg__row--warn { color: $stage-yellow; }
.lg__row--warn .lg__tag { color: $stage-yellow; }
.lg__row--err { color: $error-soft; }
.lg__row--err .lg__tag { color: $error-soft; }
// 새 줄은 아래에서 밀려 올라온다 — 실제 스트림에서 붙는 줄에만 붙는 규칙
.lg__row.is-new {
animation-name: lgNew; animation-duration: $dur-base; animation-timing-function: $easing;
animation-iteration-count: 1; animation-fill-mode: backwards;
}
@keyframes lgNew {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
// ── 격자 미리보기 루프 (2초): 트랙이 정확히 한 벌만큼 올라가 두 벌의 이음매에서 되돌아온다
.lg.is-demo .lg__box { overflow: hidden; }
.lg.is-demo .lg__track {
animation-name: lgFlow; animation-duration: $dur-loop; animation-timing-function: linear;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes lgFlow {
from { transform: translateY(0); }
to { transform: translateY(-50%); }
}
@media (max-width: 340px) {
.lg__bar, .lg__box { padding-left: $sp-2; padding-right: $sp-2; }
.lg__tag { width: 28px; }
}
@media (prefers-reduced-motion: reduce) {
.lg.is-demo .lg__track { animation: none !important; }
.lg__row.is-new { animation: none !important; }
.lg__follow { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lg {
display: flex;
flex-direction: column;
box-sizing: border-box;
width: min(100%, 408px);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
}
.lg__bar {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
padding: 8px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.16);
color: rgba(255, 255, 255, 0.62);
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1;
}
.lg__ico {
display: grid;
place-items: center;
font-size: 14px;
color: #2b9d74;
}
.lg__name {
flex: 1 1 auto;
}
.lg__follow {
flex: 0 0 auto;
box-sizing: border-box;
min-height: 22px;
padding: 0 8px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 999px;
background: none;
color: rgba(255, 255, 255, 0.62);
cursor: pointer;
font-family: inherit;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1;
transition: color 220ms ease-out, border-color 220ms ease-out;
}
.lg__follow[aria-pressed=true] {
color: #2b9d74;
border-color: #2b9d74;
}
.lg__follow:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.lg__box {
height: calc(18px * 5);
overflow-y: auto;
overscroll-behavior: contain;
padding: 8px 12px;
}
.lg__box:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.lg__row {
display: flex;
align-items: baseline;
gap: 8px;
margin: 0;
height: 18px;
color: #fff7e6;
font-weight: 500;
font-size: clamp(9px, 2.6vw, 11px);
line-height: 18px;
white-space: nowrap;
overflow: hidden;
}
.lg__tag {
flex: 0 0 auto;
width: 34px;
color: rgba(255, 255, 255, 0.62);
font-weight: 800;
font-size: clamp(8px, 2.2vw, 9px);
letter-spacing: 0.04em;
}
.lg__row--warn {
color: #ffd23f;
}
.lg__row--warn .lg__tag {
color: #ffd23f;
}
.lg__row--err {
color: #ffe0d8;
}
.lg__row--err .lg__tag {
color: #ffe0d8;
}
.lg__row.is-new {
animation-name: lgNew;
animation-duration: 300ms;
animation-timing-function: ease-out;
animation-iteration-count: 1;
animation-fill-mode: backwards;
}
@keyframes lgNew {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.lg.is-demo .lg__box {
overflow: hidden;
}
.lg.is-demo .lg__track {
animation-name: lgFlow;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes lgFlow {
from {
transform: translateY(0);
}
to {
transform: translateY(-50%);
}
}
@media (max-width: 340px) {
.lg__bar, .lg__box {
padding-left: 8px;
padding-right: 8px;
}
.lg__tag {
width: 28px;
}
}
@media (prefers-reduced-motion: reduce) {
.lg.is-demo .lg__track {
animation: none !important;
}
.lg__row.is-new {
animation: none !important;
}
.lg__follow {
transition: none !important;
}
}var lg = document.getElementById('lg');
var box = document.getElementById('box');
var track = document.getElementById('track');
var follow = document.getElementById('follow');
var FEED = [
['INFO', 'GET /api/orders 200 18ms'], ['INFO', 'worker picked job 4821'],
['WARN', 'retrying upload 2 of 3'], ['INFO', 'cache warm 1.2k keys'],
['ERR', 'POST /pay 502 upstream'], ['INFO', 'deploy 7f21c3 live']
];
var CLS = { WARN: ' lg__row--warn', ERR: ' lg__row--err' };
var at = 0, timer = 0;
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; }
}
function start() {
if (timer) { return; }
lg.classList.remove('is-demo');
Array.prototype.slice.call(track.querySelectorAll('[aria-hidden="true"]')).forEach(function (n) { track.removeChild(n); });
timer = setInterval(append, 700);
}
follow.addEventListener('click', function () {
start();
var on = follow.getAttribute('aria-pressed') === 'true';
follow.setAttribute('aria-pressed', String(!on));
if (!on) { box.scrollTop = box.scrollHeight; }
});
box.addEventListener('pointerdown', start);
box.addEventListener('focus', start);// LogFollowTail — vanilla 판(run/294/demo/04-terminal-log-follow-tail/index.html) 의 React 포트.
// 새 줄이 붙을 때마다 scrollTop 을 바닥으로 밀어 마지막 줄을 계속 따라간다.
// 따라가기가 꺼져 있으면 읽던 자리에 그대로 선다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./LogFollowTail.module.scss";
export type LogFollowTailProps = {
/** 격자 미리보기용 2초 루프. 창을 건드리면 꺼지고 실제 스트림으로 넘어간다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
/** 스크롤 위치만 쓰므로 ref 상자도 쓰는 필드만 지역 타입으로 선언한다 */
type ScrollBox = { scrollTop: number; scrollHeight: number };
type Feed = { tag: string; text: string; kind: string };
type Row = { id: number; tag: string; text: string; kind: string; fresh: boolean; ghost: boolean };
const FEED: Feed[] = [
{ tag: "INFO", text: "GET /api/orders 200 18ms", kind: "" },
{ tag: "INFO", text: "worker picked job 4821", kind: "" },
{ tag: "WARN", text: "retrying upload 2 of 3", kind: "warn" },
{ tag: "INFO", text: "cache warm 1.2k keys", kind: "" },
{ tag: "ERR", text: "POST /pay 502 upstream", kind: "err" },
{ tag: "INFO", text: "deploy 7f21c3 live", kind: "" },
];
const TICK = 700;
const MAX_ROWS = 40;
/** 미리보기는 줄 묶음을 두 벌 깔고 트랙을 translateY(-50%) 로 흘린다 — 두 벌이 이어 붙어야 한 바퀴가 매끄럽다 */
function seedRows(): Row[] {
const out: Row[] = [];
for (let copy = 0; copy < 2; copy += 1) {
for (let i = 0; i < FEED.length; i += 1) {
const item = FEED[i];
out.push({ id: -(copy * FEED.length + i) - 1, tag: item.tag, text: item.text, kind: item.kind, fresh: false, ghost: copy === 1 });
}
}
return out;
}
export default function LogFollowTail({
demo = true,
duration = 220,
easing = "ease-out",
color = "#2b9d74",
}: LogFollowTailProps) {
const [live, setLive] = useState(demo);
const [running, setRunning] = useState(false);
const [follow, setFollow] = useState(true);
const [rows, setRows] = useState<Row[]>(seedRows);
const boxRef = useRef<ScrollBox>(null);
const followRef = useRef<boolean>(true);
const atRef = useRef<number>(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function start() {
if (running) { return; }
setLive(false);
// 미리보기용 두 번째 벌은 실제 스트림이 시작되면 걷어낸다
setRows((prev) => prev.filter((row) => !row.ghost));
setRunning(true);
}
useEffect(() => {
if (!running) { return; }
const id = setInterval(() => {
setRows((prev) => {
const at = atRef.current === null ? 0 : atRef.current;
atRef.current = at + 1;
const item = FEED[at % FEED.length];
const next = prev.concat([{ id: at, tag: item.tag, text: item.text, kind: item.kind, fresh: true, ghost: false }]);
while (next.length > MAX_ROWS) { next.shift(); }
return next;
});
}, TICK);
return () => { clearInterval(id); };
}, [running]);
// 따라가기가 켜져 있을 때만 바닥으로 민다
useEffect(() => {
const box = boxRef.current;
if (!box || !followRef.current) { return; }
box.scrollTop = box.scrollHeight;
}, [rows]);
function toggleFollow() {
start();
const on = !follow;
followRef.current = on;
setFollow(on);
const box = boxRef.current;
if (on && box) { box.scrollTop = box.scrollHeight; }
}
return (
<main className={styles.stage} style={vars}>
<div className={live ? `${styles.lg} ${styles.isDemo}` : styles.lg}>
<p className={styles.bar}>
<span className={styles.ico} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M224 56v144a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2" />
<path d="M128 128a8 8 0 0 1-3 6.25l-40 32a8 8 0 1 1-10-12.5L107.19 128L75 102.25a8 8 0 1 1 10-12.5l40 32a8 8 0 0 1 3 6.25m48 24h-40a8 8 0 0 0 0 16h40a8 8 0 0 0 0-16m56-96v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16m-16 144V56H40v144z" />
</g>
</svg>
</span>
<span className={styles.name}>배포 로그</span>
<button className={styles.follow} type="button" aria-pressed={follow ? "true" : "false"} onClick={toggleFollow}>따라가기</button>
</p>
<div
className={styles.box}
ref={boxRef}
role="log"
aria-live="polite"
tabIndex={0}
aria-label="배포 로그 줄"
onPointerDown={start}
onFocus={start}
>
<div className={styles.track}>
{rows.map((row) => {
const cls = [
styles.row,
row.kind === "warn" ? styles.rowWarn : "",
row.kind === "err" ? styles.rowErr : "",
row.fresh ? styles.isNew : "",
].filter(Boolean).join(" ");
return (
<p className={cls} key={row.id} aria-hidden={row.ghost ? "true" : undefined}>
<span className={styles.tag}>{row.tag}</span>{row.text}
</p>
);
})}
</div>
</div>
</div>
</main>
);
}04 · 로그 따라가기 창 — 측정 누적면적 12.09% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
등급을 색 말고 글자로
등급은 색뿐 아니라 줄 맨 앞 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() 로 찍어 막대와 같은 걸음으로 움직입니다.

<main class="stage">
<div class="pg is-demo" id="pg">
<p class="pg__bar-top">
<span class="pg__ico" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M224 56v144a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M128 128a8 8 0 0 1-3 6.25l-40 32a8 8 0 1 1-10-12.5L107.19 128L75 102.25a8 8 0 1 1 10-12.5l40 32a8 8 0 0 1 3 6.25m48 24h-40a8 8 0 0 0 0 16h40a8 8 0 0 0 0-16m56-96v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16m-16 144V56H40v144z"/></g></svg></span>
<span class="pg__title">작업 세 개</span>
<button class="pg__again" type="button" id="again">다시 실행</button>
</p>
<p class="pg__row pg__row--1">
<span class="pg__name">build</span>
<span class="pg__meter" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="75" aria-label="build">
<span class="pg__brk">[</span>
<span class="pg__cells">
<span class="pg__track" aria-hidden="true">████████████████</span>
<span class="pg__fill" aria-hidden="true">████████████████</span>
</span>
<span class="pg__brk">]</span>
</span>
<span class="pg__pct" aria-hidden="true"></span>
<span class="pg__eta">0:04</span>
</p>
<p class="pg__row pg__row--2">
<span class="pg__name">test</span>
<span class="pg__meter" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="50" aria-label="test">
<span class="pg__brk">[</span>
<span class="pg__cells">
<span class="pg__track" aria-hidden="true">████████████████</span>
<span class="pg__fill" aria-hidden="true">████████████████</span>
</span>
<span class="pg__brk">]</span>
</span>
<span class="pg__pct" aria-hidden="true"></span>
<span class="pg__eta">0:11</span>
</p>
<p class="pg__row pg__row--3">
<span class="pg__name">docs</span>
<span class="pg__meter" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="25" aria-label="docs">
<span class="pg__brk">[</span>
<span class="pg__cells">
<span class="pg__track" aria-hidden="true">████████████████</span>
<span class="pg__fill" aria-hidden="true">████████████████</span>
</span>
<span class="pg__brk">]</span>
</span>
<span class="pg__pct" aria-hidden="true"></span>
<span class="pg__eta">0:26</span>
</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 문자로 그린 진행 막대 — 막대를 도형이 아니라 채움 문자 열여섯 개로 그린다.
// 빈 칸과 채운 칸은 같은 격자 칸에 겹쳐 두고, 채운 쪽만 clip-path: inset 으로 잘라 보여 준다.
// 자르는 폭을 steps() 로 끊어 한 걸음이 정확히 한 글자다 — 12칸이면 steps(12, end).
// 퍼센트 숫자는 @property 로 등록한 --p 를 counter() 로 찍는다. 등록하지 않은 커스텀 속성은
// 문자열이라 보간되지 않아 0 과 목표값 사이를 뛰어넘는다.
$duration: $dur-loop;
$easing: $ease-out;
$color: $chart-3;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(255, 255, 255, .16);
$dim: rgba(255, 255, 255, .6);
@property --p {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
@include stage($stage-ink);
.pg {
display: flex; flex-direction: column; gap: $sp-2;
box-sizing: border-box; width: min(100%, 408px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: rgba(255, 255, 255, .05); font-family: $mono;
}
.pg__bar-top {
display: flex; align-items: center; gap: $sp-2; margin: 0 0 $sp-1;
color: $dim; font-weight: 700; font-size: clamp(10px, 2.8vw, 11px); line-height: 1;
}
.pg__ico { display: grid; place-items: center; font-size: 14px; color: $color; }
.pg__title { flex: 1 1 auto; }
.pg__again {
flex: 0 0 auto; box-sizing: border-box; min-height: 22px; padding: 0 $sp-2;
border: 1px solid $line; border-radius: $r-pill; background: none; color: $dim; cursor: pointer;
font-family: inherit; font-weight: 700; font-size: clamp(9px, 2.4vw, 10px); line-height: 1;
}
.pg__again:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
.pg__row {
display: flex; align-items: center; gap: $sp-2; margin: 0;
font-weight: 600; font-size: clamp(9px, 2.6vw, 11px); line-height: 1.4; white-space: nowrap;
}
.pg__row--1 { --p: 75; }
.pg__row--2 { --p: 50; }
.pg__row--3 { --p: 25; }
.pg__name { flex: 0 0 auto; width: 38px; color: $subject-cream; font-weight: 700; }
.pg__meter { display: inline-flex; align-items: center; flex: 0 0 auto; }
.pg__brk { color: $dim; }
.pg__cells { display: inline-grid; }
.pg__cells > * { grid-area: 1 / 1; letter-spacing: 0; }
// 빈 칸도 3:1 은 넘겨야 막대의 끝이 보인다 — 판 바탕 #232025 위에서 알파 .18 은 1.79:1 이었고
// .38 로 올려 3.51:1 (aria-hidden 인 장식 글리프라 비텍스트 기준. 이 편 실측, run/294/_probe/contrast.py)
.pg__track { color: rgba(255, 255, 255, .38); }
.pg__fill { color: $color; }
.pg__row--1 .pg__fill { clip-path: inset(0 25% 0 0); }
.pg__row--2 .pg__fill { clip-path: inset(0 50% 0 0); }
.pg__row--3 .pg__fill { clip-path: inset(0 75% 0 0); }
// 숫자는 자릿수가 바뀌어도 줄이 밀리지 않게 폭을 고정한다
.pg__pct {
flex: 0 0 auto; width: 34px; text-align: right; color: $subject-cream;
font-variant-numeric: tabular-nums; font-weight: 700;
}
.pg__pct::after { counter-reset: p var(--p); content: counter(p) "%"; }
.pg__eta {
flex: 0 0 auto; width: 34px; text-align: right; color: $dim;
font-variant-numeric: tabular-nums;
}
// ── 격자 미리보기 루프(.is-demo)와 실제 실행(.is-run)은 같은 키프레임을 쓴다.
// 미리보기는 무한 반복, 실제 실행은 한 번만 돌고 채운 자리에 선다
.pg.is-demo .pg__row,
.pg.is-run .pg__row {
animation-duration: $duration; animation-fill-mode: both;
}
.pg.is-demo .pg__row { animation-iteration-count: infinite; }
.pg.is-run .pg__row { animation-iteration-count: 1; }
.pg.is-demo .pg__row--1, .pg.is-run .pg__row--1 { animation-name: pgNum1; animation-timing-function: steps(12, end); }
.pg.is-demo .pg__row--2, .pg.is-run .pg__row--2 { animation-name: pgNum2; animation-timing-function: steps(8, end); }
.pg.is-demo .pg__row--3, .pg.is-run .pg__row--3 { animation-name: pgNum3; animation-timing-function: steps(4, end); }
@keyframes pgNum1 {
0% { --p: 0; }
80%, 100% { --p: 75; }
}
@keyframes pgNum2 {
0% { --p: 0; }
80%, 100% { --p: 50; }
}
@keyframes pgNum3 {
0% { --p: 0; }
80%, 100% { --p: 25; }
}
.pg.is-demo .pg__fill,
.pg.is-run .pg__fill {
animation-duration: $duration; animation-fill-mode: both;
}
.pg.is-demo .pg__fill { animation-iteration-count: infinite; }
.pg.is-run .pg__fill { animation-iteration-count: 1; }
.pg.is-demo .pg__row--1 .pg__fill, .pg.is-run .pg__row--1 .pg__fill { animation-name: pgFill1; animation-timing-function: steps(12, end); }
.pg.is-demo .pg__row--2 .pg__fill, .pg.is-run .pg__row--2 .pg__fill { animation-name: pgFill2; animation-timing-function: steps(8, end); }
.pg.is-demo .pg__row--3 .pg__fill, .pg.is-run .pg__row--3 .pg__fill { animation-name: pgFill3; animation-timing-function: steps(4, end); }
@keyframes pgFill1 {
0% { clip-path: inset(0 100% 0 0); }
80%, 100% { clip-path: inset(0 25% 0 0); }
}
@keyframes pgFill2 {
0% { clip-path: inset(0 100% 0 0); }
80%, 100% { clip-path: inset(0 50% 0 0); }
}
@keyframes pgFill3 {
0% { clip-path: inset(0 100% 0 0); }
80%, 100% { clip-path: inset(0 75% 0 0); }
}
@media (max-width: 340px) {
.pg { padding: $sp-2; gap: $sp-1; }
.pg__name { width: 32px; }
.pg__pct, .pg__eta { width: 30px; }
}
@media (prefers-reduced-motion: reduce) {
.pg.is-demo .pg__row,
.pg.is-demo .pg__fill,
.pg.is-run .pg__row,
.pg.is-run .pg__fill { animation: none !important; }
}@property --p {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pg {
display: flex;
flex-direction: column;
gap: 8px;
box-sizing: border-box;
width: min(100%, 408px);
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
}
.pg__bar-top {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 4px;
color: rgba(255, 255, 255, 0.6);
font-weight: 700;
font-size: clamp(10px, 2.8vw, 11px);
line-height: 1;
}
.pg__ico {
display: grid;
place-items: center;
font-size: 14px;
color: #2b9d74;
}
.pg__title {
flex: 1 1 auto;
}
.pg__again {
flex: 0 0 auto;
box-sizing: border-box;
min-height: 22px;
padding: 0 8px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 999px;
background: none;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
font-family: inherit;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1;
}
.pg__again:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.pg__row {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-weight: 600;
font-size: clamp(9px, 2.6vw, 11px);
line-height: 1.4;
white-space: nowrap;
}
.pg__row--1 {
--p: 75;
}
.pg__row--2 {
--p: 50;
}
.pg__row--3 {
--p: 25;
}
.pg__name {
flex: 0 0 auto;
width: 38px;
color: #fff7e6;
font-weight: 700;
}
.pg__meter {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
}
.pg__brk {
color: rgba(255, 255, 255, 0.6);
}
.pg__cells {
display: inline-grid;
}
.pg__cells > * {
grid-area: 1/1;
letter-spacing: 0;
}
.pg__track {
color: rgba(255, 255, 255, 0.38);
}
.pg__fill {
color: #2b9d74;
}
.pg__row--1 .pg__fill {
clip-path: inset(0 25% 0 0);
}
.pg__row--2 .pg__fill {
clip-path: inset(0 50% 0 0);
}
.pg__row--3 .pg__fill {
clip-path: inset(0 75% 0 0);
}
.pg__pct {
flex: 0 0 auto;
width: 34px;
text-align: right;
color: #fff7e6;
font-variant-numeric: tabular-nums;
font-weight: 700;
}
.pg__pct::after {
counter-reset: p var(--p);
content: counter(p) "%";
}
.pg__eta {
flex: 0 0 auto;
width: 34px;
text-align: right;
color: rgba(255, 255, 255, 0.6);
font-variant-numeric: tabular-nums;
}
.pg.is-demo .pg__row,
.pg.is-run .pg__row {
animation-duration: 2s;
animation-fill-mode: both;
}
.pg.is-demo .pg__row {
animation-iteration-count: infinite;
}
.pg.is-run .pg__row {
animation-iteration-count: 1;
}
.pg.is-demo .pg__row--1, .pg.is-run .pg__row--1 {
animation-name: pgNum1;
animation-timing-function: steps(12, end);
}
.pg.is-demo .pg__row--2, .pg.is-run .pg__row--2 {
animation-name: pgNum2;
animation-timing-function: steps(8, end);
}
.pg.is-demo .pg__row--3, .pg.is-run .pg__row--3 {
animation-name: pgNum3;
animation-timing-function: steps(4, end);
}
@keyframes pgNum1 {
0% {
--p: 0;
}
80%, 100% {
--p: 75;
}
}
@keyframes pgNum2 {
0% {
--p: 0;
}
80%, 100% {
--p: 50;
}
}
@keyframes pgNum3 {
0% {
--p: 0;
}
80%, 100% {
--p: 25;
}
}
.pg.is-demo .pg__fill,
.pg.is-run .pg__fill {
animation-duration: 2s;
animation-fill-mode: both;
}
.pg.is-demo .pg__fill {
animation-iteration-count: infinite;
}
.pg.is-run .pg__fill {
animation-iteration-count: 1;
}
.pg.is-demo .pg__row--1 .pg__fill, .pg.is-run .pg__row--1 .pg__fill {
animation-name: pgFill1;
animation-timing-function: steps(12, end);
}
.pg.is-demo .pg__row--2 .pg__fill, .pg.is-run .pg__row--2 .pg__fill {
animation-name: pgFill2;
animation-timing-function: steps(8, end);
}
.pg.is-demo .pg__row--3 .pg__fill, .pg.is-run .pg__row--3 .pg__fill {
animation-name: pgFill3;
animation-timing-function: steps(4, end);
}
@keyframes pgFill1 {
0% {
clip-path: inset(0 100% 0 0);
}
80%, 100% {
clip-path: inset(0 25% 0 0);
}
}
@keyframes pgFill2 {
0% {
clip-path: inset(0 100% 0 0);
}
80%, 100% {
clip-path: inset(0 50% 0 0);
}
}
@keyframes pgFill3 {
0% {
clip-path: inset(0 100% 0 0);
}
80%, 100% {
clip-path: inset(0 75% 0 0);
}
}
@media (max-width: 340px) {
.pg {
padding: 8px;
gap: 4px;
}
.pg__name {
width: 32px;
}
.pg__pct, .pg__eta {
width: 30px;
}
}
@media (prefers-reduced-motion: reduce) {
.pg.is-demo .pg__row,
.pg.is-demo .pg__fill,
.pg.is-run .pg__row,
.pg.is-run .pg__fill {
animation: none !important;
}
}var pg = document.getElementById('pg');
var again = document.getElementById('again');
again.addEventListener('click', function () {
// 미리보기 루프를 끄고 한 번만 채워지는 실제 실행 경로로 넘어간다.
// 클래스를 뗐다가 다음 프레임에 다시 붙여야 같은 애니메이션이 처음부터 다시 돈다
pg.classList.remove('is-demo', 'is-run');
requestAnimationFrame(function () { pg.classList.add('is-run'); });
});// AsciiProgress — vanilla 판(run/294/demo/05-terminal-ascii-progress/index.html) 의 React 포트.
// 막대를 도형이 아니라 채움 문자 열여섯 개로 그린다. 채운 쪽만 clip-path 로 잘라 보여 준다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./AsciiProgress.module.scss";
export type AsciiProgressProps = {
/** 격자 미리보기용 2초 자동 루프. 다시 실행을 누르면 한 번만 채워지는 실제 실행으로 넘어간다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Mode = "demo" | "idle" | "run";
type Task = { name: string; value: number; eta: string };
/** 막대 한 줄은 채움 문자 열여섯 개 — 빈 칸과 채운 칸이 같은 격자 칸에 겹쳐 있다 */
const CELLS = "████████████████";
const TASKS: Task[] = [
{ name: "build", value: 75, eta: "0:04" },
{ name: "test", value: 50, eta: "0:11" },
{ name: "docs", value: 25, eta: "0:26" },
];
const ROW_CLASS = [styles.row1, styles.row2, styles.row3];
export default function AsciiProgress({
demo = true,
duration = 2000,
easing = "ease-out",
color = "#2b9d74",
}: AsciiProgressProps) {
const [mode, setMode] = useState<Mode>(demo ? "demo" : "idle");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function runAgain() {
// 미리보기 루프를 끄고 한 번만 채워지는 실제 실행 경로로 넘어간다.
// 클래스를 뗐다가 다음 프레임에 다시 붙여야 같은 애니메이션이 처음부터 다시 돈다
setMode("idle");
requestAnimationFrame(() => { setMode("run"); });
}
const rootCls = [styles.pg, mode === "demo" ? styles.isDemo : "", mode === "run" ? styles.isRun : ""]
.filter(Boolean)
.join(" ");
return (
<main className={styles.stage} style={vars}>
<div className={rootCls}>
<p className={styles.barTop}>
<span className={styles.ico} aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256">
<g fill="currentColor">
<path d="M224 56v144a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V56a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2" />
<path d="M128 128a8 8 0 0 1-3 6.25l-40 32a8 8 0 1 1-10-12.5L107.19 128L75 102.25a8 8 0 1 1 10-12.5l40 32a8 8 0 0 1 3 6.25m48 24h-40a8 8 0 0 0 0 16h40a8 8 0 0 0 0-16m56-96v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16m-16 144V56H40v144z" />
</g>
</svg>
</span>
<span className={styles.title}>작업 세 개</span>
<button className={styles.again} type="button" onClick={runAgain}>다시 실행</button>
</p>
{TASKS.map((task, i) => (
<p className={`${styles.row} ${ROW_CLASS[i]}`} key={task.name}>
<span className={styles.name}>{task.name}</span>
<span
className={styles.meter}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={task.value}
aria-label={task.name}
>
<span className={styles.brk}>[</span>
<span className={styles.cells}>
<span className={styles.track} aria-hidden="true">{CELLS}</span>
<span className={styles.fill} aria-hidden="true">{CELLS}</span>
</span>
<span className={styles.brk}>]</span>
</span>
<span className={styles.pct} aria-hidden="true" />
<span className={styles.eta}>{task.eta}</span>
</p>
))}
</div>
</main>
);
}05 · 문자로 그린 진행 막대 — 측정 누적면적 1.497% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
등록하지 않은 커스텀 속성의 함정
등록하지 않은 커스텀 속성은 문자열이라 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 이 펼치는 후보 격자
Tab 을 누르면 preventDefault 가 초점 이동을 막고 후보가 두 열 격자로 펼쳐집니다. 화살표로 고르는 동안 입력칸의 aria-activedescendant 가 고른 후보의 id 를 가리켜 스크린리더도 같은 줄을 읽고, Enter 는 고른 이름을 입력칸에 넣고 목록을 닫습니다.

<main class="stage">
<div class="tc is-demo" id="tc">
<p class="tc__line">
<span class="tc__sign" aria-hidden="true">$</span>
<input class="tc__input" id="cmd" type="text" value="git check" autocomplete="off" spellcheck="false"
role="combobox" aria-expanded="false" aria-controls="cands" aria-label="명령 입력칸. Tab 을 누르면 후보가 열립니다">
</p>
<p class="tc__hint">Tab 을 누르면 후보가 열립니다</p>
<ul class="tc__cands" id="cands" role="listbox" aria-label="완성 후보">
<li class="tc__cand" id="c0" role="option" aria-selected="true" style="--i: 0">checkout</li>
<li class="tc__cand" id="c1" role="option" aria-selected="false" style="--i: 1">check-attr</li>
<li class="tc__cand" id="c2" role="option" aria-selected="false" style="--i: 2">check-ignore</li>
<li class="tc__cand" id="c3" role="option" aria-selected="false" style="--i: 3">check-mailmap</li>
<li class="tc__cand" id="c4" role="option" aria-selected="false" style="--i: 4">check-ref-format</li>
<li class="tc__cand" id="c5" role="option" aria-selected="false" style="--i: 5">checkout-index</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// Tab 자동완성 후보 — Tab 을 누르면 preventDefault 로 초점 이동을 막고 후보를 격자로 펼친다.
// 후보 목록은 절대배치가 아니라 흐름 안에 둔다 — 띄우면 부모 높이 계산에서 빠져 320px 에서
// scrollHeight 만 넘치는 함정에 걸린다 (237 실측 9개 중 6개).
// 고른 후보는 색뿐 아니라 aria-selected 와 왼쪽 막대로도 표시하고, 입력칸의
// aria-activedescendant 가 그 id 를 가리켜 스크린리더가 같은 줄을 읽는다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $chart-3;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(255, 255, 255, .16);
$dim: rgba(255, 255, 255, .6);
@include stage($stage-ink);
.tc {
display: flex; flex-direction: column; gap: $sp-2;
box-sizing: border-box; width: min(100%, 408px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: rgba(255, 255, 255, .05); font-family: $mono;
}
.tc__line { display: flex; align-items: center; gap: $sp-2; margin: 0; }
.tc__sign { flex: 0 0 auto; color: $color; font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1.4; }
.tc__input {
flex: 1 1 auto; min-width: 0; box-sizing: border-box; padding: $sp-1 $sp-2;
border: 1px solid transparent; border-radius: $r-sm;
background: rgba(255, 255, 255, .06); color: $subject-cream;
font-family: inherit; font-weight: 600; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.4;
}
.tc__input:focus-visible { outline: 2px solid $subject-blue; outline-offset: 1px; }
.tc__hint { margin: 0; color: $dim; font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.3; }
.tc.is-open .tc__hint { visibility: hidden; }
.tc__cands {
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: $sp-1 $sp-2;
margin: 0; padding: 0; list-style: none; visibility: hidden;
}
.tc.is-open .tc__cands { visibility: visible; }
.tc__cand {
position: relative; box-sizing: border-box; padding: $sp-1 $sp-2;
border-radius: $r-xs; color: $dim; cursor: pointer;
font-weight: 600; font-size: clamp(9px, 2.6vw, 11px); line-height: 1.5;
white-space: nowrap; overflow: hidden;
}
// 고른 후보의 왼쪽 막대 — 칸 왼쪽 변에 붙은 표시라 그쪽을 원점으로 쓴다 (blog-demo 규칙 8 의 예외)
.tc__cand::before {
content: ""; position: absolute; left: 0; top: 2px; bottom: 2px; width: 2px;
border-radius: $r-pill; background: $color; transform: scaleY(0); transform-origin: center top;
transition: transform $duration $easing;
}
.tc__cand[aria-selected="true"] { background: rgba(255, 255, 255, .1); color: $subject-cream; }
.tc__cand[aria-selected="true"]::before { transform: scaleY(1); }
// ── 격자 미리보기 루프 (2초)
// 0~16% 닫힌 채로 「git ch」, 17% 에 후보가 컷으로 열리며 여섯 칸이 --i 만큼 늦게 내려앉는다.
// 고름은 40%·68% 에 컷으로 옮겨 간다 — 반투명 교차를 만들지 않으려고 페이드 대신 컷이다 (237 실측)
.tc.is-demo .tc__cands,
.tc.is-demo .tc__hint {
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.tc.is-demo .tc__cands { animation-name: tcOpen; }
.tc.is-demo .tc__hint { animation-name: tcHint; }
@keyframes tcOpen {
0%, 16% { visibility: hidden; }
17%, 100% { visibility: visible; }
}
@keyframes tcHint {
0%, 16% { visibility: visible; }
17%, 100% { visibility: hidden; }
}
// 칸이 내려앉는 건 transform 뿐이다 — opacity 로 띄우면 반투명 중간 프레임이 poster 로 뽑힌다
.tc.is-demo .tc__cand {
animation-name: tcDrop; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 40ms);
}
@keyframes tcDrop {
0%, 17% { transform: translateY(-6px); }
27%, 100% { transform: translateY(0); }
}
.tc.is-demo .tc__cand:nth-child(1) { animation-name: tcDrop, tcPick1; }
.tc.is-demo .tc__cand:nth-child(2) { animation-name: tcDrop, tcPick2; }
.tc.is-demo .tc__cand:nth-child(4) { animation-name: tcDrop, tcPick3; }
.tc.is-demo .tc__cand:nth-child(1),
.tc.is-demo .tc__cand:nth-child(2),
.tc.is-demo .tc__cand:nth-child(4) {
animation-duration: $dur-loop, $dur-loop;
animation-timing-function: $easing, steps(1, end);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
animation-delay: calc(var(--i) * 40ms), 0s;
}
@keyframes tcPick1 {
0%, 39% { background: rgba(255, 255, 255, .1); color: $subject-cream; }
40%, 100% { background: transparent; color: $dim; }
}
@keyframes tcPick2 {
0%, 39% { background: transparent; color: $dim; }
40%, 67% { background: rgba(255, 255, 255, .1); color: $subject-cream; }
68%, 100% { background: transparent; color: $dim; }
}
@keyframes tcPick3 {
0%, 67% { background: transparent; color: $dim; }
68%, 100% { background: rgba(255, 255, 255, .1); color: $subject-cream; }
}
// 왼쪽 막대는 연속 보간이라 steps 짝과 한 요소에 섞지 않고 따로 건다 (266-09 실측)
.tc.is-demo .tc__cand:nth-child(1)::before { animation-name: tcMark1; }
.tc.is-demo .tc__cand:nth-child(2)::before { animation-name: tcMark2; }
.tc.is-demo .tc__cand:nth-child(4)::before { animation-name: tcMark3; }
.tc.is-demo .tc__cand::before {
animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes tcMark1 {
0%, 17% { transform: scaleY(0); }
27%, 39% { transform: scaleY(1); }
40%, 100% { transform: scaleY(0); }
}
@keyframes tcMark2 {
0%, 40% { transform: scaleY(0); }
50%, 67% { transform: scaleY(1); }
68%, 100% { transform: scaleY(0); }
}
@keyframes tcMark3 {
0%, 68% { transform: scaleY(0); }
78%, 100% { transform: scaleY(1); }
}
@media (max-width: 340px) {
.tc { padding: $sp-2; gap: $sp-1; }
.tc__cands { gap: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.tc.is-demo .tc__cands,
.tc.is-demo .tc__hint,
.tc.is-demo .tc__cand,
.tc.is-demo .tc__cand::before { animation: none !important; }
.tc.is-demo .tc__cands { visibility: visible !important; }
.tc.is-demo .tc__hint { visibility: hidden !important; }
.tc__cand::before { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tc {
display: flex;
flex-direction: column;
gap: 8px;
box-sizing: border-box;
width: min(100%, 408px);
padding: 12px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
}
.tc__line {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
}
.tc__sign {
flex: 0 0 auto;
color: #2b9d74;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.4;
}
.tc__input {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
padding: 4px 8px;
border: 1px solid transparent;
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
color: #fff7e6;
font-family: inherit;
font-weight: 600;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.4;
}
.tc__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 1px;
}
.tc__hint {
margin: 0;
color: rgba(255, 255, 255, 0.6);
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.3;
}
.tc.is-open .tc__hint {
visibility: hidden;
}
.tc__cands {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px 8px;
margin: 0;
padding: 0;
list-style: none;
visibility: hidden;
}
.tc.is-open .tc__cands {
visibility: visible;
}
.tc__cand {
position: relative;
box-sizing: border-box;
padding: 4px 8px;
border-radius: 4px;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
font-weight: 600;
font-size: clamp(9px, 2.6vw, 11px);
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
}
.tc__cand::before {
content: "";
position: absolute;
left: 0;
top: 2px;
bottom: 2px;
width: 2px;
border-radius: 999px;
background: #2b9d74;
transform: scaleY(0);
transform-origin: center top;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tc__cand[aria-selected=true] {
background: rgba(255, 255, 255, 0.1);
color: #fff7e6;
}
.tc__cand[aria-selected=true]::before {
transform: scaleY(1);
}
.tc.is-demo .tc__cands,
.tc.is-demo .tc__hint {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tc.is-demo .tc__cands {
animation-name: tcOpen;
}
.tc.is-demo .tc__hint {
animation-name: tcHint;
}
@keyframes tcOpen {
0%, 16% {
visibility: hidden;
}
17%, 100% {
visibility: visible;
}
}
@keyframes tcHint {
0%, 16% {
visibility: visible;
}
17%, 100% {
visibility: hidden;
}
}
.tc.is-demo .tc__cand {
animation-name: tcDrop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: calc(var(--i) * 40ms);
}
@keyframes tcDrop {
0%, 17% {
transform: translateY(-6px);
}
27%, 100% {
transform: translateY(0);
}
}
.tc.is-demo .tc__cand:nth-child(1) {
animation-name: tcDrop, tcPick1;
}
.tc.is-demo .tc__cand:nth-child(2) {
animation-name: tcDrop, tcPick2;
}
.tc.is-demo .tc__cand:nth-child(4) {
animation-name: tcDrop, tcPick3;
}
.tc.is-demo .tc__cand:nth-child(1),
.tc.is-demo .tc__cand:nth-child(2),
.tc.is-demo .tc__cand:nth-child(4) {
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
animation-delay: calc(var(--i) * 40ms), 0s;
}
@keyframes tcPick1 {
0%, 39% {
background: rgba(255, 255, 255, 0.1);
color: #fff7e6;
}
40%, 100% {
background: transparent;
color: rgba(255, 255, 255, 0.6);
}
}
@keyframes tcPick2 {
0%, 39% {
background: transparent;
color: rgba(255, 255, 255, 0.6);
}
40%, 67% {
background: rgba(255, 255, 255, 0.1);
color: #fff7e6;
}
68%, 100% {
background: transparent;
color: rgba(255, 255, 255, 0.6);
}
}
@keyframes tcPick3 {
0%, 67% {
background: transparent;
color: rgba(255, 255, 255, 0.6);
}
68%, 100% {
background: rgba(255, 255, 255, 0.1);
color: #fff7e6;
}
}
.tc.is-demo .tc__cand:nth-child(1)::before {
animation-name: tcMark1;
}
.tc.is-demo .tc__cand:nth-child(2)::before {
animation-name: tcMark2;
}
.tc.is-demo .tc__cand:nth-child(4)::before {
animation-name: tcMark3;
}
.tc.is-demo .tc__cand::before {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes tcMark1 {
0%, 17% {
transform: scaleY(0);
}
27%, 39% {
transform: scaleY(1);
}
40%, 100% {
transform: scaleY(0);
}
}
@keyframes tcMark2 {
0%, 40% {
transform: scaleY(0);
}
50%, 67% {
transform: scaleY(1);
}
68%, 100% {
transform: scaleY(0);
}
}
@keyframes tcMark3 {
0%, 68% {
transform: scaleY(0);
}
78%, 100% {
transform: scaleY(1);
}
}
@media (max-width: 340px) {
.tc {
padding: 8px;
gap: 4px;
}
.tc__cands {
gap: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.tc.is-demo .tc__cands,
.tc.is-demo .tc__hint,
.tc.is-demo .tc__cand,
.tc.is-demo .tc__cand::before {
animation: none !important;
}
.tc.is-demo .tc__cands {
visibility: visible !important;
}
.tc.is-demo .tc__hint {
visibility: hidden !important;
}
.tc__cand::before {
transition: none !important;
}
}var tc = document.getElementById('tc');
var cmd = document.getElementById('cmd');
var cands = Array.prototype.slice.call(document.querySelectorAll('.tc__cand'));
var open = false, at = 0;
function mark(index) {
at = (index + cands.length) % cands.length;
cands.forEach(function (li, i) { li.setAttribute('aria-selected', String(i === at)); });
// 지금 고른 후보를 입력칸이 가리켜야 스크린리더가 같은 줄을 읽는다
cmd.setAttribute('aria-activedescendant', cands[at].id);
}
function openList() {
tc.classList.remove('is-demo');
open = true;
tc.classList.add('is-open');
cmd.setAttribute('aria-expanded', 'true');
// 후보가 모두 나누어 가진 앞글자까지는 셸이 대신 채워 준다
if (cmd.value.indexOf('git check') !== 0) { cmd.value = 'git check'; }
mark(0);
}
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');
}
});
cands.forEach(function (li, i) {
li.addEventListener('click', function () { openList(); mark(i); cmd.focus({ preventScroll: true }); });
});// TabCompletion — vanilla 판(run/294/demo/06-terminal-tab-completion/index.html) 의 React 포트.
// Tab 을 누르면 preventDefault 로 초점 이동을 막고 후보를 격자로 펼친다.
// 고른 후보는 색뿐 아니라 aria-selected 와 왼쪽 막대로도 표시하고, 입력칸의
// aria-activedescendant 가 그 id 를 가리켜 스크린리더가 같은 줄을 읽는다.
import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TabCompletion.module.scss";
export type TabCompletionProps = {
/** 격자 미리보기용 2초 자동 루프. 칸에서 Tab 을 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type IndexVar = CSSProperties & { "--i"?: number };
/** React 이벤트 제네릭이 ambient 에 없어 쓰는 필드만 지역 타입으로 선언한다 */
type KeyEvt = { key: string; shiftKey: boolean; preventDefault(): void };
type InputEvt = { target: { value: string } };
type FocusBox = { focus(options: { preventScroll: boolean }): void };
const CANDS = ["checkout", "check-attr", "check-ignore", "check-mailmap", "check-ref-format", "checkout-index"];
/** 후보가 모두 나누어 가진 앞글자 — 셸이 여기까지는 대신 채워 준다 */
const STEM = "git check";
export default function TabCompletion({
demo = true,
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2b9d74",
}: TabCompletionProps) {
const [live, setLive] = useState(demo);
const [open, setOpen] = useState(false);
const [at, setAt] = useState(0);
const [value, setValue] = useState(STEM);
const inputRef = useRef<FocusBox>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function mark(index: number) {
setAt((index + CANDS.length) % CANDS.length);
}
function openList() {
setLive(false);
setOpen(true);
if (value.indexOf(STEM) !== 0) { setValue(STEM); }
mark(0);
}
function handleKeyDown(e: KeyEvt) {
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();
setValue(`git ${CANDS[at]}`);
setOpen(false);
}
}
function handleCand(index: number) {
openList();
mark(index);
const input = inputRef.current;
if (input) { input.focus({ preventScroll: true }); }
}
const rootCls = [styles.tc, live ? styles.isDemo : "", open ? styles.isOpen : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage} style={vars}>
<div className={rootCls}>
<p className={styles.line}>
<span className={styles.sign} aria-hidden="true">$</span>
<input
className={styles.input}
ref={inputRef}
type="text"
value={value}
onChange={(e: InputEvt) => { setValue(e.target.value); }}
onKeyDown={handleKeyDown}
autoComplete="off"
spellCheck={false}
role="combobox"
aria-expanded={open ? "true" : "false"}
aria-controls="cands"
aria-activedescendant={open ? `c${at}` : undefined}
aria-label="명령 입력칸. Tab 을 누르면 후보가 열립니다"
/>
</p>
<p className={styles.hint}>Tab 을 누르면 후보가 열립니다</p>
<ul className={styles.cands} id="cands" role="listbox" aria-label="완성 후보">
{CANDS.map((cand, i) => {
const iv: IndexVar = { "--i": i };
return (
<li
className={styles.cand}
id={`c${i}`}
key={cand}
role="option"
aria-selected={i === at ? "true" : "false"}
style={iv}
onClick={() => { handleCand(i); }}
>
{cand}
</li>
);
})}
</ul>
</div>
</main>
);
}06 · Tab 자동완성 후보 — 측정 누적면적 11.728% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
실측으로 확인한 초점과 화살표
실측에서 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 이 됐습니다.

<main class="stage">
<div class="dh is-demo" id="dh">
<p class="dh__stat">
<span class="dh__chip dh__chip--add">+6</span>
<span class="dh__chip dh__chip--del">-2</span>
<span class="dh__statTxt">파일 2개가 바뀌었습니다</span>
</p>
<section class="dh__hunk">
<button class="dh__head" type="button" id="head" aria-expanded="true" aria-controls="body" aria-label="바뀐 줄 묶음 접고 펼치기">
<span class="dh__caret" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg></span>
<span class="dh__at">@@ -14,6 +14,8 @@ function boot()</span>
</button>
<div class="dh__body" id="body">
<div class="dh__inner">
<p class="dh__row"><span class="dh__sign" aria-hidden="true"> </span>const app = createApp()</p>
<p class="dh__row dh__row--del"><span class="dh__sign" aria-hidden="true">-</span>app.use(legacyRouter)</p>
<p class="dh__row dh__row--add"><span class="dh__sign" aria-hidden="true">+</span>app.use(router)</p>
<p class="dh__row dh__row--add"><span class="dh__sign" aria-hidden="true">+</span>app.use(store)</p>
<p class="dh__row"><span class="dh__sign" aria-hidden="true"> </span>return app</p>
</div>
</div>
</section>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// diff 출력 접기 — 터미널이 찍어 놓은 diff 텍스트를 묶음(hunk) 단위로 접는다.
// 접기는 grid-template-rows 를 0fr 과 1fr 사이에서 옮겨 높이를 보간한다.
// 안쪽 상자에 min-height: 0 과 overflow: hidden 이 함께 있어야 0fr 에서 실제로 눌린다.
// 밝은 테마 터미널이라 무대는 페이퍼다. 더한 줄·뺀 줄은 배경 색조로 나누고 글자는 잉크로 둔다 —
// 페이퍼 무대(흰 바탕) 위 초록 글자는 4.5:1 을 못 넘긴다($chart-3 는 3.40:1).
// 맨 앞 + - 기호가 색과 같은 정보를 글자로도 준다.
$duration: $dur-base;
$easing: $ease-spring;
$color: $chart-3;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$ink-soft: rgba(23, 20, 26, .72);
$line: rgba(23, 20, 26, .14);
@include stage($stage-paper);
.dh {
display: flex; flex-direction: column; gap: $sp-2;
box-sizing: border-box; width: min(100%, 408px);
padding: $sp-3; border: 1px solid $line; border-radius: $r-card;
background: $stage-paper; font-family: $mono;
}
.dh__stat {
display: flex; align-items: center; gap: $sp-2; margin: 0;
color: $ink-soft; font-weight: 600; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
}
.dh__chip {
box-sizing: border-box; padding: 0 $sp-1; border-radius: $r-xs;
color: $subject-ink; font-weight: 800; font-variant-numeric: tabular-nums;
}
.dh__chip--add { background: rgba(43, 157, 116, .26); }
.dh__chip--del { background: rgba(194, 54, 26, .18); }
.dh__statTxt { flex: 1 1 auto; min-width: 0; }
.dh__hunk { border: 1px solid $line; border-radius: $r-sm; overflow: hidden; }
.dh__head {
display: flex; align-items: center; gap: $sp-2;
box-sizing: border-box; width: 100%; padding: $sp-1 $sp-2;
border: 0; border-radius: 0; background: rgba(23, 20, 26, .05);
color: $ink-soft; cursor: pointer; text-align: left;
font-family: inherit; font-weight: 700; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.6;
}
.dh__head:focus-visible { outline: 2px solid $subject-blue; outline-offset: -2px; }
// 화살표는 스스로 완결한 아이콘이라 제 중심에서 돈다 (blog-demo 규칙 8)
.dh__caret {
display: grid; place-items: center; flex: 0 0 auto; font-size: 12px; color: $subject-ink;
transform: rotate(0deg); transform-origin: 50% 50%;
transition: transform $duration $easing;
}
.dh__head[aria-expanded="true"] .dh__caret { transform: rotate(90deg); }
.dh__at { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; }
// 접히는 칸: 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 {
display: flex; align-items: center; gap: $sp-1; margin: 0;
box-sizing: border-box; padding: 0 $sp-2; color: $subject-ink;
font-weight: 600; font-size: clamp(9px, 2.6vw, 11px); line-height: 1.6;
white-space: nowrap; overflow: hidden;
}
.dh__sign { flex: 0 0 auto; width: 8px; font-weight: 800; }
// 더한 줄의 왼쪽 막대 — 줄 왼쪽 변에 붙은 표시라 그 변을 원점으로 자란다 (blog-demo 규칙 8 의 예외)
.dh__row--add { position: relative; background: rgba(43, 157, 116, .22); }
.dh__row--add::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 2px;
background: $color; transform: scaleY(1); transform-origin: center top;
}
.dh__row--del { background: rgba(194, 54, 26, .14); }
// 뺀 줄 기호도 잉크다 — 뺀 줄 바탕 #f6e3df 위에서 $error 는 4.42:1 로 4.5:1 에 0.08 모자란다 (이 편 실측).
// 줄을 가르는 건 배경 색조와 맨 앞의 - 기호이지 글자색이 아니다
// ── 격자 미리보기 루프 (2초)
// 0~6% 접힌 채로 시작해 6~26% 에 펼쳐지고 그대로 선다.
// 루프 안에서 다시 접지 않는다 — 접히는 쪽이 펼치는 쪽보다 변한 면적이 커서 빈 상자가 poster 로
// 뽑히기 때문이다 (248-09·255 실측). 되돌림은 0% 경계에서만 일어난다.
// 미리보기에서만 펼침을 steps(1, end) 컷으로 바꾼다 — 높이가 보간되는 중간 프레임은 마지막 줄이
// 반쯤 잘린 장면이라 그게 poster 로 뽑힌다. 실제 클릭은 위의 transition 그대로 부드럽게 열린다 (252-07 계열).
// 컷만으로는 움직인 프레임이 모자라 더한 줄의 왼쪽 막대가 34%·48% 에 차례로 자란다 (252 실측)
.dh.is-demo .dh__body {
animation-name: dhFold; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes dhFold {
0%, 6% { grid-template-rows: 0fr; }
7%, 100% { grid-template-rows: 1fr; }
}
.dh.is-demo .dh__caret {
animation-name: dhCaret; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes dhCaret {
0%, 6% { transform: rotate(0deg); }
26%, 100% { transform: rotate(90deg); }
}
.dh.is-demo .dh__row--add:nth-of-type(3)::before { animation-name: dhMark1; }
.dh.is-demo .dh__row--add:nth-of-type(4)::before { animation-name: dhMark2; }
.dh.is-demo .dh__row--add::before {
animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes dhMark1 {
0%, 34% { transform: scaleY(0); }
46%, 100% { transform: scaleY(1); }
}
@keyframes dhMark2 {
0%, 48% { transform: scaleY(0); }
60%, 100% { transform: scaleY(1); }
}
.dh.is-demo .dh__chip {
animation-name: dhChip; animation-duration: $dur-loop; animation-timing-function: $ease-pop;
animation-iteration-count: infinite; animation-fill-mode: backwards;
animation-delay: 0s;
}
.dh.is-demo .dh__chip--del { animation-delay: 100ms; }
// 칩은 스스로 완결한 배지라 제 중심에서 커진다 (blog-demo 규칙 8)
@keyframes dhChip {
0%, 68% { transform: scale(1); }
74% { transform: scale(1.22); }
82%, 100% { transform: scale(1); }
}
@media (max-width: 340px) {
.dh { padding: $sp-2; gap: $sp-1; }
.dh__head, .dh__row { padding-left: $sp-1; padding-right: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.dh.is-demo .dh__body,
.dh.is-demo .dh__caret,
.dh.is-demo .dh__chip,
.dh.is-demo .dh__row--add::before { animation: none !important; }
.dh.is-demo .dh__body { grid-template-rows: 1fr !important; }
.dh.is-demo .dh__caret { transform: rotate(90deg) !important; }
.dh__body, .dh__caret { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dh {
display: flex;
flex-direction: column;
gap: 8px;
box-sizing: border-box;
width: min(100%, 408px);
padding: 12px;
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 16px;
background: #ffffff;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
}
.dh__stat {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
color: rgba(23, 20, 26, 0.72);
font-weight: 600;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
}
.dh__chip {
box-sizing: border-box;
padding: 0 4px;
border-radius: 4px;
color: #17141a;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.dh__chip--add {
background: rgba(43, 157, 116, 0.26);
}
.dh__chip--del {
background: rgba(194, 54, 26, 0.18);
}
.dh__statTxt {
flex: 1 1 auto;
min-width: 0;
}
.dh__hunk {
border: 1px solid rgba(23, 20, 26, 0.14);
border-radius: 8px;
overflow: hidden;
}
.dh__head {
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
width: 100%;
padding: 4px 8px;
border: 0;
border-radius: 0;
background: rgba(23, 20, 26, 0.05);
color: rgba(23, 20, 26, 0.72);
cursor: pointer;
text-align: left;
font-family: inherit;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.6;
}
.dh__head:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.dh__caret {
display: grid;
place-items: center;
flex: 0 0 auto;
font-size: 12px;
color: #17141a;
transform: rotate(0deg);
transform-origin: 50% 50%;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dh__head[aria-expanded=true] .dh__caret {
transform: rotate(90deg);
}
.dh__at {
flex: 1 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
}
.dh__body {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dh__head[aria-expanded=true] + .dh__body {
grid-template-rows: 1fr;
}
.dh__inner {
min-height: 0;
overflow: hidden;
}
.dh__row {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
box-sizing: border-box;
padding: 0 8px;
color: #17141a;
font-weight: 600;
font-size: clamp(9px, 2.6vw, 11px);
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
}
.dh__sign {
flex: 0 0 auto;
width: 8px;
font-weight: 800;
}
.dh__row--add {
position: relative;
background: rgba(43, 157, 116, 0.22);
}
.dh__row--add::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: #2b9d74;
transform: scaleY(1);
transform-origin: center top;
}
.dh__row--del {
background: rgba(194, 54, 26, 0.14);
}
.dh.is-demo .dh__body {
animation-name: dhFold;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes dhFold {
0%, 6% {
grid-template-rows: 0fr;
}
7%, 100% {
grid-template-rows: 1fr;
}
}
.dh.is-demo .dh__caret {
animation-name: dhCaret;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes dhCaret {
0%, 6% {
transform: rotate(0deg);
}
26%, 100% {
transform: rotate(90deg);
}
}
.dh.is-demo .dh__row--add:nth-of-type(3)::before {
animation-name: dhMark1;
}
.dh.is-demo .dh__row--add:nth-of-type(4)::before {
animation-name: dhMark2;
}
.dh.is-demo .dh__row--add::before {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes dhMark1 {
0%, 34% {
transform: scaleY(0);
}
46%, 100% {
transform: scaleY(1);
}
}
@keyframes dhMark2 {
0%, 48% {
transform: scaleY(0);
}
60%, 100% {
transform: scaleY(1);
}
}
.dh.is-demo .dh__chip {
animation-name: dhChip;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
animation-delay: 0s;
}
.dh.is-demo .dh__chip--del {
animation-delay: 100ms;
}
@keyframes dhChip {
0%, 68% {
transform: scale(1);
}
74% {
transform: scale(1.22);
}
82%, 100% {
transform: scale(1);
}
}
@media (max-width: 340px) {
.dh {
padding: 8px;
gap: 4px;
}
.dh__head, .dh__row {
padding-left: 4px;
padding-right: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.dh.is-demo .dh__body,
.dh.is-demo .dh__caret,
.dh.is-demo .dh__chip,
.dh.is-demo .dh__row--add::before {
animation: none !important;
}
.dh.is-demo .dh__body {
grid-template-rows: 1fr !important;
}
.dh.is-demo .dh__caret {
transform: rotate(90deg) !important;
}
.dh__body, .dh__caret {
transition: none !important;
}
}var dh = document.getElementById('dh');
var head = document.getElementById('head');
head.addEventListener('click', function () {
dh.classList.remove('is-demo');
var open = head.getAttribute('aria-expanded') === 'true';
head.setAttribute('aria-expanded', String(!open));
});// DiffFoldOutput — vanilla 판(run/294/demo/07-terminal-git-diff-output/index.html) 의 React 포트.
// 터미널이 찍어 놓은 diff 를 묶음(hunk) 단위로 접는다. 머리를 누르면 grid-template-rows 가
// 0fr 과 1fr 사이를 오가며 높이가 보간된다. 격자 미리보기(isDemo)는 머리를 누르면 꺼진다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DiffFoldOutput.module.scss";
export type DiffFoldOutputProps = {
/** 격자 미리보기용 2초 자동 루프. 묶음 머리를 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const BODY_ID = "dhBody";
// vanilla index.html 의 인라인 아이콘 그대로 — JSX 문법에 맞게 viewBox 만 camelCase 다
function CaretIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg>
);
}
export default function DiffFoldOutput({
demo = true,
duration = 300,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2b9d74",
}: DiffFoldOutputProps) {
const [live, setLive] = useState(demo);
const [open, setOpen] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function handleToggle() {
setLive(false);
setOpen((prev) => !prev);
}
const cls = [styles.root, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage} style={vars}>
<div className={cls}>
<p className={styles.stat}>
<span className={styles.chip + " " + styles.chipAdd}>+6</span>
<span className={styles.chip + " " + styles.chipDel}>-2</span>
<span className={styles.statTxt}>파일 2개가 바뀌었습니다</span>
</p>
<section className={styles.hunk}>
<button
className={styles.head}
type="button"
aria-expanded={open}
aria-controls={BODY_ID}
aria-label="바뀐 줄 묶음 접고 펼치기"
onClick={handleToggle}
>
<span className={styles.caret} aria-hidden="true"><CaretIcon /></span>
<span className={styles.at}>@@ -14,6 +14,8 @@ function boot()</span>
</button>
<div className={styles.body} id={BODY_ID}>
<div className={styles.inner}>
<p className={styles.row}><span className={styles.sign} aria-hidden="true"> </span>const app = createApp()</p>
<p className={styles.row + " " + styles.rowDel}><span className={styles.sign} aria-hidden="true">-</span>app.use(legacyRouter)</p>
<p className={styles.row + " " + styles.rowAdd}><span className={styles.sign} aria-hidden="true">+</span>app.use(router)</p>
<p className={styles.row + " " + styles.rowAdd}><span className={styles.sign} aria-hidden="true">+</span>app.use(store)</p>
<p className={styles.row}><span className={styles.sign} aria-hidden="true"> </span>return app</p>
</div>
</div>
</section>
</div>
</main>
);
}07 · diff 출력 접기 — 측정 누적면적 18.233% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
더한 줄과 뺀 줄을 가르는 것
더한 줄과 뺀 줄은 배경 색조와 맨 앞 기호로 갈립니다.
// 접히는 칸: 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() 한 곳에서 정합니다 — 두 곳에서 자르면 값이 어긋납니다.

<main class="stage">
<div class="wc is-demo" id="wc">
<div class="wc__win" id="win">
<p class="wc__bar">
<span class="wc__dots" aria-hidden="true"><i class="wc__dot wc__dot--x"></i><i class="wc__dot wc__dot--m"></i><i class="wc__dot wc__dot--z"></i></span>
<span class="wc__tabs" role="tablist" aria-label="창 탭">
<button class="wc__tab" type="button" role="tab" aria-selected="true">zsh</button>
<button class="wc__tab" type="button" role="tab" aria-selected="false">logs</button>
</span>
</p>
<div class="wc__body">
<p class="wc__ln wc__ln--cmd">$ npm run dev</p>
<p class="wc__ln">ready on localhost:5173</p>
<p class="wc__ln wc__size" aria-live="polite" aria-label="창 크기"></p>
</div>
<button class="wc__grip" type="button" id="grip" aria-label="창 크기 조절 손잡이"><span aria-hidden="true"></span></button>
</div>
<p class="wc__hint">모서리를 끌어 크기를 바꿔 보세요</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 터미널 창 크기 조절 — 모서리 손잡이를 끌면 창이 커지고 줄어든다.
// setPointerCapture 로 포인터를 손잡이에 묶어 두면 커서가 창 밖으로 나가도 pointermove 가 계속 온다.
// 한계는 JS 가 아니라 CSS clamp() 한 곳에서 정한다 — 두 곳에서 자르면 값이 어긋난다.
// 크기를 바꾸는 건 레이아웃 속성이라 보통은 피하지만(transform·opacity 원칙), 여기서는 창 크기 자체가
// 주제라 @property 로 등록한 --w · --h 를 직접 움직인다. 대신 창 하나만 바뀌고 바깥은 그대로다.
$duration: $dur-base;
$easing: $ease-spring;
$color: $chart-3;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(255, 255, 255, .18);
$dim: rgba(255, 255, 255, .62);
@property --w {
syntax: "<length>";
inherits: false;
initial-value: 300px;
}
@property --h {
syntax: "<length>";
inherits: false;
initial-value: 104px;
}
@property --wn {
syntax: "<integer>";
inherits: true;
initial-value: 300;
}
@property --hn {
syntax: "<integer>";
inherits: true;
initial-value: 104;
}
@include stage($stage-yellow);
.wc { display: flex; flex-direction: column; align-items: center; gap: $sp-2; width: 100%; }
.wc__win {
position: relative; box-sizing: border-box;
width: clamp(200px, var(--w), 100%);
height: clamp(88px, var(--h), 132px);
border: 1px solid $line; border-radius: $r-card; overflow: hidden;
background: $stage-ink; box-shadow: $shadow-raised; font-family: $mono;
}
.wc__bar {
display: flex; align-items: center; gap: $sp-3;
margin: 0; padding: $sp-1 $sp-2; border-bottom: 1px solid $line;
}
.wc__dots { display: inline-flex; gap: $sp-1; flex: 0 0 auto; }
.wc__dot { width: 8px; height: 8px; border-radius: 50%; }
.wc__dot--x { background: $error; }
.wc__dot--m { background: $stage-yellow; }
.wc__dot--z { background: $color; }
.wc__tabs { display: inline-flex; gap: $sp-1; min-width: 0; }
.wc__tab {
box-sizing: border-box; padding: 0 $sp-2; border: 0; border-radius: $r-xs;
background: none; color: $dim; cursor: pointer;
font-family: inherit; font-weight: 700; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.4;
}
.wc__tab[aria-selected="true"] { background: rgba(255, 255, 255, .12); color: $subject-cream; }
.wc__tab:focus-visible { outline: 2px solid $subject-blue; outline-offset: 1px; }
.wc__body { display: flex; flex-direction: column; gap: 0; padding: $sp-2; }
.wc__ln {
margin: 0; color: $subject-cream; font-weight: 500;
font-size: clamp(9px, 2.6vw, 11px); line-height: 1.5; white-space: nowrap; overflow: hidden;
}
.wc__ln--cmd { color: $stage-yellow; font-weight: 700; }
// 크기 표시는 글자가 아니라 등록한 커스텀 속성을 counter() 로 찍는다 — 미리보기와 실제 드래그가 같은 값을 읽는다
.wc__size { color: $color; font-variant-numeric: tabular-nums; }
.wc__size::after { counter-reset: w var(--wn) h var(--hn); content: counter(w) " × " counter(h); }
// 손잡이는 창 오른쪽 아래 모서리에 붙은 요소라 그 모서리를 원점으로 쓴다 (blog-demo 규칙 8 의 예외)
.wc__grip {
position: absolute; right: 0; bottom: 0; width: 18px; height: 18px;
padding: 0; border: 0; border-radius: 0; background: none;
cursor: nwse-resize; touch-action: none;
}
.wc__grip span {
position: absolute; right: 3px; bottom: 3px; width: 10px; height: 10px;
border-right: 2px solid $dim; border-bottom: 2px solid $dim;
transform: scale(1); transform-origin: 100% 100%;
transition: transform $duration $easing, border-color $duration $easing;
}
.wc__grip:hover span, .wc__grip:focus-visible span { border-right-color: $color; border-bottom-color: $color; transform: scale(1.2); }
.wc__grip:focus-visible { outline: 2px solid $subject-blue; outline-offset: -2px; }
.wc__hint { margin: 0; color: $subject-ink; font-family: $mono; font-weight: 700; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.3; }
// ── 격자 미리보기 루프 (2초): 창이 넓어졌다가 88% 안에 원래 크기로 돌아온다.
// 숫자 표시(--wn · --hn)는 같은 퍼센트로 함께 움직여 창 크기와 어긋나지 않는다
.wc.is-demo .wc__win {
animation-name: wcSize; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes wcSize {
0%, 8% { --w: 300px; --h: 104px; --wn: 300; --hn: 104; }
44%, 64% { --w: 380px; --h: 132px; --wn: 380; --hn: 132; }
88%, 100% { --w: 300px; --h: 104px; --wn: 300; --hn: 104; }
}
.wc.is-demo .wc__grip span {
animation-name: wcGrip; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes wcGrip {
0%, 8% { transform: scale(1); border-right-color: $dim; border-bottom-color: $dim; }
20%, 70% { transform: scale(1.2); border-right-color: $color; border-bottom-color: $color; }
88%, 100% { transform: scale(1); border-right-color: $dim; border-bottom-color: $dim; }
}
@media (max-width: 340px) {
.wc__bar { gap: $sp-2; }
.wc__body { padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.wc.is-demo .wc__win,
.wc.is-demo .wc__grip span { animation: none !important; }
.wc__grip span { transition: none !important; }
}@charset "UTF-8";
@property --w {
syntax: "<length>";
inherits: false;
initial-value: 300px;
}
@property --h {
syntax: "<length>";
inherits: false;
initial-value: 104px;
}
@property --wn {
syntax: "<integer>";
inherits: true;
initial-value: 300;
}
@property --hn {
syntax: "<integer>";
inherits: true;
initial-value: 104;
}
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wc {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 100%;
}
.wc__win {
position: relative;
box-sizing: border-box;
width: clamp(200px, var(--w), 100%);
height: clamp(88px, var(--h), 132px);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 16px;
overflow: hidden;
background: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
}
.wc__bar {
display: flex;
align-items: center;
gap: 12px;
margin: 0;
padding: 4px 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.18);
}
.wc__dots {
display: inline-flex;
gap: 4px;
flex: 0 0 auto;
}
.wc__dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.wc__dot--x {
background: #c2361a;
}
.wc__dot--m {
background: #ffd23f;
}
.wc__dot--z {
background: #2b9d74;
}
.wc__tabs {
display: inline-flex;
gap: 4px;
min-width: 0;
}
.wc__tab {
box-sizing: border-box;
padding: 0 8px;
border: 0;
border-radius: 4px;
background: none;
color: rgba(255, 255, 255, 0.62);
cursor: pointer;
font-family: inherit;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.4;
}
.wc__tab[aria-selected=true] {
background: rgba(255, 255, 255, 0.12);
color: #fff7e6;
}
.wc__tab:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 1px;
}
.wc__body {
display: flex;
flex-direction: column;
gap: 0;
padding: 8px;
}
.wc__ln {
margin: 0;
color: #fff7e6;
font-weight: 500;
font-size: clamp(9px, 2.6vw, 11px);
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
}
.wc__ln--cmd {
color: #ffd23f;
font-weight: 700;
}
.wc__size {
color: #2b9d74;
font-variant-numeric: tabular-nums;
}
.wc__size::after {
counter-reset: w var(--wn) h var(--hn);
content: counter(w) " × " counter(h);
}
.wc__grip {
position: absolute;
right: 0;
bottom: 0;
width: 18px;
height: 18px;
padding: 0;
border: 0;
border-radius: 0;
background: none;
cursor: nwse-resize;
touch-action: none;
}
.wc__grip span {
position: absolute;
right: 3px;
bottom: 3px;
width: 10px;
height: 10px;
border-right: 2px solid rgba(255, 255, 255, 0.62);
border-bottom: 2px solid rgba(255, 255, 255, 0.62);
transform: scale(1);
transform-origin: 100% 100%;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), border-color 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.wc__grip:hover span, .wc__grip:focus-visible span {
border-right-color: #2b9d74;
border-bottom-color: #2b9d74;
transform: scale(1.2);
}
.wc__grip:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.wc__hint {
margin: 0;
color: #17141a;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
font-weight: 700;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.3;
}
.wc.is-demo .wc__win {
animation-name: wcSize;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes wcSize {
0%, 8% {
--w: 300px;
--h: 104px;
--wn: 300;
--hn: 104;
}
44%, 64% {
--w: 380px;
--h: 132px;
--wn: 380;
--hn: 132;
}
88%, 100% {
--w: 300px;
--h: 104px;
--wn: 300;
--hn: 104;
}
}
.wc.is-demo .wc__grip span {
animation-name: wcGrip;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes wcGrip {
0%, 8% {
transform: scale(1);
border-right-color: rgba(255, 255, 255, 0.62);
border-bottom-color: rgba(255, 255, 255, 0.62);
}
20%, 70% {
transform: scale(1.2);
border-right-color: #2b9d74;
border-bottom-color: #2b9d74;
}
88%, 100% {
transform: scale(1);
border-right-color: rgba(255, 255, 255, 0.62);
border-bottom-color: rgba(255, 255, 255, 0.62);
}
}
@media (max-width: 340px) {
.wc__bar {
gap: 8px;
}
.wc__body {
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.wc.is-demo .wc__win,
.wc.is-demo .wc__grip span {
animation: none !important;
}
.wc__grip span {
transition: none !important;
}
}var wc = document.getElementById('wc');
var win = document.getElementById('win');
var grip = document.getElementById('grip');
var from = null;
function readOut() {
// 실제로 그려진 크기를 되읽는다 — 한계는 CSS clamp 가 정하므로 JS 가 다시 자르지 않는다
win.style.setProperty('--wn', String(Math.round(win.offsetWidth)));
win.style.setProperty('--hn', String(Math.round(win.offsetHeight)));
}
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();
});
function stop(e) {
if (!from) { return; }
from = null;
if (grip.hasPointerCapture(e.pointerId)) { grip.releasePointerCapture(e.pointerId); }
}
grip.addEventListener('pointerup', stop);
grip.addEventListener('pointercancel', stop);
// 키보드로도 넓힐 수 있어야 한다 — 손잡이에 초점을 두고 화살표를 누른다
grip.addEventListener('keydown', function (e) {
var dx = (e.key === 'ArrowRight') ? 16 : (e.key === 'ArrowLeft') ? -16 : 0;
var dy = (e.key === 'ArrowDown') ? 12 : (e.key === 'ArrowUp') ? -12 : 0;
if (!dx && !dy) { return; }
e.preventDefault();
wc.classList.remove('is-demo');
win.style.setProperty('--w', (win.offsetWidth + dx) + 'px');
win.style.setProperty('--h', (win.offsetHeight + dy) + 'px');
readOut();
});// TerminalWindowResize — vanilla 판(run/294/demo/08-terminal-window-chrome-resize/index.html) 의 React 포트.
// 모서리 손잡이를 끌면 창이 커지고 줄어든다. setPointerCapture 로 포인터를 손잡이에 묶어 두면
// 커서가 창 밖으로 나가도 pointermove 가 계속 온다. 한계는 JS 가 아니라 CSS clamp() 한 곳에서 정한다.
// 크기 표시는 글자가 아니라 등록한 --wn · --hn 을 counter() 로 찍는다.
import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TerminalWindowResize.module.scss";
export type TerminalWindowResizeProps = {
/** 격자 미리보기용 2초 자동 루프. 손잡이를 잡거나 화살표를 누르면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
// props 가 넣는 변수는 창 크기 변수(--w · --h · --wn · --hn)와 이름이 겹치지 않는다
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 이벤트 제네릭이 없는 환경이라 쓰는 필드만 지역 타입으로 선언한다
type PtrEvt = {
clientX: number;
clientY: number;
pointerId: number;
currentTarget: {
setPointerCapture(id: number): void;
releasePointerCapture(id: number): void;
hasPointerCapture(id: number): boolean;
};
};
type KeyEvt = { key: string; preventDefault(): void };
type From = { x: number; y: number; w: number; h: number };
const STEP_X = 16;
const STEP_Y = 12;
/** 실제로 그려진 크기를 되읽는다 — 한계는 CSS clamp 가 정하므로 JS 가 다시 자르지 않는다 */
function readOut(win: HTMLElement) {
win.style.setProperty("--wn", String(Math.round(win.offsetWidth)));
win.style.setProperty("--hn", String(Math.round(win.offsetHeight)));
}
export default function TerminalWindowResize({
demo = true,
duration = 300,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2b9d74",
}: TerminalWindowResizeProps) {
const [live, setLive] = useState(demo);
const winRef = useRef<HTMLDivElement>(null);
const fromBox = useRef<From | null>(null) as Box<From | null>;
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function handlePointerDown(e: PtrEvt) {
const win = winRef.current;
if (!win) { return; }
setLive(false);
e.currentTarget.setPointerCapture(e.pointerId);
fromBox.current = { x: e.clientX, y: e.clientY, w: win.offsetWidth, h: win.offsetHeight };
readOut(win);
}
function handlePointerMove(e: PtrEvt) {
const from = fromBox.current;
const win = winRef.current;
if (!from || !win) { return; }
win.style.setProperty("--w", (from.w + (e.clientX - from.x)) + "px");
win.style.setProperty("--h", (from.h + (e.clientY - from.y)) + "px");
readOut(win);
}
function handleStop(e: PtrEvt) {
if (!fromBox.current) { return; }
fromBox.current = null;
if (e.currentTarget.hasPointerCapture(e.pointerId)) { e.currentTarget.releasePointerCapture(e.pointerId); }
}
// 키보드로도 넓힐 수 있어야 한다 — 손잡이에 초점을 두고 화살표를 누른다
function handleKeyDown(e: KeyEvt) {
const win = winRef.current;
if (!win) { return; }
const dx = (e.key === "ArrowRight") ? STEP_X : (e.key === "ArrowLeft") ? -STEP_X : 0;
const dy = (e.key === "ArrowDown") ? STEP_Y : (e.key === "ArrowUp") ? -STEP_Y : 0;
if (!dx && !dy) { return; }
e.preventDefault();
setLive(false);
win.style.setProperty("--w", (win.offsetWidth + dx) + "px");
win.style.setProperty("--h", (win.offsetHeight + dy) + "px");
readOut(win);
}
const cls = [styles.root, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage} style={vars}>
<div className={cls}>
<div className={styles.win} ref={winRef}>
<p className={styles.bar}>
<span className={styles.dots} aria-hidden="true">
<i className={styles.dot + " " + styles.dotX} />
<i className={styles.dot + " " + styles.dotM} />
<i className={styles.dot + " " + styles.dotZ} />
</span>
<span className={styles.tabs} role="tablist" aria-label="창 탭">
<button className={styles.tab} type="button" role="tab" aria-selected="true">zsh</button>
<button className={styles.tab} type="button" role="tab" aria-selected="false">logs</button>
</span>
</p>
<div className={styles.body}>
<p className={styles.ln + " " + styles.lnCmd}>$ npm run dev</p>
<p className={styles.ln}>ready on localhost:5173</p>
<p className={styles.ln + " " + styles.size} aria-live="polite" aria-label="창 크기" />
</div>
<button
className={styles.grip}
type="button"
aria-label="창 크기 조절 손잡이"
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handleStop}
onPointerCancel={handleStop}
onKeyDown={handleKeyDown}
>
<span aria-hidden="true" />
</button>
</div>
<p className={styles.hint}>모서리를 끌어 크기를 바꿔 보세요</p>
</div>
</main>
);
}08 · 터미널 창 크기 조절 — 측정 누적면적 27.394% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
끌어 본 크기의 실측값
실측에서 오른쪽 아래로 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 로 왼쪽 변에서 자랍니다.

<main class="stage">
<div class="tu is-demo" id="tu" data-step="1">
<p class="tu__head">
<span class="tu__badge" aria-hidden="true"><span class="tu__no"></span> / 3</span>
<span class="tu__slot">
<span class="tu__task" data-step="1">지금 폴더를 봅니다. pwd 를 치고 Enter</span>
<span class="tu__task" data-step="2">숨은 파일까지 봅니다. ls -a 를 치고 Enter</span>
<span class="tu__task" data-step="3">바뀐 파일을 봅니다. git status 를 치고 Enter</span>
</span>
</p>
<p class="tu__rail" role="progressbar" aria-valuemin="0" aria-valuemax="3" aria-valuenow="1" aria-label="진행한 단계">
<span class="tu__fill" aria-hidden="true"></span>
</p>
<form class="tu__win" id="form">
<span class="tu__sign" aria-hidden="true">$</span>
<input class="tu__input" id="ans" type="text" autocomplete="off" spellcheck="false" placeholder="여기에 명령을 치세요" aria-label="명령 입력칸">
<span class="tu__say tu__say--ok" id="ok" aria-live="polite">맞았어요</span>
<span class="tu__say tu__say--no" id="no" aria-live="polite">다시 한 번</span>
</form>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 단계별 튜토리얼 창 — 세 단계가 차례로 과제를 내고, 맞는 명령을 치면 다음 단계로 넘어간다.
// 과제 세 줄은 같은 격자 칸(grid-area: 1 / 1)에 겹쳐 두고 data-step 으로 하나만 보인다.
// 진행 막대는 채움 요소를 scaleX 로 늘린다 — 왼쪽 변에서 자라야 하므로 transform-origin 을 그쪽에 둔다
// (blog-demo 규칙 8 에서 비중심 원점을 써도 되는 「붙는 지점이 있는 요소」).
// 맞음·틀림은 색만이 아니라 글자로도 말한다. 두 칸 다 aria-live="polite" 라 보이게 된 쪽만 읽힌다.
$duration: $dur-base;
$easing: $ease-spring;
$color: $chart-3;
$mono: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
$line: rgba(255, 255, 255, .18);
$dim: rgba(255, 255, 255, .62);
$ink-soft: rgba(23, 20, 26, .72);
// 단계 번호는 글자가 아니라 등록한 커스텀 속성을 counter() 로 찍는다 —
// 미리보기는 CSS 만으로 도는데 번호를 글자로 두면 과제는 2단계인데 배지는 1 로 남는다
@property --step {
syntax: "<integer>";
inherits: true;
initial-value: 1;
}
@include stage($stage-paper);
.tu {
display: flex; flex-direction: column; gap: $sp-2;
box-sizing: border-box; width: min(100%, 408px); font-family: $mono;
}
.tu__head { display: flex; align-items: flex-start; gap: $sp-2; margin: 0; }
.tu__badge {
flex: 0 0 auto; box-sizing: border-box; padding: 0 $sp-2; border-radius: $r-pill;
background: $subject-ink; color: $subject-cream;
font-weight: 800; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.8;
font-variant-numeric: tabular-nums;
}
.tu__slot { display: grid; flex: 1 1 auto; min-width: 0; }
.tu__slot > * { grid-area: 1 / 1; }
.tu__task {
color: $ink-soft; font-weight: 700; font-size: clamp(9px, 2.6vw, 11px); line-height: 1.5;
visibility: hidden;
}
.tu[data-step="2"] { --step: 2; }
.tu[data-step="3"] { --step: 3; }
.tu__no::after { counter-reset: s var(--step); content: counter(s); }
.tu[data-step="1"] .tu__task[data-step="1"],
.tu[data-step="2"] .tu__task[data-step="2"],
.tu[data-step="3"] .tu__task[data-step="3"] { visibility: visible; }
.tu__rail {
margin: 0; height: 4px; border-radius: $r-pill; overflow: hidden;
background: rgba(23, 20, 26, .12);
}
.tu__fill {
display: block; height: 100%; border-radius: $r-pill; background: $color;
transform: scaleX(.3334); transform-origin: left center;
transition: transform $duration $easing;
}
.tu[data-step="2"] .tu__fill { transform: scaleX(.6667); }
.tu[data-step="3"] .tu__fill { transform: scaleX(1); }
.tu__win {
display: flex; align-items: center; gap: $sp-2;
box-sizing: border-box; padding: $sp-2 $sp-3;
border: 1px solid $line; border-radius: $r-card; background: $subject-ink;
}
.tu__sign { flex: 0 0 auto; color: $color; font-weight: 800; font-size: clamp(11px, 3vw, 12px); line-height: 1.4; }
.tu__input {
flex: 1 1 auto; min-width: 0; box-sizing: border-box;
border: 0; background: none; color: $subject-cream;
font-family: inherit; font-weight: 600; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.5;
}
.tu__input::placeholder { color: rgba(255, 255, 255, .46); }
.tu__input:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; border-radius: $r-xs; }
.tu__say {
flex: 0 0 auto; box-sizing: border-box; padding: 0 $sp-2; border-radius: $r-pill;
font-weight: 800; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.8;
transform: scale(.7); transform-origin: 50% 50%; visibility: hidden;
transition: transform $duration $ease-pop;
}
.tu__say--ok { background: $color; color: $stage-ink; }
.tu__say--no { background: $error-soft; color: $subject-ink; }
.tu.is-ok .tu__say--ok, .tu.is-no .tu__say--no { visibility: visible; transform: scale(1); }
// ── 격자 미리보기 루프 (2초)
// 1단계 → 30% 에 2단계 → 62% 에 3단계. 과제 글과 번호는 컷으로 갈고 진행 막대만 연속으로 자란다.
// 되돌림은 0% 경계에서만 일어나 복귀 프레임이 poster 로 뽑히지 않는다 (blog-demo 「복귀 90%」)
.tu.is-demo .tu__task,
.tu.is-demo .tu__badge {
animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
.tu.is-demo .tu__task[data-step="1"] { animation-name: tuTask1; }
.tu.is-demo .tu__task[data-step="2"] { animation-name: tuTask2; }
.tu.is-demo .tu__task[data-step="3"] { animation-name: tuTask3; }
@keyframes tuTask1 {
0%, 29% { visibility: visible; }
30%, 100% { visibility: hidden; }
}
@keyframes tuTask2 {
0%, 29% { visibility: hidden; }
30%, 61% { visibility: visible; }
62%, 100% { visibility: hidden; }
}
@keyframes tuTask3 {
0%, 61% { visibility: hidden; }
62%, 100% { visibility: visible; }
}
.tu.is-demo .tu__badge { animation-name: tuBadge; }
.tu.is-demo {
animation-name: tuStep; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes tuStep {
0%, 29% { --step: 1; }
30%, 61% { --step: 2; }
62%, 100% { --step: 3; }
}
@keyframes tuBadge {
0%, 29% { background: $subject-ink; }
30%, 61% { background: $subject-blue; }
62%, 100% { background: $color; color: $stage-ink; }
}
// 막대만 연속 보간이다 — steps 짝과 한 요소에 섞으면 채움이 컷으로 죽는다 (266-09 실측)
.tu.is-demo .tu__fill {
animation-name: tuFill; animation-duration: $dur-loop; animation-timing-function: $easing;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes tuFill {
0%, 29% { transform: scaleX(.3334); }
42%, 61% { transform: scaleX(.6667); }
74%, 100% { transform: scaleX(1); }
}
.tu.is-demo .tu__say--ok {
animation-name: tuOk; animation-duration: $dur-loop; animation-timing-function: $ease-pop;
animation-iteration-count: infinite; animation-fill-mode: backwards;
}
@keyframes tuOk {
0%, 23% { visibility: hidden; transform: scale(.7); }
24% { visibility: visible; transform: scale(.7); }
30%, 55% { visibility: visible; transform: scale(1); }
56%, 100% { visibility: hidden; transform: scale(1); }
}
@media (max-width: 340px) {
.tu { gap: $sp-1; }
.tu__win { padding-left: $sp-2; padding-right: $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.tu.is-demo,
.tu.is-demo .tu__task,
.tu.is-demo .tu__badge,
.tu.is-demo .tu__fill,
.tu.is-demo .tu__say--ok { animation: none !important; }
.tu.is-demo .tu__task[data-step="1"] { visibility: visible !important; }
.tu.is-demo .tu__task[data-step="2"],
.tu.is-demo .tu__task[data-step="3"] { visibility: hidden !important; }
.tu__fill, .tu__say { transition: none !important; }
}@property --step {
syntax: "<integer>";
inherits: true;
initial-value: 1;
}
html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tu {
display: flex;
flex-direction: column;
gap: 8px;
box-sizing: border-box;
width: min(100%, 408px);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Consolas, "Liberation Mono", monospace;
}
.tu__head {
display: flex;
align-items: flex-start;
gap: 8px;
margin: 0;
}
.tu__badge {
flex: 0 0 auto;
box-sizing: border-box;
padding: 0 8px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 800;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.8;
font-variant-numeric: tabular-nums;
}
.tu__slot {
display: grid;
flex: 1 1 auto;
min-width: 0;
}
.tu__slot > * {
grid-area: 1/1;
}
.tu__task {
color: rgba(23, 20, 26, 0.72);
font-weight: 700;
font-size: clamp(9px, 2.6vw, 11px);
line-height: 1.5;
visibility: hidden;
}
.tu[data-step="2"] {
--step: 2;
}
.tu[data-step="3"] {
--step: 3;
}
.tu__no::after {
counter-reset: s var(--step);
content: counter(s);
}
.tu[data-step="1"] .tu__task[data-step="1"],
.tu[data-step="2"] .tu__task[data-step="2"],
.tu[data-step="3"] .tu__task[data-step="3"] {
visibility: visible;
}
.tu__rail {
margin: 0;
height: 4px;
border-radius: 999px;
overflow: hidden;
background: rgba(23, 20, 26, 0.12);
}
.tu__fill {
display: block;
height: 100%;
border-radius: 999px;
background: #2b9d74;
transform: scaleX(0.3334);
transform-origin: left center;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.tu[data-step="2"] .tu__fill {
transform: scaleX(0.6667);
}
.tu[data-step="3"] .tu__fill {
transform: scaleX(1);
}
.tu__win {
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
padding: 8px 12px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 16px;
background: #17141a;
}
.tu__sign {
flex: 0 0 auto;
color: #2b9d74;
font-weight: 800;
font-size: clamp(11px, 3vw, 12px);
line-height: 1.4;
}
.tu__input {
flex: 1 1 auto;
min-width: 0;
box-sizing: border-box;
border: 0;
background: none;
color: #fff7e6;
font-family: inherit;
font-weight: 600;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.5;
}
.tu__input::placeholder {
color: rgba(255, 255, 255, 0.46);
}
.tu__input:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
border-radius: 4px;
}
.tu__say {
flex: 0 0 auto;
box-sizing: border-box;
padding: 0 8px;
border-radius: 999px;
font-weight: 800;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.8;
transform: scale(0.7);
transform-origin: 50% 50%;
visibility: hidden;
transition: transform 300ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.tu__say--ok {
background: #2b9d74;
color: #17141a;
}
.tu__say--no {
background: #ffe0d8;
color: #17141a;
}
.tu.is-ok .tu__say--ok, .tu.is-no .tu__say--no {
visibility: visible;
transform: scale(1);
}
.tu.is-demo .tu__task,
.tu.is-demo .tu__badge {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.tu.is-demo .tu__task[data-step="1"] {
animation-name: tuTask1;
}
.tu.is-demo .tu__task[data-step="2"] {
animation-name: tuTask2;
}
.tu.is-demo .tu__task[data-step="3"] {
animation-name: tuTask3;
}
@keyframes tuTask1 {
0%, 29% {
visibility: visible;
}
30%, 100% {
visibility: hidden;
}
}
@keyframes tuTask2 {
0%, 29% {
visibility: hidden;
}
30%, 61% {
visibility: visible;
}
62%, 100% {
visibility: hidden;
}
}
@keyframes tuTask3 {
0%, 61% {
visibility: hidden;
}
62%, 100% {
visibility: visible;
}
}
.tu.is-demo .tu__badge {
animation-name: tuBadge;
}
.tu.is-demo {
animation-name: tuStep;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes tuStep {
0%, 29% {
--step: 1;
}
30%, 61% {
--step: 2;
}
62%, 100% {
--step: 3;
}
}
@keyframes tuBadge {
0%, 29% {
background: #17141a;
}
30%, 61% {
background: #2f6df6;
}
62%, 100% {
background: #2b9d74;
color: #17141a;
}
}
.tu.is-demo .tu__fill {
animation-name: tuFill;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes tuFill {
0%, 29% {
transform: scaleX(0.3334);
}
42%, 61% {
transform: scaleX(0.6667);
}
74%, 100% {
transform: scaleX(1);
}
}
.tu.is-demo .tu__say--ok {
animation-name: tuOk;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes tuOk {
0%, 23% {
visibility: hidden;
transform: scale(0.7);
}
24% {
visibility: visible;
transform: scale(0.7);
}
30%, 55% {
visibility: visible;
transform: scale(1);
}
56%, 100% {
visibility: hidden;
transform: scale(1);
}
}
@media (max-width: 340px) {
.tu {
gap: 4px;
}
.tu__win {
padding-left: 8px;
padding-right: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.tu.is-demo,
.tu.is-demo .tu__task,
.tu.is-demo .tu__badge,
.tu.is-demo .tu__fill,
.tu.is-demo .tu__say--ok {
animation: none !important;
}
.tu.is-demo .tu__task[data-step="1"] {
visibility: visible !important;
}
.tu.is-demo .tu__task[data-step="2"],
.tu.is-demo .tu__task[data-step="3"] {
visibility: hidden !important;
}
.tu__fill, .tu__say {
transition: none !important;
}
}var tu = document.getElementById('tu');
var form = document.getElementById('form');
var ans = document.getElementById('ans');
var rail = tu.querySelector('.tu__rail');
var WANT = ['pwd', 'ls -a', 'git status'];
var step = 1;
function show(state) {
tu.classList.remove('is-ok', 'is-no');
if (state) { tu.classList.add(state); }
}
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));
}
});
ans.addEventListener('input', function () { show(''); });// TutorialSteps — vanilla 판(run/294/demo/09-terminal-tutorial-steps/index.html) 의 React 포트.
// 세 단계가 차례로 과제를 내고, 맞는 명령을 치면 다음 단계로 넘어간다.
// 단계 번호는 글자가 아니라 data-step 이 세운 --step 을 counter() 로 찍는다 (module.scss).
// 격자 미리보기(isDemo)는 명령을 한 번 보내면 꺼진다.
import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TutorialSteps.module.scss";
export type TutorialStepsProps = {
/** 격자 미리보기용 2초 자동 루프. 명령을 보내면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// React 이벤트 제네릭이 없는 환경이라 쓰는 필드만 지역 타입으로 선언한다
type FormEvt = { preventDefault(): void };
type ChangeEvt = { target: { value: string } };
type Verdict = "" | "ok" | "no";
const WANT = ["pwd", "ls -a", "git status"];
const TASKS = [
"지금 폴더를 봅니다. pwd 를 치고 Enter",
"숨은 파일까지 봅니다. ls -a 를 치고 Enter",
"바뀐 파일을 봅니다. git status 를 치고 Enter",
];
export default function TutorialSteps({
demo = true,
duration = 300,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2b9d74",
}: TutorialStepsProps) {
const [live, setLive] = useState(demo);
const [step, setStep] = useState(1);
const [verdict, setVerdict] = useState<Verdict>("");
const [value, setValue] = useState("");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function handleSubmit(e: FormEvt) {
e.preventDefault();
setLive(false);
// 사이 공백이 몇 칸이든 같은 명령으로 본다
const typed = value.trim().replace(/\s+/g, " ");
if (typed !== WANT[step - 1]) { setVerdict("no"); return; }
setVerdict("ok");
setValue("");
if (step < WANT.length) { setStep(step + 1); }
}
function handleChange(e: ChangeEvt) {
setValue(e.target.value);
setVerdict("");
}
const cls = [
styles.root,
live ? styles.isDemo : "",
verdict === "ok" ? styles.isOk : "",
verdict === "no" ? styles.isNo : "",
].filter(Boolean).join(" ");
return (
<main className={styles.stage} style={vars}>
<div className={cls} data-step={String(step)}>
<p className={styles.head}>
<span className={styles.badge} aria-hidden="true"><span className={styles.no} />{" / 3"}</span>
<span className={styles.slot}>
{TASKS.map((task, i) => (
<span className={styles.task} data-step={String(i + 1)} key={WANT[i]}>{task}</span>
))}
</span>
</p>
<p
className={styles.rail}
role="progressbar"
aria-valuemin={0}
aria-valuemax={3}
aria-valuenow={step}
aria-label="진행한 단계"
>
<span className={styles.fill} aria-hidden="true" />
</p>
<form className={styles.win} onSubmit={handleSubmit}>
<span className={styles.sign} aria-hidden="true">$</span>
<input
className={styles.input}
type="text"
autoComplete="off"
spellCheck={false}
placeholder="여기에 명령을 치세요"
aria-label="명령 입력칸"
value={value}
onChange={handleChange}
/>
<span className={styles.say + " " + styles.sayOk} role="status">맞았어요</span>
<span className={styles.say + " " + styles.sayNo} role="status">다시 한 번</span>
</form>
</div>
</main>
);
}09 · 단계별 튜토리얼 창 — 측정 누적면적 3.102% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
맞고 틀림을 알리는 방법
맞았는지 틀렸는지는 색만이 아니라 글자로도 알리고 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 을 읽게 해 미리보기와 실제 드래그가 한 값을 씁니다. 09 의 과제 문장처럼 창 안의 글자를 언어별로 갈아 끼울 때 빠진 키를 잡아내는 일은 언어팩 키 누락 검수가 검사 쪽에서 맡습니다. 여기까지 손본 결과가 그대로 담긴 아홉 벌 원본을 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가지의 접기·묶기 방식이 참고가 됩니다.









