05Progress bars drawn with characters
The bars are not drawn shapes but strings of filled and empty characters, and three jobs fill at their own pace. Percent and ETA hold their column with font-variant-numeric, so the row never jitters as digits change.
Best forProgress display in a build tool, batch job result screen
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">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 · Terminal UI Design: 9 Copy-Paste CLI Parts
The post this demo lives in
Terminal UI Design: 9 Copy-Paste CLI PartsTerminal UI design is the craft of putting a command-line window inside a web page. These nine copy-paste CLI parts go f See all demos in this post


