07Progress written into the browser tab title
The share already read is prefixed as a number onto document.title, so even someone with a dozen tabs open reads their place from the title alone. The ring in the tab icon slot fills through conic-gradient, driven by a progress value registered with @property and set on that element itself.
Best forLong documents read across a row of open tabs, internal wikis
@propertyconic-gradientdocument.title

<main class="stage">
<section class="tt is-demo" id="tt" aria-label="Progress written into the tab title">
<div class="tt__bar" role="progressbar" aria-label="Reading progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" id="tt-role">
<span class="tt__tab">
<span class="tt__ring" id="tt-ring" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><path d="M224,56V200a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V56a8,8,0,0,1,8-8H216A8,8,0,0,1,224,56Z" opacity="0.2"/><path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm0,160H40V56H216V200ZM184,96a8,8,0,0,1-8,8H80a8,8,0,0,1,0-16h96A8,8,0,0,1,184,96Zm0,32a8,8,0,0,1-8,8H80a8,8,0,0,1,0-16h96A8,8,0,0,1,184,128Zm0,32a8,8,0,0,1-8,8H80a8,8,0,0,1,0-16h96A8,8,0,0,1,184,160Z"/></svg></span>
<span class="tt__titles">
<b class="tt__t tt__t--0">A long weekend read</b>
<b class="tt__t tt__t--1">34% · A long weekend read</b>
<b class="tt__t tt__t--2">78% · A long weekend read</b>
<b class="tt__t tt__t--3">100% · A long weekend read</b>
</span>
<span class="tt__x" aria-hidden="true"></span>
</span>
</div>
<div class="tt__view" id="tt-view" tabindex="0" role="region" aria-label="Long-read body">
<div class="tt__pad">
<p class="tt__lede">This is an article you read with a row of tabs open.</p>
<p class="tt__line"></p>
<p class="tt__line"></p>
<p class="tt__line"></p>
<p class="tt__line"></p>
<p class="tt__line"></p>
<p class="tt__line"></p>
<p class="tt__tail">The browser tab title changes with how far along you are.</p>
</div>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 탭 이름에 적히는 진행률 — document.title 앞에 숫자가 붙고, 탭 아이콘 자리 고리가 conic-gradient 로 채워진다
$duration: $dur-loop;
$easing: linear;
$color: $subject-cream;
// 링의 채움 각도 — 등록된 커스텀 속성이라 요소 자신의 값으로 보간된다
@property --p {
syntax: "<number>";
inherits: false;
initial-value: 0;
}
@include stage($stage-orange);
.tt {
box-sizing: border-box;
width: min(312px, 92vw);
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
overflow: hidden;
}
// 모양은 브라우저 탭을 본뜬다
.tt__bar {
display: grid;
padding: $sp-2 $sp-3 0;
}
.tt__tab {
display: inline-flex;
align-items: center;
gap: $sp-1;
box-sizing: border-box;
min-width: 0;
padding: $sp-1 $sp-2;
border-radius: $r-sm $r-sm 0 0;
background: #ecead9;
font-size: 10px;
line-height: 1.4;
}
.tt__ring {
--p: 0;
position: relative;
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
color: $subject-ink;
font-size: 12px;
// @property 등록값이라 conic-gradient 의 정지점이 --p 와 함께 움직인다
background: conic-gradient(from -90deg, $subject-blue calc(var(--p) * 360deg), rgba(23, 20, 26, .18) 0);
}
.tt__ring svg {
position: relative;
width: 12px;
height: 12px;
color: $subject-cream;
}
.tt__titles {
position: relative;
display: block;
flex: 1;
min-width: 0;
height: 14px;
white-space: nowrap;
overflow: hidden;
}
// 네 문구를 같은 자리에 겹쳐 두고 컷으로 갈아 낀다 — 반투명 겹침이 없다
.tt__t {
position: absolute;
top: 0;
left: 0;
display: none;
margin: 0;
font-size: 10px;
font-weight: 700;
line-height: 14px;
color: $subject-ink;
overflow: hidden;
text-overflow: ellipsis;
}
// 정적(실제 사용) 경로 — 첫 칸 하나만 보이고 JS 가 그 글자를 진행률로 고쳐 쓴다.
// 나머지 셋은 미리보기 루프에서만 컷으로 갈아 끼는 장면이다
.tt__t--0 { display: block; }
.tt.is-demo .tt__t { display: block; }
.tt__x {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(23, 20, 26, .25);
}
.tt__view {
box-sizing: border-box;
height: 148px;
margin: 0 $sp-3 $sp-3;
padding-right: $sp-2;
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
&:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
}
.tt__pad {
display: grid;
gap: $sp-2;
padding: $sp-2 $sp-1 84px;
}
.tt__lede {
margin: 0;
font-size: 11px;
font-weight: 700;
line-height: 1.5;
}
.tt__line {
height: 8px;
margin: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .12);
}
.tt__pad .tt__line:nth-child(3) { width: 92%; }
.tt__pad .tt__line:nth-child(4) { width: 80%; }
.tt__pad .tt__line:nth-child(5) { width: 88%; }
.tt__pad .tt__line:nth-child(6) { width: 64%; }
.tt__pad .tt__line:nth-child(7) { width: 84%; }
.tt__tail {
margin: 0;
font-size: 10px;
line-height: 1.5;
color: rgba($stage-ink, .72);
}
// ── 격자 미리보기 루프 — 고리가 0→1 로 차고 네 문구가 컷으로 바뀐다 ──
// 24프레임 샘플은 4.1667% 눈금 — 되돌아가는 구간을 87.5% 와 91.67% 사이(88~90%)에 둔다
@keyframes tt-fill {
0% { --p: 0; }
86%, 88% { --p: 1; }
90%, 100% { --p: 0; }
}
// 87% 뒤에 넷 다 꺼지면 탭 이름이 빈 칸인 장이 대표 이미지로 뽑힌다 — 고리가 0 으로 돌아가는 89% 에 첫 문구도 같이 돌아온다
@keyframes tt-t0 { 0%, 24% { opacity: 1; } 25%, 88% { opacity: 0; } 89%, 100% { opacity: 1; } }
@keyframes tt-t1 { 0%, 24% { opacity: 0; } 25%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }
@keyframes tt-t2 { 0%, 49% { opacity: 0; } 50%, 79% { opacity: 1; } 80%, 100% { opacity: 0; } }
@keyframes tt-t3 { 0%, 79% { opacity: 0; } 80%, 88% { opacity: 1; } 89%, 100% { opacity: 0; } }
.tt.is-demo {
.tt__ring {
animation-name: tt-fill;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.tt__t--0 { animation-name: tt-t0; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.tt__t--1 { animation-name: tt-t1; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.tt__t--2 { animation-name: tt-t2; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.tt__t--3 { animation-name: tt-t3; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
}
@media (max-width: 340px) {
.tt__view { height: 124px; }
.tt__tab { font-size: 9px; }
}
@media (prefers-reduced-motion: reduce) {
.tt.is-demo .tt__ring,
.tt.is-demo .tt__t { animation: none !important; }
// 정지 상태 — 고리는 절반, 문구는 첫 제목
.tt.is-demo .tt__ring { --p: .5 !important; }
.tt.is-demo .tt__t--0 { opacity: 1 !important; }
.tt.is-demo .tt__t--1,
.tt.is-demo .tt__t--2,
.tt.is-demo .tt__t--3 { opacity: 0 !important; }
}@property --p {
syntax: "<number>";
inherits: false;
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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tt {
box-sizing: border-box;
width: min(312px, 92vw);
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
overflow: hidden;
}
.tt__bar {
display: grid;
padding: 8px 12px 0;
}
.tt__tab {
display: inline-flex;
align-items: center;
gap: 4px;
box-sizing: border-box;
min-width: 0;
padding: 4px 8px;
border-radius: 8px 8px 0 0;
background: #ecead9;
font-size: 10px;
line-height: 1.4;
}
.tt__ring {
--p: 0;
position: relative;
display: inline-grid;
place-items: center;
width: 20px;
height: 20px;
border-radius: 50%;
color: #17141a;
font-size: 12px;
background: conic-gradient(from -90deg, #2f6df6 calc(var(--p) * 360deg), rgba(23, 20, 26, 0.18) 0);
}
.tt__ring svg {
position: relative;
width: 12px;
height: 12px;
color: #fff7e6;
}
.tt__titles {
position: relative;
display: block;
flex: 1;
min-width: 0;
height: 14px;
white-space: nowrap;
overflow: hidden;
}
.tt__t {
position: absolute;
top: 0;
left: 0;
display: none;
margin: 0;
font-size: 10px;
font-weight: 700;
line-height: 14px;
color: #17141a;
overflow: hidden;
text-overflow: ellipsis;
}
.tt__t--0 {
display: block;
}
.tt.is-demo .tt__t {
display: block;
}
.tt__x {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.25);
}
.tt__view {
box-sizing: border-box;
height: 148px;
margin: 0 12px 12px;
padding-right: 8px;
overflow-y: auto;
scrollbar-width: none;
}
.tt__view::-webkit-scrollbar {
display: none;
}
.tt__view:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.tt__pad {
display: grid;
gap: 8px;
padding: 8px 4px 84px;
}
.tt__lede {
margin: 0;
font-size: 11px;
font-weight: 700;
line-height: 1.5;
}
.tt__line {
height: 8px;
margin: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
}
.tt__pad .tt__line:nth-child(3) {
width: 92%;
}
.tt__pad .tt__line:nth-child(4) {
width: 80%;
}
.tt__pad .tt__line:nth-child(5) {
width: 88%;
}
.tt__pad .tt__line:nth-child(6) {
width: 64%;
}
.tt__pad .tt__line:nth-child(7) {
width: 84%;
}
.tt__tail {
margin: 0;
font-size: 10px;
line-height: 1.5;
color: rgba(23, 20, 26, 0.72);
}
@keyframes tt-fill {
0% {
--p: 0;
}
86%, 88% {
--p: 1;
}
90%, 100% {
--p: 0;
}
}
@keyframes tt-t0 {
0%, 24% {
opacity: 1;
}
25%, 88% {
opacity: 0;
}
89%, 100% {
opacity: 1;
}
}
@keyframes tt-t1 {
0%, 24% {
opacity: 0;
}
25%, 49% {
opacity: 1;
}
50%, 100% {
opacity: 0;
}
}
@keyframes tt-t2 {
0%, 49% {
opacity: 0;
}
50%, 79% {
opacity: 1;
}
80%, 100% {
opacity: 0;
}
}
@keyframes tt-t3 {
0%, 79% {
opacity: 0;
}
80%, 88% {
opacity: 1;
}
89%, 100% {
opacity: 0;
}
}
.tt.is-demo .tt__ring {
animation-name: tt-fill;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.tt.is-demo .tt__t--0 {
animation-name: tt-t0;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tt.is-demo .tt__t--1 {
animation-name: tt-t1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tt.is-demo .tt__t--2 {
animation-name: tt-t2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.tt.is-demo .tt__t--3 {
animation-name: tt-t3;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.tt__view {
height: 124px;
}
.tt__tab {
font-size: 9px;
}
}
@media (prefers-reduced-motion: reduce) {
.tt.is-demo .tt__ring,
.tt.is-demo .tt__t {
animation: none !important;
}
.tt.is-demo .tt__ring {
--p: .5 !important;
}
.tt.is-demo .tt__t--0 {
opacity: 1 !important;
}
.tt.is-demo .tt__t--1,
.tt.is-demo .tt__t--2,
.tt.is-demo .tt__t--3 {
opacity: 0 !important;
}
}// Progress written into the tab title — document.title 앞에 숫자를 붙이고 고리는 --p 로 채운다
var tt = document.getElementById('tt');
var view = document.getElementById('tt-view');
var ring = document.getElementById('tt-ring');
var role = document.getElementById('tt-role');
var tabLabel = document.querySelector('.tt__t--0');
var baseTitle = document.title;
var tabName = tabLabel.textContent;
view.addEventListener('scroll', function () {
tt.classList.remove('is-demo');
var max = view.scrollHeight - view.clientHeight;
var r = max > 0 ? view.scrollTop / max : 0;
var now = Math.round(r * 100);
// @property 로 등록한 --p 는 inherits:false — 링 요소 자신에 얹는다 (262 실측)
ring.style.setProperty('--p', r.toFixed(3));
role.setAttribute('aria-valuenow', now);
document.title = now > 0 ? now + '% · ' + baseTitle : baseTitle;
// 데모 안의 가짜 탭도 같은 규칙으로 — 루프를 뗀 정적 경로에서 빈 칸이 되지 않게
tabLabel.textContent = now > 0 ? now + '% · ' + tabName : tabName;
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TabTitleProgress.module.scss";
export type TabTitleProgressProps = {
title?: string;
titlesAt?: [string, string, string, string];
lede?: string;
tail?: string;
groupLabel?: string;
bodyLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 가 얇아 DOM 요소 타입을 못 쓴다 — 필요한 꼴만 로컬 타입으로 선언
type ScrollBox = { scrollTop: number; scrollHeight: number; clientHeight: number };
function ArticleIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M224,56V200a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V56a8,8,0,0,1,8-8H216A8,8,0,0,1,224,56Z" opacity="0.2"/><path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm0,160H40V56H216V200ZM184,96a8,8,0,0,1-8,8H80a8,8,0,0,1,0-16h96A8,8,0,0,1,184,96Zm0,32a8,8,0,0,1-8,8H80a8,8,0,0,1,0-16h96A8,8,0,0,1,184,128Zm0,32a8,8,0,0,1-8,8H80a8,8,0,0,1,0-16h96A8,8,0,0,1,184,160Z"/></svg>
);
}
export default function TabTitleProgress({
title = "주말에 읽는 긴 글",
titlesAt = ["주말에 읽는 긴 글", "34% · 주말에 읽는 긴 글", "78% · 주말에 읽는 긴 글", "100% · 주말에 읽는 긴 글"],
lede = "탭을 여러 개 열어 두고 읽는 글이에요.",
tail = "브라우저 탭 이름이 지금 몇 % 인지 따라 바뀌어요.",
groupLabel = "읽기 진행",
bodyLabel = "긴 글 본문",
demo = true,
duration = 2000,
easing = "linear",
color = "#fff7e6",
}: TabTitleProgressProps) {
const [pct, setPct] = useState(0);
const [live, setLive] = useState(demo);
const viewRef = useRef<ScrollBox>(null);
const ringRef = useRef<HTMLElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// document.title 앞에 진행률 숫자를 붙인다 — 탭만 보고도 위치를 안다
useEffect(() => {
if (live) return;
document.title = pct > 0 ? pct + "% · " + title : title;
}, [pct, live, title]);
function onScroll() {
const el = viewRef.current;
if (!el) return;
setLive(false);
const max = el.scrollHeight - el.clientHeight;
const now = max > 0 ? Math.round((el.scrollTop / max) * 100) : 0;
setPct(now);
// @property 등록값은 요소 자신에게 얹는다
const ring = ringRef.current;
if (ring) ring.style.setProperty("--p", ((max > 0 ? el.scrollTop / max : 0)).toFixed(3));
}
const cls = [styles.tt, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label="탭 이름에 적히는 진행률">
<div className={styles.bar} role="progressbar" aria-label={groupLabel} aria-valuemin={0} aria-valuemax={100} aria-valuenow={pct}>
<span className={styles.tab}>
<span className={styles.ring} ref={ringRef} aria-hidden="true"><ArticleIcon /></span>
<span className={styles.titles}>
{live ? (
// 미리보기 루프 — 네 문구를 같은 자리에 겹쳐 두고 컷으로 갈아 낀다
titlesAt.map((t, i) => (
<b className={`${styles.t} ${styles["t" + i]}`} key={i}>{t}</b>
))
) : (
// 정적(실제 사용) 경로 — 실제 진행률 숫자를 붙인 한 칸만
<b className={`${styles.t} ${styles.t0}`}>{pct > 0 ? pct + "% · " + title : title}</b>
)}
</span>
<span className={styles.x} aria-hidden="true"></span>
</span>
</div>
<div className={styles.view} ref={viewRef} onScroll={onScroll} tabIndex={0} role="region" aria-label={bodyLabel}>
<div className={styles.pad}>
<p className={styles.lede}>{lede}</p>
<p className={styles.line}></p>
<p className={styles.line}></p>
<p className={styles.line}></p>
<p className={styles.line}></p>
<p className={styles.line}></p>
<p className={styles.tail}>{tail}</p>
</div>
</div>
</section>
);
}07 Progress written into the browser tab title — Measured area 0.849% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · 9 Reading Progress Bar CSS Patterns, No Library
The post this demo lives in
9 Reading Progress Bar CSS Patterns, No LibraryA reading progress bar css pattern is any on-page marker that tells a reader how much of a document is already behind th See all demos in this post
If you like this effect - similar demos
Scroll02 Circular ring badge9 CSS Scroll Progress Bar Ideas, Copy-Paste
Scroll03 Circular progress ring9 Back To Top Button CSS: No JS, Scroll-Driven
Scroll07 Section hue shift — the particles' color value steps to the next section's hue each time the scroll-progress value crosses a thresholdMoving Particles Background CSS: 9 Scroll Scenes