Terminal UI Design: 9 Copy-Paste CLI Parts
Terminal UI design is the craft of putting a command-line window inside a web page.
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
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Install command copy box
- 02 Command run and output lines
- 03 Prompt theme switcher
- 04 Log follow window
- 05 Progress bars drawn with characters
- 06 Tab completion candidates
- 07 Folding diff output
- 08 Resizable terminal window
- 09 Step-by-step tutorial shell
The order follows how a reader meets these parts while skimming developer docs, not how hard they are to build. The opening part is where a hand lands first: rewrite the install line into your own package manager's syntax and copy it (01). Then two that answer "so what does it print?" — run a command and watch output stack up (02), and try on a prompt shape (03). Then two that move on their own while you read — a log chasing its last line (04), and three jobs filling bars drawn out of characters (05). The hand comes back for Tab completion (06), and then the eye does the work, folding diff output (07). The last two are about the window itself: drag a corner to grow it (08), and walk three steps of a guided shell (09). Copy buttons, line numbers, and language tabs that belong to a code block are covered in 9 Code Block UI Patterns; if all you want is letters appearing one at a time, 9 CSS Typing Animation is closer. Keycap notation and shortcut hints live in Keyboard Shortcuts UI Design. Stage colors are six ink, two paper, and one yellow — a terminal belongs on a dark ground, so this set skips the usual rotation and only splits off the light-theme terminal (07) and the windows that sit on a page (08, 09). The bright-faced neighbor for the same slot is weather widget design, and letting people edit code instead of typing commands inside this window moves you to code editor JavaScript.
01Install command copy box
Tabs that rewrite the install line
Tap npm, pnpm, or yarn and the install line rewrites itself in that tool's syntax. The three lines sit stacked in one grid cell and are chosen with visibility alone, so the row height never shifts when the syntax changes, and the copy button really writes to the clipboard through navigator.clipboard.

<main class="stage">
<div class="ic is-demo" id="ic" data-mgr="npm">
<div class="ic__tabs" role="tablist" aria-label="Package manager">
<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">Copy command</span>
</button>
</div>
<p class="ic__note" id="note" aria-live="polite">Copied to your clipboard</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 = 'Copied';
clearTimeout(doneTimer);
// 알림을 지웠다가 다시 넣어야 같은 문장이라도 aria-live 가 한 번 더 읽는다
note.textContent = '';
doneTimer = setTimeout(function () {
note.textContent = 'Copied to your clipboard';
doneTimer = setTimeout(function () { ic.classList.remove('is-done'); word.textContent = 'Copy command'; }, 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 · Install command copy box — Measured area 3.313% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
What the preview loop measured
The preview loop mixes tab cuts with a button fill and measured 3.31% changed area over 11 of 23 frames.
copyBtn.addEventListener('click', function () {
ic.classList.remove('is-demo');
var text = currentCommand();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(showDone, showDone);
} else {
showDone();
}
});
02Command run and output lines
Output that stacks as you type
Type a command, press Enter, and the output lines stack up one by one; the summary line only prints after the spinner has turned into a done mark. The lines are staggered with animation-delay, and without animation-fill-mode: backwards alongside it, the finished line flashes once at frame zero during the delay.

<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">Build terminal</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="Type a command, then Enter" aria-label="Command to run">
</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 · Command run and output lines — Measured area 1.423% · moving frames 17/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why the spinner lifts the frame count
Because the spinner keeps turning, the movement count climbed to 17 of 23 frames.
.rn.is-demo .rn__line {
animation-name: rnRise; animation-duration: $dur-loop; animation-timing-function: steps(1, end);
animation-iteration-count: infinite; animation-fill-mode: backwards;
// each row starts --i later. the cycle stays 2s so it never drifts from one gif turn
animation-delay: calc(var(--i) * 80ms);
}
// rows cut in, they do not fade — a half-transparent middle frame is the one that becomes the poster
@keyframes rnRise {
0% { opacity: 0; }
6%, 100% { opacity: 1; }
}
03Prompt theme switcher
Prompt shape is the cheapest win in cli ui design
One prompt line, swapped between three shapes: plain, wave, and segment. The angled seam between segments is not a stacked triangle — clip-path shaves the right edge and the next segment bites into that notch with margin-left: -8px.

<main class="stage">
<div class="pt is-demo" id="pt" data-shape="plain">
<div class="pt__pick" role="group" aria-label="Prompt shape">
<button class="pt__btn" type="button" data-shape="plain" aria-pressed="true">Plain<span class="pt__bar" aria-hidden="true"></span></button>
<button class="pt__btn" type="button" data-shape="wave" aria-pressed="false">Wave<span class="pt__bar" aria-hidden="true"></span></button>
<button class="pt__btn" type="button" data-shape="seg" aria-pressed="false">Segment<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 · Prompt theme switcher — Measured area 3.626% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
Announcing the choice, and the numbers
The chosen shape is announced with aria-pressed, and the preview loop measured 3.63% changed area at a frame-to-frame intensity of 112.4.
.pt__seg {
display: inline-flex; align-items: center;
box-sizing: border-box; height: 22px; padding: 0 $sp-3 0 $sp-2;
color: $stage-ink; font-weight: 700;
clip-path: polygon(0 0, calc(100% - #{$notch}) 0, 100% 50%, calc(100% - #{$notch}) 100%, 0 100%);
}
.pt__seg--a { background: $subject-sky; padding-left: $sp-2; }
.pt__seg--b { background: $stage-yellow; margin-left: -$notch; padding-left: $sp-3; }
.pt__seg--c { background: $color; margin-left: -$notch; padding-left: $sp-3; padding-right: $sp-2; }
04Log follow window
A pane that chases its last line
Every new row pushes scrollTop up to scrollHeight, so the pane keeps chasing the last line. Switch following off and rows keep arriving while the view stays where you were reading; switch it back on and it snaps to the bottom — when measured, scrollTop held at 0 while it was off and read 90, the exact bottom, once it was on again.

<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">Deploy log</span>
<button class="lg__follow" type="button" id="follow" aria-pressed="true">Follow</button>
</p>
<div class="lg__box" id="box" role="log" aria-live="polite" tabindex="0" aria-label="Deploy log lines">
<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 · Log follow window — Measured area 12.09% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Levels told apart without color
Levels are told apart by the leading INFO, WARN, and ERR text, not by color alone.
function append() {
var item = FEED[at % FEED.length];
at += 1;
var p = document.createElement('p');
p.className = 'lg__row is-new' + (CLS[item[0]] || '');
p.innerHTML = '<span class="lg__tag"></span>';
p.querySelector('.lg__tag').textContent = item[0];
p.appendChild(document.createTextNode(item[1]));
track.appendChild(p);
while (track.children.length > 40) { track.removeChild(track.firstChild); }
// push to the bottom only while following is on — off, it stays where you were reading
if (follow.getAttribute('aria-pressed') === 'true') { box.scrollTop = box.scrollHeight; }
}
05Progress bars drawn with characters
Sixteen fill characters instead of a shape
The bar is not a shape but sixteen fill characters, and only the filled copy is cut open with clip-path: inset. Put steps(12, end) on that cut and one step is exactly one glyph, so the bar can only grow character by character, while the percent number comes from a --p registered with @property and printed by counter() on the same step.

<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">Three jobs</span>
<button class="pg__again" type="button" id="again">Run 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 · Progress bars drawn with characters — Measured area 1.497% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
Why the property has to be registered
An unregistered custom property is just a string, so it would jump straight from 0 to the target.
@property --p {
syntax: "<integer>";
inherits: true;
initial-value: 0;
}
.pg__pct::after { counter-reset: p var(--p); content: counter(p) "%"; }
.pg.is-demo .pg__row--1, .pg.is-run .pg__row--1 { animation-name: pgNum1; animation-timing-function: steps(12, end); }
@keyframes pgNum1 {
0% { --p: 0; }
80%, 100% { --p: 75; }
}
@keyframes pgFill1 {
0% { clip-path: inset(0 100% 0 0); }
80%, 100% { clip-path: inset(0 25% 0 0); }
}
06Tab completion candidates
Tab opens a two-column grid
Press Tab and preventDefault holds focus in place while the candidates open as a two-column grid. As arrows move the choice, aria-activedescendant on the input points to the current candidate's id so a screen reader reads the same row, and Enter drops the chosen name into the input and closes the list.

<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="Command input. Press Tab to open the candidates">
</p>
<p class="tc__hint">Press Tab to open the candidates</p>
<ul class="tc__cands" id="cands" role="listbox" aria-label="Completion candidates">
<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 completion candidates — Measured area 11.728% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
What the focus measurement showed
When measured, Tab left focus on the input (cmd), and two arrow presses walked the pointer from c0 to 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');
}
});
07Folding diff output
Folding one hunk header at a time
This folds diff text the terminal has already printed, one hunk header at a time. The fold slides grid-template-rows between 0fr and 1fr to interpolate height, and the inner box needs both min-height: 0 and overflow: hidden before 0fr actually squeezes it — when measured, an inner box 88px tall when open went to 0px when folded.

<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 files changed</span>
</p>
<section class="dh__hunk">
<button class="dh__head" type="button" id="head" aria-expanded="true" aria-controls="body" aria-label="Fold or unfold this changed block">
<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 · Folding diff output — Measured area 18.233% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · See the password
Added and removed rows
Added and removed lines are told apart by the row tint and the leading sign.
// the folding cell: 0fr squeezes only if the inner box carries min-height: 0
.dh__body {
display: grid; grid-template-rows: 0fr;
transition: grid-template-rows $duration $easing;
}
.dh__head[aria-expanded="true"] + .dh__body { grid-template-rows: 1fr; }
.dh__inner { min-height: 0; overflow: hidden; }
.dh__row--add { position: relative; background: rgba(43, 157, 116, .22); }
.dh__row--del { background: rgba(194, 54, 26, .14); }
08Resizable terminal window
The window itself, in command line ui design
Drag the corner grip and the window grows or shrinks. setPointerCapture ties the pointer to the grip so pointermove keeps arriving even when the cursor leaves the window, and the limits live in one place — CSS clamp() — because clamping in two places lets the numbers drift apart.

<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="Window tabs">
<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="Window size"></p>
</div>
<button class="wc__grip" type="button" id="grip" aria-label="Window resize grip"><span aria-hidden="true"></span></button>
</div>
<p class="wc__hint">Drag the corner to resize</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 · Resizable terminal window — Measured area 27.394% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password
The numbers from a real drag
When measured, a 60 by 20 drag toward the bottom right took the window from 300 by 104 to 360 by 124, and the readout inside it showed the same pair.
grip.addEventListener('pointerdown', function (e) {
wc.classList.remove('is-demo');
grip.setPointerCapture(e.pointerId);
from = { x: e.clientX, y: e.clientY, w: win.offsetWidth, h: win.offsetHeight };
readOut();
});
grip.addEventListener('pointermove', function (e) {
if (!from) { return; }
win.style.setProperty('--w', (from.w + (e.clientX - from.x)) + 'px');
win.style.setProperty('--h', (from.h + (e.clientY - from.y)) + 'px');
readOut();
});
09Step-by-step tutorial shell
Three steps, one task at a time
Three steps hand out one task at a time, and the right command moves you on. What you typed is trimmed and collapsed to single spaces before it is compared, so ls -a passes too, and the progress bar grows from its left edge with scaleX.

<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">See where you are. Type pwd and press Enter</span>
<span class="tu__task" data-step="2">List hidden files too. Type ls -a and press Enter</span>
<span class="tu__task" data-step="3">See what changed. Type git status and press Enter</span>
</span>
</p>
<p class="tu__rail" role="progressbar" aria-valuemin="0" aria-valuemax="3" aria-valuenow="1" aria-label="Steps completed">
<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="Type the command here" aria-label="Command input">
<span class="tu__say tu__say--ok" id="ok" aria-live="polite">Correct</span>
<span class="tu__say tu__say--no" id="no" aria-live="polite">Try again</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 · Step-by-step tutorial shell — Measured area 3.102% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
Saying right or wrong in words
Right or wrong is stated in words as well as in color, and announced once through aria-live.
form.addEventListener('submit', function (e) {
e.preventDefault();
tu.classList.remove('is-demo');
// however many spaces sit between the words, read it as the same command
var typed = ans.value.trim().replace(/\s+/g, ' ');
if (typed !== WANT[step - 1]) { show('is-no'); return; }
show('is-ok');
ans.value = '';
if (step < WANT.length) {
step += 1;
tu.setAttribute('data-step', String(step));
rail.setAttribute('aria-valuenow', String(step));
}
});
Where it breaks — the trap
Contrast that breaks on a dark stage
The longest fight here was terminal colors falling short of the body-text ratio on a dark stage. The blue, green, and red everyone pictures when they think of a terminal sit right on the edge. The path text in 03 started as #2f6df6, which is 3.57:1 on a #232025 ground and short of the 4.5:1 body minimum; switching to sky #6ec8ff took it to 8.74:1. The removed-line sign in 07 failed in the other direction — #c2361a on a bright #f6e3df row measured 4.42:1, missing by 0.08, so the sign went back to ink at 14.74:1 and the job of separating rows was left to the tint and the leading -. The empty glyphs in 05 sat at 1.79:1 with rgba(255, 255, 255, .18), which hid where the bar ended, and .38 brought them to 3.51:1. The measured table for all fifty-two pieces of on-screen text is in run/294/_probe/_대비실측.json, and the before values with the method are in run/294/_수리전실측.json.
When the poster picks the wrong frame
The second is that the poster image is always taken from the frame that changed most. Letting the output lines in 02 rise on opacity, the way they do in real use, made the biggest frame the one where only the third line is half transparent, so the poster was forever a half-drawn terminal. Cutting the line entrance with steps(1, end) handed the same measurement a frame that reaches the done mark. Item 07 was worse: folding the hunk back inside the loop made the closing side change more area than the opening side, so an empty box owned the poster at 25.92% changed area over 8 of 23 frames. Dropping the fold-back from the loop and filling the missing frames with the left bar on added rows and a pop on the count chips gave 18.23% over 13 of 23. A real click still folds smoothly through transition.
Numbers left as text drift out of step
The third is that the preview runs on CSS alone, so any number left as text drifts out of step. The step badge in 09 was a plain <span>1</span>, and while the task text moved on to step two, the badge stayed at one. Swapping the badge for a --step registered with @property and printed by counter() advances it at the same keyframe percentage as the task, and the size readout in 08 reads --wn and --hn the same way, so the preview and a real drag share one value. Catching a missing key when the words inside the window — the task lines in 09, for instance — get swapped per language is handled on the inspection side by i18n JSON checks. The nine originals with all of this already fixed are zipped up, and the password that opens the archive is <span class="pw-inline" id="pw">n85asnxb</span>.
Three variations
| Name | Changed value | Feel |
|---|---|---|
| Twenty-cell bar | 05 sixteen fill characters to twenty, steps(12, end) to steps(15, end) |
Finer notches, so the same 75% reads as a denser climb |
| Slow log | 04 setInterval(append, 700) to 1200 |
Rows arrive slowly enough to read the stream as it goes by |
| Deeper window | 08 height: clamp(88px, var(--h), 132px) to clamp(88px, var(--h), 180px) |
A taller window: 139px of room under the title bar, which is eight output rows |
AI prompt
When you build a new terminal part with vibe coding, nailing down what shows, what moves, and what is banned in one go cuts the number of rewrites. Here is the fragment used for the character progress bars in 05.
Build a CLI progress bar drawn out of characters.
Shows: a dark card with three job rows; each row is a name, sixteen fill cells inside brackets, a percent, and an ETA.
Moves: cut only the filled copy with clip-path inset and step it with steps(cells, end) so one step equals one glyph.
Print the percent from an integer custom property registered with @property plus counter(), on that same step.
Constraints: lock the number columns with font-variant-numeric: tabular-nums; the cycle must divide two seconds.
Banned: drawing the bar as a div width; rewriting the percent from JS on every frame.
Swap steps(cells, end) for linear and the bar starts cutting glyphs in half, which stops it from reading as characters at all — so when the result feels slippery, that is the one word to check first.
Accessibility
One frame that still says what is on
Under prefers-reduced-motion: reduce, all nine animations stop and each part keeps one frame that still says what is switched on. 01 rests with the npm tab chosen; 02 rests with all four lines printed and the done mark lit. 03 shows only the plain prompt, 04 shows log rows that do not flow, 05 leaves the three bars at their target values, and 06 rests with the candidates open. 07 stays unfolded, 08 sits at its starting size, and 09 rests on the first task.
Putting state into words
To put state into words as well, the status line in 01 and the verdict chips in 09 use aria-live, the output pane in 02 and the log pane in 04 use role="log", the choices in 03 and 08 use aria-pressed and aria-selected, the input in 06 uses aria-activedescendant, the hunk header in 07 uses aria-expanded, and progress in 05 and 09 is written as role="progressbar" with aria-valuenow. Levels in 04 are never carried by color alone — the leading word rides along with it — and the added and removed rows in 07 pair their tint with a + or -. For keyboard-only users, the grip in 08 takes focus and the arrow keys resize the window: when measured, one press of the right arrow took the width from 360 to 376. Tab in 06 is only intercepted while that input holds focus, so once the list closes, Tab moves on to the next element as usual.
FAQ
Which browsers support navigator.clipboard?
Rechecked on caniuse in September 2026, it is 96.39% globally — Chrome 66, Edge 79, Firefox 63, and Safari 13.1 (iOS 13.4) and up. That is why 01 tests for navigator.clipboard first and falls back to lighting the done mark without a copy. Note that the API is only allowed inside a user gesture such as a click, so calling it as the page loads is rejected. The full spec is on MDN Clipboard.writeText.
Can a percent number animate without @property?
An unregistered custom property is treated as a string, so it jumps from 0 to 75 with nothing in between. Declaring syntax: "<integer>" through @property is what makes it interpolate as a number, and only then does counter() print a different figure each frame. Support checked the same day sits at 95.01% globally (Chrome and Edge 85, Safari 16.4, Firefox 128 and up), which is why 05, 08, and 09 use it directly; where it is missing, the initial-value stays put, so the bar moves and the number holds.
Will the log pane slow down as rows pile up?
04 drops rows from the front once track.children.length passes 40. Without a ceiling on node count, a tab left open all day keeps thousands of rows around, and the scroll math gets heavy. If you need to handle a longer stream, the grouping and collapsing in 9 Activity Feed UI Widgets is a useful reference.









