05문자로 그린 진행 막대
막대를 그림이 아니라 채움 문자와 빈 문자로 그려 세 작업이 각자의 속도로 찹니다. 퍼센트와 남은 시간은 font-variant-numeric 으로 자릿수를 고정해 숫자가 바뀌어도 줄이 흔들리지 않습니다.
쓰임빌드 도구의 진행 표시, 배치 작업 결과 화면
aria-valuenowfont-variant-numericanimation-fill-mode

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


