ターミナル風 UI 9選 — コピペで動くCLIパーツ
ターミナル風の UI とは、Web ページの中に黒いコマンド画面のような部品を組み込んだ見た目のことです。ここに並ぶ9つは、インストール行のコピー箱からログ窓・文字で描く進捗バー・サイズを変えられる窓・チュートリアル窓まで、貼り付ければそのまま動きます。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 インストールコマンドのコピー箱
- 02 コマンド実行と出力行
- 03 プロンプトテーマの切り替え
- 04 ログ追従ウィンドウ
- 05 文字で描く進捗バー
- 06 Tab 補完の候補
- 07 diff 出力の折りたたみ
- 08 サイズ変更できるターミナル窓
- 09 ステップ式チュートリアル窓
並べた順番は技法の難しさではなく、読者が開発ドキュメントを眺めるときにこれらの部品と出会う順です。最初はいちばん先に手が伸びるもの — インストール行を自分の道具の文法に切り替えてコピーします(01)。次は「で、何が出るのか」を見せるもの2つ — コマンドを打って出力が積まれるのを見て(02)、プロンプトの形を選んでみます(03)。続いて放っておいても流れるもの2つ — ログが最終行を追いかけ(04)、3つのジョブの進捗が文字で描いたバーで埋まります(05)。ふたたび手が入るのが Tab 補完(06)と、読む側が折りたためる diff 出力(07)です。最後の2つは窓そのものを扱うもの — 角をドラッグして窓を広げ(08)、3ステップをたどってコマンドを覚えます(09)。コードブロック自体のコピーボタン・行番号・言語タブはコードブロック UI 9選が扱い、文字が1つずつ打たれる効果だけが欲しいならCSS タイピング アニメーション 9選のほうが近道です。キーキャップ表記やショートカット案内はショートカットキー 表示 UI 9選にあります。ステージ色はインク6・ペーパー2・イエロー1 にしました — ターミナルは暗い地が定石なので番号順のローテーションには従わず、明るいテーマのターミナル(07)とページの上に載る窓(08・09)だけを分けています。同じ枠に明るい顔で収まる隣人は天気 ウィジェットで、この窓の中でコマンドではなくコードを直させる形にするとhtml css エディタ UIの側へ移ります。
01インストールコマンドのコピー箱
タブごとに変わるインストール行
npm・pnpm・yarn のタブを押すと、同じパッケージのインストール行がその道具の文法に切り替わります。3行は同じグリッドのマスに重ねて 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
プレビューで測った動き
プレビューのループはタブのカットとボタンの塗りを混ぜ、累積面積 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 を一緒に付けないと、遅延のあいだに完成後の行が0フレーム目で一度だけちらつきます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
スピナーが押し上げたフレーム
スピナーが回り続けるおかげで動いたフレームは 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 に選ばれる
@keyframes rnRise {
0% { opacity: 0; }
6%, 100% { opacity: 1; }
}
03プロンプトテーマの切り替え
プロンプトの形が決める ターミナル UI の第一印象
同じ一行プロンプトを標準・波・セグメントの3つの形に差し替えます。セグメントの折れた継ぎ目は三角形を別に重ねるのではなく、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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
選んだ形の伝え方と測定値
選んだ形は 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
色に頼らないログレベル
ログレベルは色だけでなく行頭の 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文字で描く進捗バー
塗り文字16個で描くバー
バーは図形ではなく塗り文字16個で描き、塗った側だけを 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">ジョブ3件</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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
登録していないカスタムプロパティの落とし穴
登録していないカスタムプロパティは文字列なので、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 が開く2列の候補
Tab を押すと preventDefault がフォーカス移動を止め、候補が2列のグリッドで開きます。矢印キーで選ぶあいだは入力欄の 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
フォーカスと矢印キーの実測
実測では Tab を押してもフォーカスは入力欄(cmd)に残り、矢印キーを2回押すと 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
追加行と削除行の見分け
追加行と削除行は背景の色味と行頭の記号で見分けます。
// 折りたたむマス: 0fr でつぶれるには内側に min-height: 0 が要る
.dh__body {
display: grid; grid-template-rows: 0fr;
transition: grid-template-rows $duration $easing;
}
.dh__head[aria-expanded="true"] + .dh__body { grid-template-rows: 1fr; }
.dh__inner { min-height: 0; overflow: hidden; }
.dh__row--add { position: relative; background: rgba(43, 157, 116, .22); }
.dh__row--del { background: rgba(194, 54, 26, .14); }
08サイズ変更できるターミナル窓
コンソール UI では窓そのものも部品になる
角のつまみをドラッグすると窓が大きくなったり小さくなったりします。setPointerCapture がポインタをつまみに結び付けるのでカーソルが窓の外に出ても pointermove は途切れず、限界は JS ではなく CSS の clamp() 一か所が決めます — 二か所で切ると値がずれていきます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
ドラッグして測った実測値
実測では右下へ 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ステップ式チュートリアル窓
3ステップの課題
3つのステップが順に課題を出し、正しいコマンドを打つと次へ進みます。打った文字は前後の空白を落とし、語間の空白を1つに詰めてから比べるので 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
正誤を伝える二重の手がかり
正解か不正解かは色だけでなく文字でも伝え、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 にしています。画面文字52か所の実測比率表は run/294/_probe/_대비실측.json に、直す前の値と測り方は run/294/_수리전실측.json に残しました。
代表画像が違うフレームから選ばれるとき
2つめは、代表画像がいつも最も大きく変わったフレームから選ばれることです。02 の出力行を実使用どおり opacity で浮かせたところ、24フレームのうち3行目だけが半透明の場面が最大の変化になり、代表画像がいつも「半分だけ描かれたターミナル」になりました。行の登場を steps(1, end) のカットに替えると、同じ測定で完了表示まで写った一枚が代表になりました。07 はもっと厄介でした — ループの中で変更ブロックをまた折りたたむと、閉じる側のほうが開く側より変わる面積が大きく、空の箱が代表画像を占めてしまいました(累積面積 25.92%・動いたフレーム 8/23)。ループから折りたたみを外し、足りないフレームは追加行の左バーとチップの跳ねで埋めて 18.23%・13/23 になりました。実際のクリックは今も transition でなめらかに閉じます。
文字のまま置いた数字がずれる
3つめは、プレビューが CSS だけで回るため、数字を文字のまま置くとずれることです。09 のステップ番号を <span>1</span> と書いていたら、課題の文は2つめに進んだのに番号は1のまま止まっていました。番号を @property で登録した --step と counter() に替えて課題と同じパーセントで進むようにし、08 の窓サイズ表示も同じやり方で --wn・--hn を読ませ、プレビューと実際のドラッグが一つの値を使うようにしました。09の課題文のように窓の中の文字を言語ごとに差し替えるとき、抜けた鍵を見つける仕事は言語パックJSONの検証が検査の側で受け持ちます。ここまで直した結果がそのまま入った9組の原本を zip にまとめてあり、その zip を解凍するパスワードは <span class="pw-inline" id="pw">n85asnxb</span> です。
3つのバリエーション
| 名前 | 変えた値 | 感じ |
|---|---|---|
| 20マスのバー | 05 塗り文字16個 → 20個、steps(12, end) → steps(15, end) |
目盛りが細かくなり、同じ75%でも進み具合を細かく読めます |
| ゆっくりログ | 04 setInterval(append, 700) → 1200 |
行がゆっくり増え、流れる文字を目で追えます |
| 深い窓 | 08 height: clamp(88px, var(--h), 132px) → clamp(88px, var(--h), 180px) |
タイトル行の下に 139px の余地ができ、出力行が8行入ります |
AIプロンプト
バイブコーディングでターミナル部品を新しく作るときは、見えるもの・動き・禁止の3つを一度に決め打ちすると書き直しの回数が減ります。下は 05 の文字進捗バーを作るときに使った断片です。
文字で描く CLI 進捗バーを作れ。
見えるもの: 暗いカードの中にジョブ3行。各行は名前、角かっこ内の塗りマス16個、パーセント、残り時間。
動き: 塗った側だけを clip-path inset で切り、steps(マス数, end) で刻んで一歩が一文字になるようにせよ。
パーセントの数字は @property で登録した整数カスタムプロパティと counter() で打ち、バーと同じ歩幅にせよ。
制約: 数字の欄は font-variant-numeric: tabular-nums で幅を固定。周期は2秒の約数にせよ。
禁止: バーを div の幅で描くこと。パーセントを JS で毎フレーム書き直すこと。
steps(マス数, end) を linear に替えるとバーが文字の途中で半マスずつ切れ、文字のバーには見えなくなります。動きがぬるっとするときは、まずこの一語を見てください。
アクセシビリティ
動きを減らしたときに残る一枚
画面の動きを減らす prefers-reduced-motion: reduce の設定では9つのアニメーションをすべて止め、何が入っているか読み取れる一枚を残します。01 は npm のタブが選ばれた状態で、02 は4行が出そろい完了表示が点いた画面で止まります。03 は標準プロンプトだけを表示し、04 は流れないログの行で、05 は3本のバーが目標値に立ったまま、06 は候補が開いた状態で止まります。07 はブロックを開いたままにし、08 は最初の大きさの窓で、09 は1ステップ目の課題で止まります。
状態を言葉で伝える属性
状態を言葉でも伝えるため、01 のステータス行と 09 の判定チップは aria-live、02 の出力欄と 04 のログ欄は role="log"、03・08 の選択は aria-pressed・aria-selected、06 の入力欄は aria-activedescendant、07 の見出しは aria-expanded、05・09 の進捗は role="progressbar" と aria-valuenow で書いています。04 のログレベルは色だけに任せず行頭の文字を添え、07 の追加行・削除行も背景の色味と +・- の記号を並べます。マウスを使わずキーボードだけで操作する人のために、08 のつまみはフォーカスを受けて矢印キーでも窓を広げられます — 実測では右矢印を1回押すと幅が 360 から 376 に伸びました。06 の Tab はその欄がフォーカスを持っているあいだだけ横取りするので、一覧を閉じれば Tab は元どおり次の要素へ移ります。
FAQ
navigator.clipboard はどのブラウザまで使えますか
caniuse を2026年9月に見直した値で全体 96.39% です — Chrome 66、Edge 79、Firefox 63、Safari 13.1(iOS 13.4)以上で使えます。そのため 01 は navigator.clipboard があるかを先に見て、無ければコピーせずに完了表示だけを点ける経路へ切り替えます。なおこの API はクリックなどのユーザー操作の中でしか許されないので、ページが開いた直後に呼ぶと拒否されます。仕様は MDN Clipboard.writeText にあります。
@property なしでパーセントの数字を動かせませんか
登録していないカスタムプロパティはブラウザが文字列として扱うので補間されず、0 から 75 へそのまま飛びます。@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選の折りたたみ・まとめ方が参考になります。









