04로그 따라가기 창
새 줄이 붙을 때마다 scrollTop 을 바닥으로 밀어 화면이 마지막 줄을 계속 따라갑니다. 따라가기를 끄면 그 자리에 멈추고, 줄의 등급은 색 말고 앞에 붙은 기호로도 구분됩니다.
쓰임배포 도구의 실행 로그 패널, 상태 페이지의 이벤트 스트림
scrollToparia-livetranslateY

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


