04Log follow window
Every new line pushes scrollTop to the bottom, so the view keeps chasing the last row. Turn following off and it freezes where you left it, and each level is marked by a leading glyph, not color alone.
Best forRun-log panel in a deploy tool, event stream on a status page
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">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 · 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
If you like this effect - similar demos
Embeds01 Team activity stream where new work lands on top9 Activity Feed UI Widgets — Sidebar-Ready Lists
Embeds06 Window header that carries the file name9 Code Block UI Patterns — No Highlighter Library
Embeds09 Clock widget whose sky and greeting follow the real hourWeather Widget Design — 9 Widgets, No Library