02테두리를 한 바퀴 도는 읽기 진행선
글 상자에 scroll-timeline-name 으로 이름을 붙이고 네 변의 가는 막대가 animation-range 로 사분면을 하나씩 나눠 가져, 위에서 시작해 시계 방향으로 테두리가 그어집니다. 읽은 비율은 aria-valuenow 에 그대로 적힙니다.
쓰임상단 바를 둘 자리가 없는 전면 읽기 화면, 사진 위에 글을 얹는 기사
scroll-timeline-nameanimation-rangearia-valuenow

<main class="stage">
<div class="pv" id="pv">
<div class="pv__scroller" id="pv-scroller" tabindex="0" role="region" aria-label="긴 글 본문">
<div class="pv__tall">
<article class="pv__frame" id="pv-frame" role="progressbar" aria-label="읽기 진행" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<span class="pv__bar pv__bar--top"></span>
<span class="pv__bar pv__bar--right"></span>
<span class="pv__bar pv__bar--bottom"></span>
<span class="pv__bar pv__bar--left"></span>
<h2 class="pv__title">주말에 읽는 긴 글</h2>
<p class="pv__row pv__row--w1"></p>
<p class="pv__row pv__row--w2"></p>
<p class="pv__row pv__row--w3"></p>
<p class="pv__pct" id="pv-pct" aria-hidden="true"></p>
</article>
</div>
</div>
<p class="pv__note" aria-hidden="true">테두리가 시계 방향으로 그어져요</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 테두리를 한 바퀴 도는 읽기 진행선 — 네 변의 막대가 animation-range 로 사분면을 하나씩 맡는다
$duration: $dur-loop;
$easing: linear;
$color: $subject-blue;
@include stage($stage-yellow);
.pv {
--h: clamp(172px, 60vh, 212px);
--fw: min(240px, 84vw);
display: grid;
justify-items: center;
gap: $sp-2;
width: min(300px, 92vw);
}
// ⚠ 스크롤포트 자체에 타임라인 이름을 붙인다. scroll() 를 그냥 쓰면 overflow 상자를
// 다른 스크롤포트로 잡는 일이 생긴다 (260 실측) — 이름을 지정해 연결을 고정한다
.pv__scroller {
height: var(--h);
overflow-y: auto;
scrollbar-width: none;
scroll-timeline-name: --pv-scroll;
scroll-timeline-axis: block;
&::-webkit-scrollbar { display: none; }
&:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
}
// sticky 의 여유는 padding 이 아니라 이 상자의 height 로 준다 (260 실측)
.pv__tall {
position: relative;
height: 340%;
}
.pv__frame {
position: sticky;
top: 0;
box-sizing: border-box;
display: grid;
align-content: start;
gap: $sp-2;
height: var(--h);
margin: 0;
padding: $sp-5 $sp-4 0;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
overflow: hidden;
}
// ── 네 변의 진행 막대: 시계 방향으로 위→오른쪽→아래→왼쪽이 차례로 그어진다 ──
.pv__bar {
position: absolute;
background: $color;
animation-duration: 1s;
animation-timing-function: $easing;
animation-fill-mode: both;
animation-timeline: --pv-scroll;
}
.pv__bar--top {
top: 0; left: 0;
width: 100%; height: 4px;
transform-origin: left center; // 규칙8: 윗변 left에서 그어짐
transform: scaleX(0);
animation-name: pv-draw-x;
animation-range: 0% 25%;
}
.pv__bar--right {
top: 0; right: 0;
width: 4px; height: 100%;
transform-origin: center top;
transform: scaleY(0);
animation-name: pv-draw-y;
animation-range: 25% 50%;
}
.pv__bar--bottom {
bottom: 0; right: 0;
width: 100%; height: 4px;
transform-origin: right center; // 규칙8: 아랫변 right에서 그어짐
transform: scaleX(0);
animation-name: pv-draw-x;
animation-range: 50% 75%;
}
.pv__bar--left {
bottom: 0; left: 0;
width: 4px; height: 100%;
transform-origin: center bottom;
transform: scaleY(0);
animation-name: pv-draw-y;
animation-range: 75% 100%;
}
@keyframes pv-draw-x { from { transform: scaleX(0); } to { transform: scaleX(1); } }
@keyframes pv-draw-y { from { transform: scaleY(0); } to { transform: scaleY(1); } }
.pv__title {
margin: 0;
font-size: 13px;
font-weight: 800;
line-height: 1.4;
}
.pv__row {
height: 8px;
margin: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .12);
}
.pv__row--w1 { width: 94%; }
.pv__row--w2 { width: 80%; }
.pv__row--w3 { width: 66%; }
// 읽은 비율 숫자도 같은 타임라인에서 나온다 — 등록한 정수 속성을 카운터로 찍으면
// 「테두리는 그어졌는데 숫자는 0%」 같은 어긋난 장면이 아예 생기지 않는다
@property --pv-pct {
syntax: "<integer>";
inherits: false;
initial-value: 0;
}
@keyframes pv-count { from { --pv-pct: 0; } to { --pv-pct: 100; } }
.pv__pct {
margin: 0;
font-size: 22px;
font-weight: 800;
line-height: 1.2;
color: $color;
animation-name: pv-count;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --pv-scroll;
animation-range: 0% 100%;
&::after {
counter-reset: pv-n var(--pv-pct);
content: counter(pv-n) "%";
}
}
.pv__note {
margin: 0;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
color: $subject-ink;
}
// ── 격자 미리보기 루프 — 네 변이 시계 방향으로 그어졌다가 88% 에 함께 지워지고 처음부터 다시 그린다 ──
// 이 항목만 미리보기용 시간 루프가 없다 — 네 변과 숫자가 전부 스크롤 타임라인에서 나오므로
// 격자 미리보기도 같은 진행률을 보여 준다 (다른 여덟 개의 `.is-demo` 루프에 해당하는 자리)
@media (max-width: 340px) {
.pv { --h: 150px; --fw: 84vw; }
.pv__frame { padding: $sp-4 $sp-3 0; }
.pv__title { font-size: 12px; }
.pv__pct { font-size: 18px; }
}
@media (prefers-reduced-motion: reduce) {
// 스스로 움직이는 애니메이션은 끄고, 진행률은 정보라서 남긴다 — 테두리 대신 숫자 한 줄이 그 자리를 맡는다
.pv__bar { animation: none !important; transform: none !important; }
.pv__bar--top { transform: scaleX(1) !important; }
.pv__bar--right { transform: scaleY(0) !important; }
.pv__bar--bottom { transform: scaleX(0) !important; }
.pv__bar--left { transform: scaleY(0) !important; }
// 숫자는 사용자가 스크롤한 만큼만 바뀌는 정보라 그대로 둔다
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pv {
--h: clamp(172px, 60vh, 212px);
--fw: min(240px, 84vw);
display: grid;
justify-items: center;
gap: 8px;
width: min(300px, 92vw);
}
.pv__scroller {
height: var(--h);
overflow-y: auto;
scrollbar-width: none;
scroll-timeline-name: --pv-scroll;
scroll-timeline-axis: block;
}
.pv__scroller::-webkit-scrollbar {
display: none;
}
.pv__scroller:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.pv__tall {
position: relative;
height: 340%;
}
.pv__frame {
position: sticky;
top: 0;
box-sizing: border-box;
display: grid;
align-content: start;
gap: 8px;
height: var(--h);
margin: 0;
padding: 20px 16px 0;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
overflow: hidden;
}
.pv__bar {
position: absolute;
background: #2f6df6;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --pv-scroll;
}
.pv__bar--top {
top: 0;
left: 0;
width: 100%;
height: 4px;
transform-origin: left center;
transform: scaleX(0);
animation-name: pv-draw-x;
animation-range: 0% 25%;
}
.pv__bar--right {
top: 0;
right: 0;
width: 4px;
height: 100%;
transform-origin: center top;
transform: scaleY(0);
animation-name: pv-draw-y;
animation-range: 25% 50%;
}
.pv__bar--bottom {
bottom: 0;
right: 0;
width: 100%;
height: 4px;
transform-origin: right center;
transform: scaleX(0);
animation-name: pv-draw-x;
animation-range: 50% 75%;
}
.pv__bar--left {
bottom: 0;
left: 0;
width: 4px;
height: 100%;
transform-origin: center bottom;
transform: scaleY(0);
animation-name: pv-draw-y;
animation-range: 75% 100%;
}
@keyframes pv-draw-x {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes pv-draw-y {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
.pv__title {
margin: 0;
font-size: 13px;
font-weight: 800;
line-height: 1.4;
}
.pv__row {
height: 8px;
margin: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
}
.pv__row--w1 {
width: 94%;
}
.pv__row--w2 {
width: 80%;
}
.pv__row--w3 {
width: 66%;
}
@property --pv-pct {
syntax: "<integer>";
inherits: false;
initial-value: 0;
}
@keyframes pv-count {
from {
--pv-pct: 0;
}
to {
--pv-pct: 100;
}
}
.pv__pct {
margin: 0;
font-size: 22px;
font-weight: 800;
line-height: 1.2;
color: #2f6df6;
animation-name: pv-count;
animation-duration: 1s;
animation-timing-function: linear;
animation-fill-mode: both;
animation-timeline: --pv-scroll;
animation-range: 0% 100%;
}
.pv__pct::after {
counter-reset: pv-n var(--pv-pct);
content: counter(pv-n) "%";
}
.pv__note {
margin: 0;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
color: #17141a;
}
@media (max-width: 340px) {
.pv {
--h: 150px;
--fw: 84vw;
}
.pv__frame {
padding: 16px 12px 0;
}
.pv__title {
font-size: 12px;
}
.pv__pct {
font-size: 18px;
}
}
@media (prefers-reduced-motion: reduce) {
.pv__bar {
animation: none !important;
transform: none !important;
}
.pv__bar--top {
transform: scaleX(1) !important;
}
.pv__bar--right {
transform: scaleY(0) !important;
}
.pv__bar--bottom {
transform: scaleX(0) !important;
}
.pv__bar--left {
transform: scaleY(0) !important;
}
}// 테두리를 도는 읽기 진행선 — 스크롤포트에 scroll-timeline-name 을 붙여 네 변이 사분면을 나눠 가진다
// 보이는 숫자와 네 변은 CSS 가 타임라인에서 직접 그린다 — JS 는 스크린 리더가 읽을 값만 맞춘다
var scroller = document.getElementById('pv-scroller');
var frame = document.getElementById('pv-frame');
scroller.addEventListener('scroll', function () {
var max = scroller.scrollHeight - scroller.clientHeight;
var r = max > 0 ? scroller.scrollTop / max : 0;
frame.setAttribute('aria-valuenow', Math.round(r * 100));
}, { passive: true });import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FrameLineProgress.module.scss";
export type FrameLineProgressProps = {
title?: string;
note?: 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 };
export default function FrameLineProgress({
title = "주말에 읽는 긴 글",
note = "테두리가 시계 방향으로 그어져요",
groupLabel = "읽기 진행",
bodyLabel = "긴 글 본문",
demo = true,
duration = 2000,
easing = "linear",
color = "#2f6df6",
}: FrameLineProgressProps) {
const [pct, setPct] = useState(0);
const [live, setLive] = useState(demo);
const viewRef = useRef<ScrollBox>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function onScroll() {
const el = viewRef.current;
if (!el) return;
setLive(false);
const max = el.scrollHeight - el.clientHeight;
setPct(max > 0 ? Math.round((el.scrollTop / max) * 100) : 0);
}
const cls = [styles.pv, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<div className={cls} style={vars}>
<div className={styles.scroller} ref={viewRef} onScroll={onScroll} tabIndex={0} role="region" aria-label={bodyLabel}>
<div className={styles.tall}>
<article
className={styles.frame}
role="progressbar"
aria-label={groupLabel}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
>
<span className={`${styles.bar} ${styles.barTop}`}></span>
<span className={`${styles.bar} ${styles.barRight}`}></span>
<span className={`${styles.bar} ${styles.barBottom}`}></span>
<span className={`${styles.bar} ${styles.barLeft}`}></span>
<h2 className={styles.ptitle}>{title}</h2>
<p className={styles.row}></p>
<p className={styles.row}></p>
<p className={styles.row}></p>
<p className={styles.pct}>{pct}%</p>
</article>
</div>
</div>
<p className={styles.note} aria-hidden="true">{note}</p>
</div>
);
}02 테두리를 한 바퀴 도는 읽기 진행선 — 측정 누적면적 1.528% · 움직인 프레임 6/23GODRICH 원작 · zip 에 SCSS·React 포함 · 스크롤 인디케이터 9종 — 어디까지 읽었는지 보여주는
이 데모가 속한 글
스크롤 인디케이터 9종 — 어디까지 읽었는지 보여주는스크롤 인디케이터는 지금 이 화면의 어디쯤을 보고 있는지, 아래로 얼마나 더 남았는지를 눈에 보이게 그려 주는 장치입니다. 여기 모은 아홉 벌은 HTML·SCSS·JS 만으로 짜여 있어 복사해 붙이면 그 자리에서 움 이 글의 데모 전부 보기


