스크롤 인디케이터 9종 — 어디까지 읽었는지 보여주는
스크롤 인디케이터는 지금 이 화면의 어디쯤을 보고 있는지, 아래로 얼마나 더 남았는지를 눈에 보이게 그려 주는 장치입니다. 여기 모은 아홉 벌은 HTML·SCSS·JS 만으로 짜여 있어 복사해 붙이면 그 자리에서 움직입니다.
01 scroll-hint-arrow-firstview
02 scroll-progress-frame-line
03 scroll-depth-milestone-badge
04 scroll-read-focus-paragraph
05 scroll-section-counter-keys
06 scroll-overflow-edge-hint
07 scroll-tab-title-progress
08 scroll-resume-marker
09 scroll-next-article-ring
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 첫 화면에서만 흔들리는 아래로 힌트
- 02 테두리를 한 바퀴 도는 읽기 진행선
- 03 4분의 1씩 켜지는 읽기 깊이 배지
- 04 지금 읽는 문단만 또렷해지는 초점
- 05 몇 번째 구간인지 세어 주는 카운터
- 06 옆으로 더 있다고 알려 주는 가장자리 그늘
- 07 브라우저 탭 이름에 적히는 진행률
- 08 읽던 자리를 남겨 두는 이어읽기 표시
- 09 끝에서 다음 글을 당겨 오는 진행 링
아홉 개는 기술 난이도나 css 스크롤바 디자인 취향이 아니라 읽는 사람이 한 편의 글에서 겪는 시간 순서로 놓았습니다. 화면을 열자마자 더 내려갈 것이 있는지 알려 주는 첫 화면 힌트(01)에서 시작해, 읽는 동안의 진행률을 테두리(02)와 깊이 배지(03)로 계속 보여 줍니다. 그다음 셋은 위치가 아니라 초점을 다룹니다 — 지금 읽는 문단만 또렷하게 남기고(04), 몇 번째 구간인지 숫자로 세고(05), 아래가 아니라 옆으로 더 있다는 사실을 가장자리 그늘로 알립니다(06). 마지막 셋은 화면 밖과 글 밖으로 나갑니다. 탭을 여러 개 띄워 둔 사람에게는 탭 이름 자체가 표시를 맡고(07), 자리를 비웠다 돌아온 사람에게는 읽던 자리가 점선으로 남아 있고(08), 끝까지 읽은 사람에게는 다음 글을 당겨 오는 고리(09)가 기다립니다. 아홉 벌이 같은 색·간격·이징 값을 나눠 쓰기 때문에 둘셋을 한 페이지에 섞어도 톤이 어긋나지 않습니다. 이 위치 값이 장면을 통째로 갈아 끼우는 쪽으로 흘러가면 스크롤 스토리텔링 섹션이 되고, 가장자리 그늘을 반투명 유리로 물들이고 싶을 때는 글래스모피즘 효과가 같은 값을 이어받습니다.
01첫 화면에서만 흔들리는 아래로 힌트
스크롤 표시에서 무엇이 보이나
글이 아직 한 줄도 움직이지 않았을 때만 아래 화살표가 1초 주기로 5px 만큼 위아래를 오갑니다. scrollTop 이 8px 을 넘는 순간 힌트 묶음이 translateY(120%) 로 카드 밖으로 내려가며 흐려지고, 맨 위로 돌아오면 같은 길로 제자리에 섭니다.

<main class="stage">
<section class="sh is-demo" id="sh" aria-label="첫 화면 스크롤 힌트">
<header class="sh__head">
<p class="sh__kicker">봄 소식지</p>
<p class="sh__state"><span class="sh__s sh__s--top" id="sh-state">맨 위 · 힌트 보이는 중</span><span class="sh__s sh__s--gone" aria-hidden="true">읽는 중 · 힌트 물러남</span></p>
</header>
<div class="sh__view" id="sh-view" tabindex="0" role="region" aria-label="소식지 본문">
<div class="sh__pad">
<p class="sh__lede">화면이 꽉 차 보이지만 아래가 더 길어요.</p>
<p class="sh__line"></p>
<p class="sh__line"></p>
<p class="sh__line"></p>
<p class="sh__line"></p>
<p class="sh__line"></p>
<p class="sh__tail">끝까지 내려오면 여기가 마지막 줄이에요.</p>
</div>
</div>
<div class="sh__hint" id="sh-hint" aria-hidden="true">
<span class="sh__pill"><svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true"><path d="M200,144l-72,72L56,144Z" opacity="0.2"/><path d="M207.39,140.94A8,8,0,0,0,200,136H136V40a8,8,0,0,0-16,0v96H56a8,8,0,0,0-5.66,13.66l72,72a8,8,0,0,0,11.32,0l72-72A8,8,0,0,0,207.39,140.94ZM128,204.69,75.31,152H180.69Z"/></svg></span>
<span class="sh__bob"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M200,144l-72,72L56,144Z" opacity="0.2"/><path d="M207.39,140.94A8,8,0,0,0,200,136H136V40a8,8,0,0,0-16,0v96H56a8,8,0,0,0-5.66,13.66l72,72a8,8,0,0,0,11.32,0l72-72A8,8,0,0,0,207.39,140.94ZM128,204.69,75.31,152H180.69Z"/></svg></span>
<span class="sh__note">아래로 내려 보세요</span>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 첫 화면에서만 흔들리는 아래로 힌트 — 스크롤이 8px 만 움직여도 힌트는 물러난다
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
.sh {
position: relative;
box-sizing: border-box;
width: min(300px, 92vw);
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
overflow: hidden;
}
.sh__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
padding: $sp-2 $sp-3 0;
}
.sh__kicker {
margin: 0;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.sh__state {
position: relative;
margin: 0;
font-size: 10px;
line-height: 1.3;
color: rgba($stage-ink, .72);
white-space: nowrap;
}
// 미리보기 루프에서만 쓰는 두 번째 문구 — 정적 경로에서는 JS 가 첫 칸의 글자를 갈아 쓴다
.sh__s--gone { display: none; }
.sh__view {
box-sizing: border-box;
height: 148px;
margin: $sp-2;
padding-right: $sp-2;
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
&:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
}
.sh__pad {
display: grid;
gap: $sp-2;
padding: $sp-1 $sp-1 96px;
}
.sh__lede {
margin: 0;
font-size: 11px;
font-weight: 700;
line-height: 1.5;
}
.sh__line {
height: 8px;
margin: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .12);
}
.sh__line:nth-child(3) { width: 92%; }
.sh__line:nth-child(4) { width: 78%; }
.sh__line:nth-child(5) { width: 88%; }
.sh__line:nth-child(6) { width: 64%; }
.sh__tail {
margin: 0;
font-size: 10px;
line-height: 1.5;
color: rgba($stage-ink, .72);
}
// ── 힌트 묶음: 본문 아래 가운데에 붙는다 ──
.sh__hint {
position: absolute;
left: 0;
right: 0;
bottom: $sp-1;
display: grid;
justify-items: center;
gap: $sp-1;
color: $subject-ink;
// 힌트가 본문 글자 위에 그냥 얹히면 글자가 원 뒤로 비쳐 지저분하다 — 제 자리만큼 바탕을 덮는다
isolation: isolate;
&::before {
content: "";
position: absolute;
inset: -$sp-5 0 -$sp-1;
background: linear-gradient(to bottom, transparent, $subject-cream 58%);
z-index: -1;
}
// 정적(실제 사용) 경로 — 스크롤되면 .is-gone 로 물러난다
transition: transform $dur-quick $easing, opacity $dur-instant linear;
}
.sh.is-gone .sh__hint {
transform: translateY(120%);
opacity: 0;
}
.sh__pill {
display: inline-grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: $subject-ink;
color: $stage-yellow;
}
.sh__pill svg { width: 14px; height: 14px; }
.sh__bob {
display: inline-grid;
place-items: center;
font-size: 18px;
line-height: 1;
}
.sh__note {
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
font-size: 10px;
font-weight: 700;
line-height: 1.4;
}
// ── 격자 미리보기 루프: 화살표는 계속 점프하고, 중간에 본문이 흐르면 힌트가 물러난다 ──
@keyframes sh-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(5px); }
}
// 물러남은 네 프레임에 나눠 흐리게, 돌아옴은 한 프레임 컷 — 그래야 대표 이미지가
// 「힌트가 사라진 빈 화면」 이 아니라 힌트가 서 있는 장이 된다 (퇴장이 등장을 이기는 함정)
@keyframes sh-fade {
0%, 37% { opacity: 1; }
54%, 79% { opacity: 0; }
80%, 100% { opacity: 1; }
}
@keyframes sh-drop {
0%, 37% { transform: translateY(0); }
54%, 79% { transform: translateY(120%); }
80%, 100% { transform: translateY(0); }
}
@keyframes sh-flow {
0%, 38% { transform: translateY(0); }
78% { transform: translateY(-20%); }
82%, 100% { transform: translateY(0); }
}
// 머리말도 두 문구를 겹쳐 두고 컷으로 갈아 낀다 — 힌트가 물러났는데 「보이는 중」 이라고
// 적힌 장이 대표 이미지로 나가면 글과 그림이 서로 다른 말을 한다
@keyframes sh-state-0 { 0%, 37% { opacity: 1; } 38%, 79% { opacity: 0; } 80%, 100% { opacity: 1; } }
@keyframes sh-state-1 { 0%, 37% { opacity: 0; } 38%, 79% { opacity: 1; } 80%, 100% { opacity: 0; } }
.sh.is-demo {
.sh__bob {
animation-name: sh-bob;
animation-duration: 1s;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.sh__hint {
animation-name: sh-fade, sh-drop;
animation-duration: $duration, $duration;
animation-timing-function: $ease-out, $easing;
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.sh__pad {
animation-name: sh-flow;
animation-duration: $duration;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
.sh__s--gone { display: block; position: absolute; right: 0; top: 0; }
.sh__s {
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sh__s--top { animation-name: sh-state-0; }
.sh__s--gone { animation-name: sh-state-1; }
}
@media (max-width: 340px) {
.sh__view { height: 124px; }
.sh__lede { font-size: 10px; }
.sh__line { height: 6px; }
}
@media (prefers-reduced-motion: reduce) {
.sh.is-demo .sh__bob,
.sh.is-demo .sh__pad,
.sh.is-demo .sh__s { animation: none !important; transform: none !important; }
.sh.is-demo .sh__hint { animation: none !important; transform: none !important; opacity: 1 !important; }
// 정지 상태에서도 힌트가 무엇인지는 보인다 — 움직임만 뺀다. 머리말은 맨 위 문구 하나만
.sh.is-demo .sh__s--top { opacity: 1 !important; }
.sh.is-demo .sh__s--gone { opacity: 0 !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);
}
.sh {
position: relative;
box-sizing: border-box;
width: min(300px, 92vw);
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
overflow: hidden;
}
.sh__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 8px 12px 0;
}
.sh__kicker {
margin: 0;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.sh__state {
position: relative;
margin: 0;
font-size: 10px;
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
white-space: nowrap;
}
.sh__s--gone {
display: none;
}
.sh__view {
box-sizing: border-box;
height: 148px;
margin: 8px;
padding-right: 8px;
overflow-y: auto;
scrollbar-width: none;
}
.sh__view::-webkit-scrollbar {
display: none;
}
.sh__view:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
.sh__pad {
display: grid;
gap: 8px;
padding: 4px 4px 96px;
}
.sh__lede {
margin: 0;
font-size: 11px;
font-weight: 700;
line-height: 1.5;
}
.sh__line {
height: 8px;
margin: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
}
.sh__line:nth-child(3) {
width: 92%;
}
.sh__line:nth-child(4) {
width: 78%;
}
.sh__line:nth-child(5) {
width: 88%;
}
.sh__line:nth-child(6) {
width: 64%;
}
.sh__tail {
margin: 0;
font-size: 10px;
line-height: 1.5;
color: rgba(23, 20, 26, 0.72);
}
.sh__hint {
position: absolute;
left: 0;
right: 0;
bottom: 4px;
display: grid;
justify-items: center;
gap: 4px;
color: #17141a;
isolation: isolate;
}
.sh__hint::before {
content: "";
position: absolute;
inset: -20px -4px;
background: linear-gradient(to bottom, transparent, #fff7e6 58%);
z-index: -1;
}
.sh__hint {
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 140ms linear;
}
.sh.is-gone .sh__hint {
transform: translateY(120%);
opacity: 0;
}
.sh__pill {
display: inline-grid;
place-items: center;
width: 28px;
height: 28px;
border-radius: 50%;
background: #17141a;
color: #ffd23f;
}
.sh__pill svg {
width: 14px;
height: 14px;
}
.sh__bob {
display: inline-grid;
place-items: center;
font-size: 18px;
line-height: 1;
}
.sh__note {
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
font-size: 10px;
font-weight: 700;
line-height: 1.4;
}
@keyframes sh-bob {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(5px);
}
}
@keyframes sh-fade {
0%, 37% {
opacity: 1;
}
54%, 79% {
opacity: 0;
}
80%, 100% {
opacity: 1;
}
}
@keyframes sh-drop {
0%, 37% {
transform: translateY(0);
}
54%, 79% {
transform: translateY(120%);
}
80%, 100% {
transform: translateY(0);
}
}
@keyframes sh-flow {
0%, 38% {
transform: translateY(0);
}
78% {
transform: translateY(-20%);
}
82%, 100% {
transform: translateY(0);
}
}
@keyframes sh-state-0 {
0%, 37% {
opacity: 1;
}
38%, 79% {
opacity: 0;
}
80%, 100% {
opacity: 1;
}
}
@keyframes sh-state-1 {
0%, 37% {
opacity: 0;
}
38%, 79% {
opacity: 1;
}
80%, 100% {
opacity: 0;
}
}
.sh.is-demo .sh__bob {
animation-name: sh-bob;
animation-duration: 1s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.sh.is-demo .sh__hint {
animation-name: sh-fade, sh-drop;
animation-duration: 2s, 2s;
animation-timing-function: ease-out, cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite, infinite;
animation-fill-mode: backwards, backwards;
}
.sh.is-demo .sh__pad {
animation-name: sh-flow;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.sh.is-demo .sh__s--gone {
display: block;
position: absolute;
right: 0;
top: 0;
}
.sh.is-demo .sh__s {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sh.is-demo .sh__s--top {
animation-name: sh-state-0;
}
.sh.is-demo .sh__s--gone {
animation-name: sh-state-1;
}
@media (max-width: 340px) {
.sh__view {
height: 124px;
}
.sh__lede {
font-size: 10px;
}
.sh__line {
height: 6px;
}
}
@media (prefers-reduced-motion: reduce) {
.sh.is-demo .sh__bob,
.sh.is-demo .sh__pad,
.sh.is-demo .sh__s {
animation: none !important;
transform: none !important;
}
.sh.is-demo .sh__hint {
animation: none !important;
transform: none !important;
opacity: 1 !important;
}
.sh.is-demo .sh__s--top {
opacity: 1 !important;
}
.sh.is-demo .sh__s--gone {
opacity: 0 !important;
}
}// 첫 화면에서만 흔들리는 아래로 힌트 — scrollTop 8px 기준으로 보였다 물러난다
var sh = document.getElementById('sh');
var view = document.getElementById('sh-view');
var state = document.getElementById('sh-state');
view.addEventListener('scroll', function () {
sh.classList.remove('is-demo');
var gone = view.scrollTop > 8;
sh.classList.toggle('is-gone', gone);
state.textContent = gone ? '읽는 중 · 힌트 물러남' : '맨 위 · 힌트 보이는 중';
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ScrollHintFirstview.module.scss";
export type ScrollHintFirstviewProps = {
kicker?: string;
stateVisible?: string;
stateHidden?: string;
lede?: string;
tail?: 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 };
function ArrowDownIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M200,144l-72,72L56,144Z" opacity="0.2"/><path d="M207.39,140.94A8,8,0,0,0,200,136H136V40a8,8,0,0,0-16,0v96H56a8,8,0,0,0-5.66,13.66l72,72a8,8,0,0,0,11.32,0l72-72A8,8,0,0,0,207.39,140.94ZM128,204.69,75.31,152H180.69Z"/></svg>
);
}
export default function ScrollHintFirstview({
kicker = "봄 소식지",
stateVisible = "맨 위 · 힌트 보이는 중",
stateHidden = "읽는 중 · 힌트 물러남",
lede = "화면이 꽉 차 보이지만 아래가 더 길어요.",
tail = "끝까지 내려오면 여기가 마지막 줄이에요.",
note = "아래로 내려 보세요",
groupLabel = "첫 화면 스크롤 힌트",
bodyLabel = "소식지 본문",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: ScrollHintFirstviewProps) {
const [hidden, setHidden] = useState(false);
const [live, setLive] = useState(demo);
const viewRef = useRef<ScrollBox>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// scrollTop 8px 기준 — 처음 화면에서만 힌트가 살아 있다
function onScroll() {
const el = viewRef.current;
if (!el) return;
setLive(false);
setHidden(el.scrollTop > 8);
}
const cls = [styles.sh, live ? styles.isDemo : "", hidden ? styles.isGone : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.head}>
<p className={styles.kicker}>{kicker}</p>
<p className={styles.state}>
{live ? (
// 미리보기 루프 — 두 문구를 겹쳐 두고 힌트가 물러나는 순간에 맞춰 컷으로 갈아 낀다
<>
<span className={`${styles.s} ${styles.sTop}`}>{stateVisible}</span>
<span className={`${styles.s} ${styles.sGone}`} aria-hidden="true">{stateHidden}</span>
</>
) : (
hidden ? stateHidden : stateVisible
)}
</p>
</header>
<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.tail}>{tail}</p>
</div>
</div>
<div className={styles.hint} aria-hidden="true">
<span className={styles.pill}><ArrowDownIcon /></span>
<span className={styles.bob}><ArrowDownIcon /></span>
<span className={styles.shnote}>{note}</span>
</div>
</section>
);
}01 · 첫 화면에서만 흔들리는 아래로 힌트 — 측정 누적면적 19.999% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
첫 화면이 꽉 차 보여서 아래가 더 있다는 것을 아무도 모르는 소개 페이지에 씁니다.
@keyframes sh-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(5px); }
}
/* 정적(실제 사용) 경로 — 스크롤되면 .is-gone 로 물러난다 */
.sh__hint {
transition: transform 220ms cubic-bezier(.2, .8, .2, 1),
opacity 140ms linear;
}
.sh.is-gone .sh__hint {
transform: translateY(120%);
opacity: 0;
}
02테두리를 한 바퀴 도는 읽기 진행선
화면에서 일어나는 일
글 상자를 감싼 네 변의 가는 막대가 위에서 시작해 시계 방향으로 차례로 그어집니다. 막대마다 animation-range 로 사분면을 하나씩 맡겼기 때문에 25% 를 읽으면 윗변만, 절반을 읽으면 윗변과 오른쪽 변이 그어진 상태가 됩니다.

<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 포함 · 비밀번호 보기
코드 읽는 법
상단 바를 놓을 자리가 없는 전면 읽기 화면이나 사진 위에 글을 얹는 기사에 씁니다.
/* 스크롤포트 자체에 타임라인 이름을 붙인다 — 이름 없는 scroll() 은
overflow 상자를 엉뚱한 스크롤포트로 잡는다 */
.pv__scroller {
height: var(--h);
overflow-y: auto;
scroll-timeline-name: --pv-scroll;
scroll-timeline-axis: block;
}
.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;
animation-name: pv-draw-x;
animation-range: 0% 25%;
}
@keyframes pv-draw-x { from { transform: scaleX(0); } to { transform: scaleX(1); } }
034분의 1씩 켜지는 읽기 깊이 배지
어떤 움직임인가
25·50·75·100 네 개의 동그란 배지가 그 깊이에 닿는 순간 색을 갈아입고 한 번 튀어오릅니다. 색이 바뀌는 전환에 steps(1, end) 를 걸어 중간색이 나오지 않게 했고, 튐은 cubic-bezier(.2, 1.4, .4, 1) 로 1.22배까지 커졌다 돌아옵니다.

<main class="stage">
<section class="db is-demo" id="db" aria-label="읽기 깊이 배지">
<header class="db__head">
<p class="db__title">이벤트 안내문</p>
<p class="db__live" id="db-live" aria-live="polite">읽기 시작</p>
</header>
<div class="db__view" id="db-view" tabindex="0" role="region" aria-label="안내문 본문">
<div class="db__pad">
<p class="db__lede">여기서 끝까지 읽으면 쿠폰을 드려요.</p>
<p class="db__line"></p>
<p class="db__line"></p>
<p class="db__line"></p>
<p class="db__line"></p>
<p class="db__line"></p>
<p class="db__line"></p>
<p class="db__tail">다 읽으셨어요. 오른쪽 위 배지를 확인해 보세요.</p>
</div>
</div>
<footer class="db__foot">
<div class="db__row" role="progressbar" aria-label="읽은 깊이" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" id="db-bar-role">
<span class="db__cell"><b class="db__chip" data-milestone="25">25</b></span>
<span class="db__cell"><b class="db__chip" data-milestone="50">50</b></span>
<span class="db__cell"><b class="db__chip" data-milestone="75">75</b></span>
<span class="db__cell"><b class="db__chip" data-milestone="100">100</b></span>
</div>
<span class="db__fill" id="db-fill" aria-hidden="true"></span>
</footer>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 읽기 깊이 배지 — 25·50·75·100 배지가 steps(1,end) 컷으로 색을 갈아입고 한 번 튀어오른다
$duration: $dur-loop;
$easing: $ease-pop;
$color: $subject-orange-deep;
@include stage($stage-orange);
.db {
position: relative;
box-sizing: border-box;
width: min(300px, 92vw);
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
overflow: hidden;
}
.db__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
padding: $sp-2 $sp-3 0;
}
.db__title {
margin: 0;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.db__live {
margin: 0;
font-size: 10px;
line-height: 1.3;
color: $color;
font-weight: 700;
}
.db__view {
box-sizing: border-box;
height: 132px;
margin: $sp-2 $sp-2 0;
padding-right: $sp-2;
overflow-y: auto;
scrollbar-width: none;
// 창 아래 모서리에서 글줄이 반토막으로 잘려 보이는 걸 막는다 — 마지막 12px 을 흐리게 내린다
mask-image: linear-gradient(to bottom, #000 calc(100% - 12px), transparent);
&::-webkit-scrollbar { display: none; }
&:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
}
.db__pad {
display: grid;
gap: $sp-2;
padding: $sp-1 $sp-1 104px;
}
.db__lede {
margin: 0;
font-size: 11px;
font-weight: 700;
line-height: 1.5;
}
.db__line {
height: 8px;
margin: 0;
border-radius: $r-pill;
background: rgba(23, 20, 26, .12);
}
.db__pad .db__line:nth-child(3) { width: 94%; }
.db__pad .db__line:nth-child(4) { width: 82%; }
.db__pad .db__line:nth-child(5) { width: 90%; }
.db__pad .db__line:nth-child(6) { width: 70%; }
.db__pad .db__line:nth-child(7) { width: 86%; }
.db__tail {
margin: 0;
font-size: 10px;
line-height: 1.5;
color: rgba($stage-ink, .72);
}
// ── 배지 줄: 게이지 칸은 grid 로 짠다 (BD19 오탐 회피 · 칸 폭이 정확히 균등) ──
.db__foot {
position: relative;
margin: 0 $sp-2 $sp-2;
padding: $sp-2 0 0;
}
.db__row {
display: grid;
grid-template-columns: repeat(4, 1fr);
justify-items: center;
gap: $sp-2;
}
.db__chip {
display: inline-grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(23, 20, 26, .1);
color: rgba($stage-ink, .72);
font-size: 11px;
font-weight: 800;
line-height: 1;
// 정적 경로 — 실제로는 .is-on 이 붙는 순간 컷으로 색이 바뀐다
transition: background $dur-instant steps(1, end), color $dur-instant steps(1, end);
}
.db__chip.is-on {
background: $color;
color: #fff;
animation-name: db-pop;
animation-duration: $dur-base;
animation-timing-function: $easing;
animation-iteration-count: 1;
}
@keyframes db-pop {
0% { transform: scale(1); }
45% { transform: scale(1.22); }
100% { transform: scale(1); }
}
// 같은 비율만큼 차오르는 막대 — grid 칸 아래를 지나가는 길
.db__fill {
display: block;
height: 4px;
margin-top: $sp-2;
border-radius: $r-pill;
background: rgba(23, 20, 26, .1);
overflow: hidden;
&::after {
content: "";
display: block;
height: 100%;
border-radius: $r-pill;
background: $color;
transform: scaleX(0);
transform-origin: left center; // 규칙8: 진행막대 left 채움
}
}
.db.is-demo .db__fill::after {
animation-name: db-fill;
animation-duration: $duration;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes db-fill {
0% { transform: scaleX(0); }
88% { transform: scaleX(1); }
90%, 100% { transform: scaleX(0); }
}
// ── 격자 미리보기 루프: 배지 넷이 제 각각의 순간에 컷으로 켜지고 튀어오른다 ──
@keyframes db-cut-1 { 0%, 21% { background: rgba(23, 20, 26, .1); color: rgba($stage-ink, .72); } 22%, 88% { background: $color; color: #fff; } 89%, 100% { background: rgba(23, 20, 26, .1); color: rgba($stage-ink, .72); } }
@keyframes db-cut-2 { 0%, 41% { background: rgba(23, 20, 26, .1); color: rgba($stage-ink, .72); } 42%, 88% { background: $color; color: #fff; } 89%, 100% { background: rgba(23, 20, 26, .1); color: rgba($stage-ink, .72); } }
@keyframes db-cut-3 { 0%, 61% { background: rgba(23, 20, 26, .1); color: rgba($stage-ink, .72); } 62%, 88% { background: $color; color: #fff; } 89%, 100% { background: rgba(23, 20, 26, .1); color: rgba($stage-ink, .72); } }
@keyframes db-cut-4 { 0%, 81% { background: rgba(23, 20, 26, .1); color: rgba($stage-ink, .72); } 82%, 88% { background: $color; color: #fff; } 89%, 100% { background: rgba(23, 20, 26, .1); color: rgba($stage-ink, .72); } }
@keyframes db-hop-1 { 0%, 21% { transform: scale(1); } 24% { transform: scale(1.22); } 30%, 100% { transform: scale(1); } }
@keyframes db-hop-2 { 0%, 41% { transform: scale(1); } 44% { transform: scale(1.22); } 50%, 100% { transform: scale(1); } }
@keyframes db-hop-3 { 0%, 61% { transform: scale(1); } 64% { transform: scale(1.22); } 70%, 100% { transform: scale(1); } }
@keyframes db-hop-4 { 0%, 81% { transform: scale(1); } 84% { transform: scale(1.22); } 90%, 100% { transform: scale(1); } }
@keyframes db-say {
0%, 21% { opacity: 1; }
22%, 41% { opacity: .4; }
42%, 61% { opacity: 1; }
62%, 81% { opacity: .4; }
82%, 88% { opacity: 1; }
89%, 100% { opacity: .4; }
}
.db.is-demo {
.db__chip[data-milestone="25"] { animation-name: db-hop-1; animation-duration: $duration; animation-timing-function: $easing; animation-iteration-count: infinite; }
.db__chip[data-milestone="50"] { animation-name: db-hop-2; animation-duration: $duration; animation-timing-function: $easing; animation-iteration-count: infinite; }
.db__chip[data-milestone="75"] { animation-name: db-hop-3; animation-duration: $duration; animation-timing-function: $easing; animation-iteration-count: infinite; }
.db__chip[data-milestone="100"] { animation-name: db-hop-4; animation-duration: $duration; animation-timing-function: $easing; animation-iteration-count: infinite; }
}
// 색 컷은 별도 애니메이션 두 개를 겹친다 — 낱개 속성으로 써야 지연 리셋 함정을 피한다
.db.is-demo .db__chip[data-milestone="25"] { animation-name: db-hop-1, db-cut-1; animation-duration: $duration, $duration; animation-timing-function: $easing, steps(1, end); animation-iteration-count: infinite, infinite; }
.db.is-demo .db__chip[data-milestone="50"] { animation-name: db-hop-2, db-cut-2; animation-duration: $duration, $duration; animation-timing-function: $easing, steps(1, end); animation-iteration-count: infinite, infinite; }
.db.is-demo .db__chip[data-milestone="75"] { animation-name: db-hop-3, db-cut-3; animation-duration: $duration, $duration; animation-timing-function: $easing, steps(1, end); animation-iteration-count: infinite, infinite; }
.db.is-demo .db__chip[data-milestone="100"] { animation-name: db-hop-4, db-cut-4; animation-duration: $duration, $duration; animation-timing-function: $easing, steps(1, end); animation-iteration-count: infinite, infinite; }
.db.is-demo .db__live {
animation-name: db-say;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
// 폰 320×200 무대의 세로 여유는 174px — 머리 24 + 배지줄 56 을 빼면 읽기 창은 84px 이 상한
.db__view { height: 84px; }
.db__chip { width: 28px; height: 28px; font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.db.is-demo .db__chip,
.db.is-demo .db__fill::after,
.db.is-demo .db__live { animation: none !important; }
// 정지 상태에서도 깊이를 알 수 있게 절반까지 켜진 모습으로 둔다
.db.is-demo .db__chip[data-milestone="25"],
.db.is-demo .db__chip[data-milestone="50"] { background: $color !important; color: #fff !important; }
.db.is-demo .db__fill::after { transform: scaleX(.5) !important; }
}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);
}
.db {
position: relative;
box-sizing: border-box;
width: min(300px, 92vw);
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
overflow: hidden;
}
.db__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 8px 12px 0;
}
.db__title {
margin: 0;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.db__live {
margin: 0;
font-size: 10px;
line-height: 1.3;
color: #c93000;
font-weight: 700;
}
.db__view {
box-sizing: border-box;
height: 132px;
margin: 8px 8px 0;
padding-right: 8px;
overflow-y: auto;
scrollbar-width: none;
mask-image: linear-gradient(to bottom, #000 calc(100% - 12px), transparent);
}
.db__view::-webkit-scrollbar {
display: none;
}
.db__view:focus-visible {
outline: 2px solid #c93000;
outline-offset: 2px;
}
.db__pad {
display: grid;
gap: 8px;
padding: 4px 4px 104px;
}
.db__lede {
margin: 0;
font-size: 11px;
font-weight: 700;
line-height: 1.5;
}
.db__line {
height: 8px;
margin: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
}
.db__pad .db__line:nth-child(3) {
width: 94%;
}
.db__pad .db__line:nth-child(4) {
width: 82%;
}
.db__pad .db__line:nth-child(5) {
width: 90%;
}
.db__pad .db__line:nth-child(6) {
width: 70%;
}
.db__pad .db__line:nth-child(7) {
width: 86%;
}
.db__tail {
margin: 0;
font-size: 10px;
line-height: 1.5;
color: rgba(23, 20, 26, 0.72);
}
.db__foot {
position: relative;
margin: 0 8px 8px;
padding: 8px 0 0;
}
.db__row {
display: grid;
grid-template-columns: repeat(4, 1fr);
justify-items: center;
gap: 8px;
}
.db__chip {
display: inline-grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(23, 20, 26, 0.1);
color: rgba(23, 20, 26, 0.72);
font-size: 11px;
font-weight: 800;
line-height: 1;
transition: background 140ms steps(1, end), color 140ms steps(1, end);
}
.db__chip.is-on {
background: #c93000;
color: #fff;
animation-name: db-pop;
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
@keyframes db-pop {
0% {
transform: scale(1);
}
45% {
transform: scale(1.22);
}
100% {
transform: scale(1);
}
}
.db__fill {
display: block;
height: 4px;
margin-top: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
overflow: hidden;
}
.db__fill::after {
content: "";
display: block;
height: 100%;
border-radius: 999px;
background: #c93000;
transform: scaleX(0);
transform-origin: left center;
}
.db.is-demo .db__fill::after {
animation-name: db-fill;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes db-fill {
0% {
transform: scaleX(0);
}
88% {
transform: scaleX(1);
}
90%, 100% {
transform: scaleX(0);
}
}
@keyframes db-cut-1 {
0%, 21% {
background: rgba(23, 20, 26, 0.1);
color: rgba(23, 20, 26, 0.72);
}
22%, 88% {
background: #c93000;
color: #fff;
}
89%, 100% {
background: rgba(23, 20, 26, 0.1);
color: rgba(23, 20, 26, 0.72);
}
}
@keyframes db-cut-2 {
0%, 41% {
background: rgba(23, 20, 26, 0.1);
color: rgba(23, 20, 26, 0.72);
}
42%, 88% {
background: #c93000;
color: #fff;
}
89%, 100% {
background: rgba(23, 20, 26, 0.1);
color: rgba(23, 20, 26, 0.72);
}
}
@keyframes db-cut-3 {
0%, 61% {
background: rgba(23, 20, 26, 0.1);
color: rgba(23, 20, 26, 0.72);
}
62%, 88% {
background: #c93000;
color: #fff;
}
89%, 100% {
background: rgba(23, 20, 26, 0.1);
color: rgba(23, 20, 26, 0.72);
}
}
@keyframes db-cut-4 {
0%, 81% {
background: rgba(23, 20, 26, 0.1);
color: rgba(23, 20, 26, 0.72);
}
82%, 88% {
background: #c93000;
color: #fff;
}
89%, 100% {
background: rgba(23, 20, 26, 0.1);
color: rgba(23, 20, 26, 0.72);
}
}
@keyframes db-hop-1 {
0%, 21% {
transform: scale(1);
}
24% {
transform: scale(1.22);
}
30%, 100% {
transform: scale(1);
}
}
@keyframes db-hop-2 {
0%, 41% {
transform: scale(1);
}
44% {
transform: scale(1.22);
}
50%, 100% {
transform: scale(1);
}
}
@keyframes db-hop-3 {
0%, 61% {
transform: scale(1);
}
64% {
transform: scale(1.22);
}
70%, 100% {
transform: scale(1);
}
}
@keyframes db-hop-4 {
0%, 81% {
transform: scale(1);
}
84% {
transform: scale(1.22);
}
90%, 100% {
transform: scale(1);
}
}
@keyframes db-say {
0%, 21% {
opacity: 1;
}
22%, 41% {
opacity: 0.4;
}
42%, 61% {
opacity: 1;
}
62%, 81% {
opacity: 0.4;
}
82%, 88% {
opacity: 1;
}
89%, 100% {
opacity: 0.4;
}
}
.db.is-demo .db__chip[data-milestone="25"] {
animation-name: db-hop-1;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.db.is-demo .db__chip[data-milestone="50"] {
animation-name: db-hop-2;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.db.is-demo .db__chip[data-milestone="75"] {
animation-name: db-hop-3;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.db.is-demo .db__chip[data-milestone="100"] {
animation-name: db-hop-4;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
.db.is-demo .db__chip[data-milestone="25"] {
animation-name: db-hop-1, db-cut-1;
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
}
.db.is-demo .db__chip[data-milestone="50"] {
animation-name: db-hop-2, db-cut-2;
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
}
.db.is-demo .db__chip[data-milestone="75"] {
animation-name: db-hop-3, db-cut-3;
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
}
.db.is-demo .db__chip[data-milestone="100"] {
animation-name: db-hop-4, db-cut-4;
animation-duration: 2s, 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1), steps(1, end);
animation-iteration-count: infinite, infinite;
}
.db.is-demo .db__live {
animation-name: db-say;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.db__view {
height: 84px;
}
.db__chip {
width: 28px;
height: 28px;
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.db.is-demo .db__chip,
.db.is-demo .db__fill::after,
.db.is-demo .db__live {
animation: none !important;
}
.db.is-demo .db__chip[data-milestone="25"],
.db.is-demo .db__chip[data-milestone="50"] {
background: #c93000 !important;
color: #fff !important;
}
.db.is-demo .db__fill::after {
transform: scaleX(0.5) !important;
}
}// 읽기 깊이 배지 — 25·50·75·100 에 닿으면 배지가 켜지고 한 번 튀어오른다
var db = document.getElementById('db');
var view = document.getElementById('db-view');
var live = document.getElementById('db-live');
var fill = document.getElementById('db-fill');
var role = document.getElementById('db-bar-role');
var chips = document.querySelectorAll('.db__chip');
var lit = 0;
view.addEventListener('scroll', function () {
db.classList.remove('is-demo');
var max = view.scrollHeight - view.clientHeight;
var pct = max > 0 ? Math.round(view.scrollTop / max * 100) : 0;
fill.style.transform = 'scaleX(' + (pct / 100) + ')';
role.setAttribute('aria-valuenow', pct);
var told = [ '4분의 1 읽었어요', '절반 읽었어요', '4분의 3 읽었어요', '다 읽었어요' ];
chips.forEach(function (c) {
var m = Number(c.dataset.milestone);
var on = pct >= m;
if (on && !c.classList.contains('is-on') && m > lit * 25) {
lit = m / 25;
live.textContent = told[m / 25 - 1];
}
c.classList.toggle('is-on', on);
});
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DepthMilestoneBadges.module.scss";
export type DepthMilestoneBadgesProps = {
title?: string;
lede?: string;
tail?: string;
liveStart?: string;
liveSaid?: [string, string, string, string];
groupLabel?: string;
bodyLabel?: string;
barLabel?: 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 };
const MILESTONES = [25, 50, 75, 100];
export default function DepthMilestoneBadges({
title = "이벤트 안내문",
lede = "여기서 끝까지 읽으면 쿠폰을 드려요.",
tail = "다 읽으셨어요. 오른쪽 위 배지를 확인해 보세요.",
liveStart = "읽기 시작",
liveSaid = ["4분의 1 읽었어요", "절반 읽었어요", "4분의 3 읽었어요", "다 읽었어요"],
groupLabel = "읽기 깊이 배지",
bodyLabel = "안내문 본문",
barLabel = "읽은 깊이",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#ff4d1f",
}: DepthMilestoneBadgesProps) {
const [pct, setPct] = useState(0);
const [said, setSaid] = useState(-1);
const [live, setLive] = useState(demo);
const viewRef = useRef<ScrollBox>(null);
const lastLitRef = useRef(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 25·50·75·100 — 닿는 순간 배지가 켜지고 한 번 튀어오른다
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);
MILESTONES.forEach((m, i) => {
if (now >= m && m > (lastLitRef.current || 0) * 25) {
lastLitRef.current = i + 1;
setSaid(i);
}
});
}
const cls = [styles.db, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.head}>
<p className={styles.dtitle}>{title}</p>
<p className={styles.live} aria-live="polite">{said >= 0 ? liveSaid[said] : liveStart}</p>
</header>
<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>
<footer className={styles.foot}>
<div className={styles.row} role="progressbar" aria-label={barLabel} aria-valuemin={0} aria-valuemax={100} aria-valuenow={pct}>
{MILESTONES.map((m) => (
<span className={styles.cell} key={m}>
<b className={`${styles.chip} ${pct >= m ? styles.on : ""} ${styles["m" + m]}`}>{m}</b>
</span>
))}
</div>
<span className={styles.fill} aria-hidden="true"></span>
</footer>
</section>
);
}03 · 4분의 1씩 켜지는 읽기 깊이 배지 — 측정 누적면적 4.192% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
끝까지 읽으면 쿠폰을 주는 안내문이나 읽은 깊이를 재는 사내 교육 문서에 씁니다.
.db__chip {
background: rgba(23, 20, 26, .1);
color: rgba(23, 20, 26, .45);
/* 켜짐은 보간하지 않는다 — 한 프레임에 갈아입는다 */
transition: background 140ms steps(1, end), color 140ms steps(1, end);
}
.db__chip.is-on {
background: #ff4d1f;
color: #fff;
animation-name: db-pop;
animation-duration: 300ms;
animation-timing-function: cubic-bezier(.2, 1.4, .4, 1);
animation-iteration-count: 1;
}
@keyframes db-pop {
0% { transform: scale(1); }
45% { transform: scale(1.22); }
100% { transform: scale(1); }
}
04지금 읽는 문단만 또렷해지는 초점
무엇이 보이나
가운데 띠를 지나는 문단 하나만 opacity: 1 로 또렷해지고 나머지는 0.35 로 물러나며, 왼쪽 레일의 점이 문단마다 한 칸씩 내려갑니다. 어느 문단이 가운데인지는 위아래를 45% 씩 깎은 rootMargin 으로 정하므로, 창 한가운데의 얇은 띠에 걸친 문단 하나만 뽑힙니다.

<main class="stage">
<section class="rf is-demo" id="rf" aria-label="읽는 문단 초점">
<header class="rf__head">
<p class="rf__title">서비스 이용약관</p>
<p class="rf__state" id="rf-state">지금: 문단 1</p>
</header>
<div class="rf__body">
<div class="rf__rail" aria-hidden="true"><span class="rf__dot" id="rf-dot"></span></div>
<div class="rf__view" id="rf-view" tabindex="0" role="region" aria-label="약관 본문">
<div class="rf__pad">
<p class="rf__p">첫 문단은 서비스가 무엇인지 설명합니다. 지나치면 흐려지고 가운데 오면 또렷해져요.</p>
<p class="rf__p">두 번째 문단은 계정과 회원가입에 관한 이야기를 담습니다.</p>
<p class="rf__p">세 번째 문단은 이용 중 지켜야 할 규칙을 적는 자리입니다.</p>
<p class="rf__p">네 번째 문단은 환불과 철회에 관한 안내를 담습니다.</p>
<p class="rf__p">마지막 문단은 문의처와 언제든 되돌아올 수 있다는 말로 끝나요.</p>
</div>
</div>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 지금 읽는 문단만 또렷해지는 초점 — aria-current 문단은 진하게, 나머지는 opacity 로 물러난다
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
.rf {
box-sizing: border-box;
width: min(312px, 92vw);
border-radius: $r-card;
background: #f4f4f6;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
border: 1px solid rgba(23, 20, 26, .08);
}
.rf__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
padding: $sp-2 $sp-3 0;
}
.rf__title {
margin: 0;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.rf__state {
margin: 0;
font-size: 10px;
line-height: 1.3;
color: $color;
font-weight: 700;
}
.rf__body {
display: grid;
grid-template-columns: auto 1fr;
gap: $sp-2;
padding: $sp-2 $sp-3 $sp-3;
}
// 왼쪽 레일 — 점이 내려가며 지금 문단을 가리킨다
.rf__rail {
position: relative;
width: 8px;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
}
.rf__dot {
position: absolute;
top: 2px;
left: 0;
display: block;
width: 8px;
height: 8px;
border-radius: 50%;
background: $color;
// 정적 경로 — JS 가 문단 인덱스 × 20px 로 translateY 한다
transition: transform $dur-quick $ease-spring;
}
.rf__view {
box-sizing: border-box;
height: 148px;
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
&:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
}
.rf__pad {
display: grid;
gap: $sp-3;
padding: 0 $sp-1 64px;
}
.rf__p {
margin: 0;
font-size: 11px;
line-height: 1.6;
color: $subject-ink;
// 정적(실제 사용) 경로 — .is-cur 문단만 진하게
opacity: .35;
transition: opacity $dur-quick $easing;
}
.rf__p.is-cur,
.rf__p[aria-current="true"] {
opacity: 1;
font-weight: 700;
}
// ── 격자 미리보기 루프 — 초점이 문단에서 문단으로 흘러 내려간다 ──
@keyframes rf-focus {
0% { opacity: .35; }
6% { opacity: 1; }
14% { opacity: 1; }
20%, 100% { opacity: .35; }
}
// 문단 다섯 개가 0.4s 간격으로 위 키프레임을 돈다 (2s 루프의 다섯 조각)
.rf.is-demo .rf__p:nth-child(1) { animation-name: rf-focus; animation-duration: $duration; animation-timing-function: $easing; animation-iteration-count: infinite; animation-delay: 0s; animation-fill-mode: backwards; }
.rf.is-demo .rf__p:nth-child(2) { animation-name: rf-focus; animation-duration: $duration; animation-timing-function: $easing; animation-iteration-count: infinite; animation-delay: .4s; animation-fill-mode: backwards; }
.rf.is-demo .rf__p:nth-child(3) { animation-name: rf-focus; animation-duration: $duration; animation-timing-function: $easing; animation-iteration-count: infinite; animation-delay: .8s; animation-fill-mode: backwards; }
.rf.is-demo .rf__p:nth-child(4) { animation-name: rf-focus; animation-duration: $duration; animation-timing-function: $easing; animation-iteration-count: infinite; animation-delay: 1.2s; animation-fill-mode: backwards; }
.rf.is-demo .rf__p:nth-child(5) { animation-name: rf-focus; animation-duration: $duration; animation-timing-function: $easing; animation-iteration-count: infinite; animation-delay: 1.6s; animation-fill-mode: backwards; }
// 점은 같은 박자로 다섯 칸을 지나간다 (0·20·40·60·80px) — 루프 끝에 첫 칸으로 되돌아온다
@keyframes rf-dot-move {
0%, 8% { transform: translateY(0); }
28% { transform: translateY(20px); }
48% { transform: translateY(40px); }
68% { transform: translateY(60px); }
88%, 90% { transform: translateY(80px); }
98%, 100% { transform: translateY(0); }
}
.rf.is-demo .rf__dot {
animation-name: rf-dot-move;
animation-duration: $duration;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
@keyframes rf-say {
0%, 27% { opacity: 1; }
28%, 47% { opacity: .4; }
48%, 67% { opacity: 1; }
68%, 87% { opacity: .4; }
88%, 100% { opacity: 1; }
}
.rf.is-demo .rf__state {
animation-name: rf-say;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.rf__view { height: 124px; }
.rf__p { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.rf.is-demo .rf__p,
.rf.is-demo .rf__dot,
.rf.is-demo .rf__state { animation: none !important; transform: none !important; }
// 정지 상태에서도 초점의 뜻은 보인다 — 3번 문단만 진하게
.rf.is-demo .rf__p:nth-child(3) { opacity: 1 !important; font-weight: 700; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rf {
box-sizing: border-box;
width: min(312px, 92vw);
border-radius: 16px;
background: #f4f4f6;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
border: 1px solid rgba(23, 20, 26, 0.08);
}
.rf__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 8px 12px 0;
}
.rf__title {
margin: 0;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.rf__state {
margin: 0;
font-size: 10px;
line-height: 1.3;
color: #0a4fe8;
font-weight: 700;
}
.rf__body {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px;
padding: 8px 12px 12px;
}
.rf__rail {
position: relative;
width: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
}
.rf__dot {
position: absolute;
top: 2px;
left: 0;
display: block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #0a4fe8;
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rf__view {
box-sizing: border-box;
height: 148px;
overflow-y: auto;
scrollbar-width: none;
}
.rf__view::-webkit-scrollbar {
display: none;
}
.rf__view:focus-visible {
outline: 2px solid #0a4fe8;
outline-offset: 2px;
}
.rf__pad {
display: grid;
gap: 12px;
padding: 0 4px 64px;
}
.rf__p {
margin: 0;
font-size: 11px;
line-height: 1.6;
color: #17141a;
opacity: 0.35;
transition: opacity 220ms ease-out;
}
.rf__p.is-cur,
.rf__p[aria-current=true] {
opacity: 1;
font-weight: 700;
}
@keyframes rf-focus {
0% {
opacity: 0.35;
}
6% {
opacity: 1;
}
14% {
opacity: 1;
}
20%, 100% {
opacity: 0.35;
}
}
.rf.is-demo .rf__p:nth-child(1) {
animation-name: rf-focus;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-delay: 0s;
animation-fill-mode: backwards;
}
.rf.is-demo .rf__p:nth-child(2) {
animation-name: rf-focus;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-delay: 0.4s;
animation-fill-mode: backwards;
}
.rf.is-demo .rf__p:nth-child(3) {
animation-name: rf-focus;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-delay: 0.8s;
animation-fill-mode: backwards;
}
.rf.is-demo .rf__p:nth-child(4) {
animation-name: rf-focus;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-delay: 1.2s;
animation-fill-mode: backwards;
}
.rf.is-demo .rf__p:nth-child(5) {
animation-name: rf-focus;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-delay: 1.6s;
animation-fill-mode: backwards;
}
@keyframes rf-dot-move {
0%, 8% {
transform: translateY(0);
}
28% {
transform: translateY(20px);
}
48% {
transform: translateY(40px);
}
68% {
transform: translateY(60px);
}
88%, 90% {
transform: translateY(80px);
}
98%, 100% {
transform: translateY(0);
}
}
.rf.is-demo .rf__dot {
animation-name: rf-dot-move;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes rf-say {
0%, 27% {
opacity: 1;
}
28%, 47% {
opacity: 0.4;
}
48%, 67% {
opacity: 1;
}
68%, 87% {
opacity: 0.4;
}
88%, 100% {
opacity: 1;
}
}
.rf.is-demo .rf__state {
animation-name: rf-say;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.rf__view {
height: 124px;
}
.rf__p {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.rf.is-demo .rf__p,
.rf.is-demo .rf__dot,
.rf.is-demo .rf__state {
animation: none !important;
transform: none !important;
}
.rf.is-demo .rf__p:nth-child(3) {
opacity: 1 !important;
font-weight: 700;
}
}// 지금 읽는 문단만 또렷해지는 초점 — IntersectionObserver 가 가운데 띠를 지나는 문단 하나만 고른다
var rf = document.getElementById('rf');
var view = document.getElementById('rf-view');
var dot = document.getElementById('rf-dot');
var state = document.getElementById('rf-state');
var ps = Array.prototype.slice.call(document.querySelectorAll('.rf__p'));
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
var i = ps.indexOf(e.target);
ps.forEach(function (p, j) {
p.classList.toggle('is-cur', i === j);
if (i === j) p.setAttribute('aria-current', 'true'); else p.removeAttribute('aria-current');
});
dot.style.transform = 'translateY(' + (i * 20) + 'px)';
state.textContent = '지금: 문단 ' + (i + 1);
});
}, { root: view, rootMargin: '-45% 0px -45% 0px', threshold: 0 });
ps.forEach(function (p) { io.observe(p); });
// 격자 루프는 사람이 손댄 순간에만 끝난다 — IO 는 페이지 열자마자도 뛰므로 여기서 끄지 않는다
['wheel', 'pointerdown', 'touchstart', 'keydown'].forEach(function (ev) {
view.addEventListener(ev, function () { rf.classList.remove('is-demo'); }, { passive: true, once: true });
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ReadFocusParagraph.module.scss";
export type ReadFocusParagraphProps = {
title?: string;
stateFormat?: string;
paragraphs?: 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; clientHeight: number };
type ParaBox = { offsetTop: number; offsetHeight: number };
const DEFAULT_PARAGRAPHS = [
"첫 문단은 서비스가 무엇인지 설명합니다. 지나치면 흐려지고 가운데 오면 또렷해져요.",
"두 번째 문단은 계정과 회원가입에 관한 이야기를 담습니다.",
"세 번째 문단은 이용 중 지켜야 할 규칙을 적는 자리입니다.",
"네 번째 문단은 환불과 철회에 관한 안내를 담습니다.",
"마지막 문단은 문의처와 언제든 되돌아올 수 있다는 말로 끝나요.",
];
export default function ReadFocusParagraph({
title = "서비스 이용약관",
stateFormat = "지금: 문단 {n}",
paragraphs = DEFAULT_PARAGRAPHS,
groupLabel = "읽는 문단 초점",
bodyLabel = "약관 본문",
demo = true,
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
}: ReadFocusParagraphProps) {
const [current, setCurrent] = useState(0);
const [live, setLive] = useState(demo);
const viewRef = useRef<ScrollBox>(null);
const paraRefs = useRef<(ParaBox | null)[]>([]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 가운데 띠(-45% ~ -45%)를 지나는 문단 하나만 고른다.
// 로컬 타입(ScrollBox·ParaBox)과 DOM 의 Element 는 겹치지 않으므로 관측기에 넘길 때만 unknown 을 거쳐 단언한다
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const io = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
if (!e.isIntersecting) return;
const list = paraRefs.current || [];
const i = list.indexOf(e.target as unknown as ParaBox);
if (i >= 0) setCurrent(i);
});
},
{ root: view as unknown as Element, rootMargin: "-45% 0px -45% 0px", threshold: 0 }
);
(paraRefs.current || []).forEach((p) => { if (p) io.observe(p as unknown as Element); });
return () => io.disconnect();
}, []);
function touch() {
setLive(false);
}
const cls = [styles.rf, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.head}>
<p className={styles.rtitle}>{title}</p>
<p className={styles.state}>{stateFormat.replace("{n}", String(current + 1))}</p>
</header>
<div className={styles.body}>
<div className={styles.rail} aria-hidden="true">
<span className={styles.dot} style={{ transform: `translateY(${current * 20}px)` }}></span>
</div>
<div
className={styles.view}
ref={viewRef}
tabIndex={0}
role="region"
aria-label={bodyLabel}
onWheel={touch}
onPointerDown={touch}
onTouchStart={touch}
onKeyDown={touch}
>
<div className={styles.pad}>
{paragraphs.map((p, i) => (
<p
key={p}
ref={(el: ParaBox | null) => { if (paraRefs.current) paraRefs.current[i] = el; }}
className={`${styles.p} ${styles["p" + (i + 1)]} ${i === current ? styles.cur : ""}`}
aria-current={i === current ? "true" : undefined}
>
{p}
</p>
))}
</div>
</div>
</div>
</section>
);
}04 · 지금 읽는 문단만 또렷해지는 초점 — 측정 누적면적 4.237% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
긴 계약서나 약관처럼 줄을 놓치면 곤란한 문서, 소리 내어 읽는 대본에 씁니다.
// 가운데 띠를 지나는 문단 하나만 골라 aria-current 를 옮겨 단다
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
var i = ps.indexOf(e.target);
ps.forEach(function (p, j) {
p.classList.toggle('is-cur', i === j);
if (i === j) p.setAttribute('aria-current', 'true'); else p.removeAttribute('aria-current');
});
dot.style.transform = 'translateY(' + (i * 20) + 'px)';
state.textContent = '지금: 문단 ' + (i + 1);
});
}, { root: view, rootMargin: '-45% 0px -45% 0px', threshold: 0 });
ps.forEach(function (p) { io.observe(p); });
05몇 번째 구간인지 세어 주는 카운터
화면에서 일어나는 일
구간을 지날 때마다 머리의 알약 안의 숫자가 바뀌고 지금 구간이 파란 칸으로 켜집니다. 스크롤에는 scroll-snap-type: y mandatory 가 걸려 있어 장면이 중간에서 멈추지 않고, 위아래 화살표 키는 scrollTo 로 앞뒤 장면의 offsetTop 에 정확히 내려놓습니다.

<main class="stage">
<section class="sc is-demo" id="sc" aria-label="구간 카운터">
<header class="sc__head">
<p class="sc__title">제품 소개 자료</p>
<p class="sc__count">
<b class="sc__now" id="sc-now">1</b>
<span class="sc__num sc__num--1" aria-hidden="true">1</span>
<span class="sc__num sc__num--3" aria-hidden="true">3</span>
<span class="sc__num sc__num--6" aria-hidden="true">6</span>
<span class="sc__num sc__num--9" aria-hidden="true">9</span>
<span class="sc__slash"> / 9</span>
</p>
</header>
<div class="sc__view" id="sc-view" tabindex="0" role="region" aria-label="장면 목록 — 위아래 화살표 키로 이동">
<div class="sc__track" id="sc-track">
<article class="sc__sec" data-i="0"><b>01</b><span>처음 보는 화면</span></article>
<article class="sc__sec" data-i="1"><b>02</b><span>무엇을 만들까요</span></article>
<article class="sc__sec" data-i="2"><b>03</b><span>재료를 고르세요</span></article>
<article class="sc__sec" data-i="3"><b>04</b><span>조립이 시작돼요</span></article>
<article class="sc__sec" data-i="4"><b>05</b><span>색을 입힙니다</span></article>
<article class="sc__sec" data-i="5"><b>06</b><span>움직임을 붙여요</span></article>
<article class="sc__sec" data-i="6"><b>07</b><span>검사를 지나가요</span></article>
<article class="sc__sec" data-i="7"><b>08</b><span>포장하는 장면</span></article>
<article class="sc__sec" data-i="8"><b>09</b><span>다음 이야기로</span></article>
</div>
</div>
<footer class="sc__keys">
<button class="sc__key" type="button" id="sc-up" aria-label="이전 장면"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M208,160H48l80-80Z" opacity="0.2"/><path d="M213.66,154.34l-80-80a8,8,0,0,0-11.32,0l-80,80A8,8,0,0,0,48,168H208a8,8,0,0,0,5.66-13.66ZM67.31,152,128,91.31,188.69,152Z"/></svg></button>
<span class="sc__hint">화살표 키로도 넘어가요</span>
<button class="sc__key" type="button" id="sc-down" aria-label="다음 장면"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M208,96l-80,80L48,96Z" opacity="0.2"/><path d="M215.39,92.94A8,8,0,0,0,208,88H48a8,8,0,0,0-5.66,13.66l80,80a8,8,0,0,0,11.32,0l80-80A8,8,0,0,0,215.39,92.94ZM128,164.69,67.31,104H188.69Z"/></svg></button>
</footer>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 구간 카운터 — 장면을 지날 때마다 「3 / 9」 가 바뀌고 화살표 키로도 앞뒤로 넘어간다
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
.sc {
position: relative;
box-sizing: border-box;
width: min(300px, 92vw);
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
overflow: hidden;
}
.sc__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
padding: $sp-2 $sp-3 0;
}
.sc__title {
margin: 0;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.sc__count {
position: relative;
display: inline-flex;
align-items: baseline;
gap: 0;
margin: 0;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
background: $subject-ink;
color: $stage-yellow;
font-size: 12px;
line-height: 1.2;
}
.sc__now { font-size: 14px; font-weight: 800; font-variant-numeric: tabular-nums; }
.sc__slash { opacity: .65; font-size: 10px; }
// 격자 미리보기에서 카운터 숫자 넷을 같은 자리에 겹쳐 두고 컷으로 갈아 끼운다
.sc__num {
display: none;
font-size: 14px;
font-weight: 800;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.sc__view {
box-sizing: border-box;
height: 128px;
margin: $sp-2 $sp-2 0;
overflow-y: auto;
scrollbar-width: none;
scroll-snap-type: y mandatory;
&::-webkit-scrollbar { display: none; }
&:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
}
.sc__track {
display: grid;
}
.sc__sec {
display: flex;
align-items: center;
gap: $sp-2;
height: 128px;
padding: 0 $sp-3;
border-radius: $r-sm;
background: rgba(23, 20, 26, .05);
font-size: 12px;
line-height: 1.4;
scroll-snap-align: start;
scroll-snap-stop: always;
// 정적 경로 — aria-current 장면만 진하게
opacity: .6;
transition: opacity $dur-quick $easing, background $dur-quick $easing;
}
.sc__sec b { font-size: 12px; opacity: .5; }
.sc__sec[aria-current="true"] {
opacity: 1;
background: $subject-blue;
color: #fff;
}
.sc__sec[aria-current="true"] b { opacity: .85; }
.sc__keys {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
padding: $sp-2 $sp-3;
}
.sc__key {
display: inline-grid;
place-items: center;
width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: 0;
border-radius: $r-control;
background: $subject-ink;
color: $stage-yellow;
font-size: 16px;
line-height: 1;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:focus-visible { outline: 2px solid $subject-blue; outline-offset: 2px; }
&:active { transform: scale(.9); }
}
.sc__hint {
margin: 0;
font-size: 10px;
line-height: 1.4;
color: rgba($stage-ink, .72);
}
// ── 격자 미리보기 루프 — 장면이 1 → 3 → 6 → 9 를 지나고 88% 에 첫 장면으로 돌아온다 ──
@keyframes sc-flow {
0%, 26% { transform: translateY(0); }
34%, 54% { transform: translateY(-256px); }
62%, 76% { transform: translateY(-640px); }
84% { transform: translateY(-1024px); }
88%, 100% { transform: translateY(0); }
}
// 숫자는 각자의 창에만 보이는 steps 컷 — 반투명 겹침이 없다
@keyframes sc-n1 { 0%, 29% { opacity: 1; } 30%, 87% { opacity: 0; } 88%, 100% { opacity: 1; } }
@keyframes sc-n3 { 0%, 33% { opacity: 0; } 34%, 53% { opacity: 1; } 54%, 100% { opacity: 0; } }
@keyframes sc-n6 { 0%, 61% { opacity: 0; } 62%, 75% { opacity: 1; } 76%, 100% { opacity: 0; } }
@keyframes sc-n9 { 0%, 83% { opacity: 0; } 84%, 87% { opacity: 1; } 88%, 100% { opacity: 0; } }
.sc.is-demo {
// 숫자 칸의 폭은 .sc__now 가 그대로 잡아 둔다 — display:none 으로 빼면 알약이 슬래시 폭으로 쪼그라들어
// 겹쳐 둔 숫자가 「/ 9」 위로 올라탄다 (실측: 알약이 「/6」 으로 보였다)
.sc__now { color: transparent; }
.sc__num { display: block; position: absolute; left: $sp-2; }
.sc__num--1 { animation-name: sc-n1; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.sc__num--3 { animation-name: sc-n3; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.sc__num--6 { animation-name: sc-n6; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.sc__num--9 { animation-name: sc-n9; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.sc__track {
animation-name: sc-flow;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
// 미리보기 동안에는 스냅이 트랙을 다시 붙잡지 않게 한다 (262 실측)
scroll-snap-type: none;
}
// 정적(스냅) 경로에서 aria-current 는 JS 가 다니, 미리보기에서는 첫 장면이 켜진 모습으로 시작한다
.sc__sec[data-i="0"] { opacity: 1; background: $subject-blue; color: #fff; }
@keyframes sc-sec-0 { 0%, 29% { opacity: 1; background: $subject-blue; color: #fff; } 30%, 87% { opacity: .6; background: rgba(23, 20, 26, .05); color: $subject-ink; } 88%, 100% { opacity: 1; background: $subject-blue; color: #fff; } }
.sc__sec[data-i="0"] { animation-name: sc-sec-0; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
@keyframes sc-sec-2 { 0%, 33% { opacity: .6; background: rgba(23, 20, 26, .05); color: $subject-ink; } 34%, 53% { opacity: 1; background: $subject-blue; color: #fff; } 54%, 100% { opacity: .6; background: rgba(23, 20, 26, .05); color: $subject-ink; } }
.sc__sec[data-i="2"] { animation-name: sc-sec-2; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
@keyframes sc-sec-5 { 0%, 61% { opacity: .6; background: rgba(23, 20, 26, .05); color: $subject-ink; } 62%, 75% { opacity: 1; background: $subject-blue; color: #fff; } 76%, 100% { opacity: .6; background: rgba(23, 20, 26, .05); color: $subject-ink; } }
.sc__sec[data-i="5"] { animation-name: sc-sec-5; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
@keyframes sc-sec-8 { 0%, 83% { opacity: .6; background: rgba(23, 20, 26, .05); color: $subject-ink; } 84%, 87% { opacity: 1; background: $subject-blue; color: #fff; } 88%, 100% { opacity: .6; background: rgba(23, 20, 26, .05); color: $subject-ink; } }
.sc__sec[data-i="8"] { animation-name: sc-sec-8; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
}
@media (max-width: 340px) {
// 폰 320×200 무대의 세로 여유는 174px — 머리 31 + 키 줄 48 을 빼면 장면 창은 84px 이 상한
.sc__view { height: 84px; }
.sc__sec { height: 84px; font-size: 11px; }
// 장면 높이가 바뀌면 루프의 이동 거리도 같이 바뀌어야 한다 (84 × 2·5·8)
@keyframes sc-flow {
0%, 26% { transform: translateY(0); }
34%, 54% { transform: translateY(-168px); }
62%, 76% { transform: translateY(-420px); }
84% { transform: translateY(-672px); }
88%, 100% { transform: translateY(0); }
}
}
@media (prefers-reduced-motion: reduce) {
.sc.is-demo .sc__track,
.sc.is-demo .sc__num,
.sc.is-demo .sc__sec { animation: none !important; transform: none !important; }
// 정지 상태 — 첫 장면과 카운터 1 만 보인다
.sc.is-demo .sc__num--1 { opacity: 1 !important; }
.sc.is-demo .sc__num--3,
.sc.is-demo .sc__num--6,
.sc.is-demo .sc__num--9 { opacity: 0 !important; }
.sc.is-demo .sc__sec[data-i="0"] { opacity: 1 !important; background: $subject-blue !important; color: #fff !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);
}
.sc {
position: relative;
box-sizing: border-box;
width: min(300px, 92vw);
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
overflow: hidden;
}
.sc__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 8px 12px 0;
}
.sc__title {
margin: 0;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.sc__count {
position: relative;
display: inline-flex;
align-items: baseline;
gap: 0;
margin: 0;
padding: 4px 8px;
border-radius: 999px;
background: #17141a;
color: #ffd23f;
font-size: 12px;
line-height: 1.2;
}
.sc__now {
font-size: 14px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.sc__slash {
opacity: 0.65;
font-size: 10px;
}
.sc__num {
display: none;
font-size: 14px;
font-weight: 800;
line-height: 1.2;
font-variant-numeric: tabular-nums;
}
.sc__view {
box-sizing: border-box;
height: 128px;
margin: 8px 8px 0;
overflow-y: auto;
scrollbar-width: none;
scroll-snap-type: y mandatory;
}
.sc__view::-webkit-scrollbar {
display: none;
}
.sc__view:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.sc__track {
display: grid;
}
.sc__sec {
display: flex;
align-items: center;
gap: 8px;
height: 128px;
padding: 0 12px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
font-size: 12px;
line-height: 1.4;
scroll-snap-align: start;
scroll-snap-stop: always;
opacity: 0.6;
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1), background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.sc__sec b {
font-size: 12px;
opacity: 0.5;
}
.sc__sec[aria-current=true] {
opacity: 1;
background: #2f6df6;
color: #fff;
}
.sc__sec[aria-current=true] b {
opacity: 0.85;
}
.sc__keys {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
}
.sc__key {
display: inline-grid;
place-items: center;
width: 32px;
height: 32px;
margin: 0;
padding: 0;
border: 0;
border-radius: 12px;
background: #17141a;
color: #ffd23f;
font-size: 16px;
line-height: 1;
cursor: pointer;
transition: transform 140ms ease-out;
}
.sc__key:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.sc__key:active {
transform: scale(0.9);
}
.sc__hint {
margin: 0;
font-size: 10px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.72);
}
@keyframes sc-flow {
0%, 26% {
transform: translateY(0);
}
34%, 54% {
transform: translateY(-256px);
}
62%, 76% {
transform: translateY(-640px);
}
84% {
transform: translateY(-1024px);
}
88%, 100% {
transform: translateY(0);
}
}
@keyframes sc-n1 {
0%, 29% {
opacity: 1;
}
30%, 87% {
opacity: 0;
}
88%, 100% {
opacity: 1;
}
}
@keyframes sc-n3 {
0%, 33% {
opacity: 0;
}
34%, 53% {
opacity: 1;
}
54%, 100% {
opacity: 0;
}
}
@keyframes sc-n6 {
0%, 61% {
opacity: 0;
}
62%, 75% {
opacity: 1;
}
76%, 100% {
opacity: 0;
}
}
@keyframes sc-n9 {
0%, 83% {
opacity: 0;
}
84%, 87% {
opacity: 1;
}
88%, 100% {
opacity: 0;
}
}
.sc.is-demo .sc__now {
color: transparent;
}
.sc.is-demo .sc__num {
display: block;
position: absolute;
left: 8px;
}
.sc.is-demo .sc__num--1 {
animation-name: sc-n1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sc.is-demo .sc__num--3 {
animation-name: sc-n3;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sc.is-demo .sc__num--6 {
animation-name: sc-n6;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sc.is-demo .sc__num--9 {
animation-name: sc-n9;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sc.is-demo .sc__track {
animation-name: sc-flow;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
scroll-snap-type: none;
}
.sc.is-demo .sc__sec[data-i="0"] {
opacity: 1;
background: #2f6df6;
color: #fff;
}
@keyframes sc-sec-0 {
0%, 29% {
opacity: 1;
background: #2f6df6;
color: #fff;
}
30%, 87% {
opacity: 0.6;
background: rgba(23, 20, 26, 0.05);
color: #17141a;
}
88%, 100% {
opacity: 1;
background: #2f6df6;
color: #fff;
}
}
.sc.is-demo .sc__sec[data-i="0"] {
animation-name: sc-sec-0;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sc-sec-2 {
0%, 33% {
opacity: 0.6;
background: rgba(23, 20, 26, 0.05);
color: #17141a;
}
34%, 53% {
opacity: 1;
background: #2f6df6;
color: #fff;
}
54%, 100% {
opacity: 0.6;
background: rgba(23, 20, 26, 0.05);
color: #17141a;
}
}
.sc.is-demo .sc__sec[data-i="2"] {
animation-name: sc-sec-2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sc-sec-5 {
0%, 61% {
opacity: 0.6;
background: rgba(23, 20, 26, 0.05);
color: #17141a;
}
62%, 75% {
opacity: 1;
background: #2f6df6;
color: #fff;
}
76%, 100% {
opacity: 0.6;
background: rgba(23, 20, 26, 0.05);
color: #17141a;
}
}
.sc.is-demo .sc__sec[data-i="5"] {
animation-name: sc-sec-5;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sc-sec-8 {
0%, 83% {
opacity: 0.6;
background: rgba(23, 20, 26, 0.05);
color: #17141a;
}
84%, 87% {
opacity: 1;
background: #2f6df6;
color: #fff;
}
88%, 100% {
opacity: 0.6;
background: rgba(23, 20, 26, 0.05);
color: #17141a;
}
}
.sc.is-demo .sc__sec[data-i="8"] {
animation-name: sc-sec-8;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.sc__view {
height: 84px;
}
.sc__sec {
height: 84px;
font-size: 11px;
}
@keyframes sc-flow {
0%, 26% {
transform: translateY(0);
}
34%, 54% {
transform: translateY(-168px);
}
62%, 76% {
transform: translateY(-420px);
}
84% {
transform: translateY(-672px);
}
88%, 100% {
transform: translateY(0);
}
}
}
@media (prefers-reduced-motion: reduce) {
.sc.is-demo .sc__track,
.sc.is-demo .sc__num,
.sc.is-demo .sc__sec {
animation: none !important;
transform: none !important;
}
.sc.is-demo .sc__num--1 {
opacity: 1 !important;
}
.sc.is-demo .sc__num--3,
.sc.is-demo .sc__num--6,
.sc.is-demo .sc__num--9 {
opacity: 0 !important;
}
.sc.is-demo .sc__sec[data-i="0"] {
opacity: 1 !important;
background: #2f6df6 !important;
color: #fff !important;
}
}// 구간 카운터 — 스크롤로 지나간 장면을 세고, keydown 화살표로도 앞뒤 장면을 부른다
var sc = document.getElementById('sc');
var view = document.getElementById('sc-view');
var now = document.getElementById('sc-now');
var secs = Array.prototype.slice.call(document.querySelectorAll('.sc__sec'));
function currentIndex() {
var mid = view.scrollTop + view.clientHeight / 2;
var best = 0, dist = 1e9;
secs.forEach(function (s, i) {
var d = Math.abs(s.offsetTop + s.offsetHeight / 2 - mid);
if (d < dist) { dist = d; best = i; }
});
return best;
}
function paint() {
var i = currentIndex();
now.textContent = i + 1;
secs.forEach(function (s, j) {
if (j === i) s.setAttribute('aria-current', 'true');
else s.removeAttribute('aria-current');
});
}
function goTo(i) {
sc.classList.remove('is-demo');
i = Math.max(0, Math.min(secs.length - 1, i));
view.scrollTo({ top: secs[i].offsetTop, behavior: 'smooth' });
}
view.addEventListener('scroll', function () { sc.classList.remove('is-demo'); paint(); }, { passive: true });
view.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { e.preventDefault(); goTo(currentIndex() + 1); }
if (e.key === 'ArrowUp') { e.preventDefault(); goTo(currentIndex() - 1); }
});
document.getElementById('sc-up').addEventListener('click', function () { goTo(currentIndex() - 1); });
document.getElementById('sc-down').addEventListener('click', function () { goTo(currentIndex() + 1); });import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SectionCounterKeys.module.scss";
export type SectionCounterKeysProps = {
title?: string;
scenes?: [string, string][];
hint?: string;
groupLabel?: string;
listLabel?: string;
prevLabel?: string;
nextLabel?: 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; clientHeight: number; scrollTo: (o: { top: number; behavior: string }) => void };
type SecBox = { offsetTop: number; offsetHeight: number };
type KeyEvt = { key: string; preventDefault(): void };
const DEFAULT_SCENES: [string, string][] = [
["01", "처음 보는 화면"],
["02", "무엇을 만들까요"],
["03", "재료를 고르세요"],
["04", "조립이 시작돼요"],
["05", "색을 입힙니다"],
["06", "움직임을 붙여요"],
["07", "검사를 지나가요"],
["08", "포장하는 장면"],
["09", "다음 이야기로"],
];
function CaretUpIcon() {
return <svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M208,160H48l80-80Z" opacity="0.2"/><path d="M213.66,154.34l-80-80a8,8,0,0,0-11.32,0l-80,80A8,8,0,0,0,48,168H208a8,8,0,0,0,5.66-13.66ZM67.31,152,128,91.31,188.69,152Z"/></svg>;
}
function CaretDownIcon() {
return <svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M208,96l-80,80L48,96Z" opacity="0.2"/><path d="M215.39,92.94A8,8,0,0,0,208,88H48a8,8,0,0,0-5.66,13.66l80,80a8,8,0,0,0,11.32,0l80-80A8,8,0,0,0,215.39,92.94ZM128,164.69,67.31,104H188.69Z"/></svg>;
}
export default function SectionCounterKeys({
title = "제품 소개 자료",
scenes = DEFAULT_SCENES,
hint = "화살표 키로도 넘어가요",
groupLabel = "구간 카운터",
listLabel = "장면 목록 — 위아래 화살표 키로 이동",
prevLabel = "이전 장면",
nextLabel = "다음 장면",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: SectionCounterKeysProps) {
const [current, setCurrent] = useState(0);
const [live, setLive] = useState(demo);
const viewRef = useRef<ScrollBox>(null);
const secRefs = useRef<(SecBox | null)[]>([]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 뷰포트 가운데에 가장 가까운 장면이 몇 번째인지
function pick(): number {
const view = viewRef.current;
if (!view) return 0;
const mid = view.scrollTop + view.clientHeight / 2;
let best = 0;
let dist = 1e9;
(secRefs.current || []).forEach((s, i) => {
if (!s) return;
const d = Math.abs(s.offsetTop + s.offsetHeight / 2 - mid);
if (d < dist) { dist = d; best = i; }
});
return best;
}
function onScroll() {
setLive(false);
setCurrent(pick());
}
function go(i: number) {
setLive(false);
const view = viewRef.current;
const s = (secRefs.current || [])[Math.max(0, Math.min(scenes.length - 1, i))];
if (!view || !s) return;
view.scrollTo({ top: s.offsetTop, behavior: "smooth" });
}
// keydown — 위아래 화살표로 앞뒤 장면을 부른다
function onKey(e: KeyEvt) {
if (e.key === "ArrowDown") { e.preventDefault(); go(pick() + 1); }
if (e.key === "ArrowUp") { e.preventDefault(); go(pick() - 1); }
}
const cls = [styles.sc, live ? styles.isDemo : ""].filter(Boolean).join(" ");
const nums = ["n1", "n3", "n6", "n9"];
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.head}>
<p className={styles.stitle}>{title}</p>
<p className={styles.count}>
{live ? (
nums.map((n, i) => (
<span className={`${styles.num} ${styles[n]}`} key={n} aria-hidden="true">{[1, 3, 6, 9][i]}</span>
))
) : (
<b className={styles.now}>{current + 1}</b>
)}
<span className={styles.slash}> / {scenes.length}</span>
</p>
</header>
<div className={styles.view} ref={viewRef} onScroll={onScroll} onKeyDown={onKey} tabIndex={0} role="region" aria-label={listLabel}>
<div className={styles.track}>
{scenes.map(([num, label], i) => (
<article
key={num}
ref={(el: SecBox | null) => { if (secRefs.current) secRefs.current[i] = el; }}
className={`${styles.sec} ${styles["s" + i]}`}
aria-current={i === current ? "true" : undefined}
>
<b>{num}</b><span>{label}</span>
</article>
))}
</div>
</div>
<footer className={styles.keys}>
<button className={styles.key} type="button" aria-label={prevLabel} onClick={() => go(pick() - 1)}>
<CaretUpIcon />
</button>
<span className={styles.hint}>{hint}</span>
<button className={styles.key} type="button" aria-label={nextLabel} onClick={() => go(pick() + 1)}>
<CaretDownIcon />
</button>
</footer>
</section>
);
}05 · 몇 번째 구간인지 세어 주는 카운터 — 측정 누적면적 25.274% · 움직인 프레임 10/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
한 화면에 한 장면씩 넘기는 발표 자료나 단계가 정해진 설명 문서에 씁니다.
function paint() {
var i = currentIndex();
now.textContent = i + 1;
secs.forEach(function (s, j) {
if (j === i) s.setAttribute('aria-current', 'true');
else s.removeAttribute('aria-current');
});
}
function goTo(i) {
sc.classList.remove('is-demo');
i = Math.max(0, Math.min(secs.length - 1, i));
view.scrollTo({ top: secs[i].offsetTop, behavior: 'smooth' });
}
view.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { e.preventDefault(); goTo(currentIndex() + 1); }
if (e.key === 'ArrowUp') { e.preventDefault(); goTo(currentIndex() - 1); }
});
06옆으로 더 있다고 알려 주는 가장자리 그늘
어떤 움직임인가
양 끝에 32px 짜리 그러데이션 그늘이 덮여 카드가 그 아래로 녹아 들어가고, 한쪽 끝에 닿으면 그쪽 그늘만 220ms 만에 꺼집니다. 그늘은 pointer-events: none 이라 그 위를 눌러도 밑의 카드가 그대로 잡히고, 오른쪽 그늘에 얹힌 작은 화살표가 4px 밀렸다 돌아와 아직 남았다고 말합니다.

<main class="stage">
<section class="ot is-demo" id="ot" aria-label="옆으로 더 있다는 가장자리 알림">
<header class="ot__head">
<p class="ot__title">요금 비교표</p>
<p class="ot__state" id="ot-state">옆으로 더 있어요</p>
</header>
<div class="ot__view" id="ot-view" tabindex="0" role="region" aria-label="요금표 — 좌우로 살펴보기">
<div class="ot__track" id="ot-track">
<div class="ot__card"><b>무료</b><span>0원</span><i>한 명용</i></div>
<div class="ot__card"><b>기본</b><span>9,000원</span><i>다섯 명용</i></div>
<div class="ot__card"><b>팀</b><span>19,000원</span><i>열 명용</i></div>
<div class="ot__card"><b>단체</b><span>39,000원</span><i>제한 없음</i></div>
</div>
</div>
<div class="ot__shade ot__shade--l" id="ot-l" aria-hidden="true"></div>
<div class="ot__shade ot__shade--r" id="ot-r" aria-hidden="true">
<span class="ot__nudge"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M176,128,96,208V48Z" opacity="0.2"/><path d="M181.66,122.34l-80-80A8,8,0,0,0,88,48V208a8,8,0,0,0,13.66,5.66l80-80A8,8,0,0,0,181.66,122.34ZM104,188.69V67.31L164.69,128Z"/></svg></span>
</div>
<p class="ot__note" aria-hidden="true">양 끝이 흐려진 곳까지가 아직 안 읽은 부분이에요</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 옆으로 더 있다고 알려 주는 가장자리 그늘 — 스크롤포트와 트랙을 분리하고 transform 은 트랙에만
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-ink;
@include stage($stage-yellow);
.ot {
position: relative;
box-sizing: border-box;
width: min(300px, 92vw);
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
overflow: hidden;
}
.ot__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
padding: $sp-2 $sp-3 0;
}
.ot__title {
margin: 0;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.ot__state {
margin: 0;
font-size: 10px;
line-height: 1.3;
color: rgba($stage-ink, .72);
}
// 스크롤포트(overflow) — 이 상자 자체에는 transform 을 걸지 않는다 (051 실측)
.ot__view {
box-sizing: border-box;
height: 116px;
margin: $sp-2 $sp-2 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
scroll-snap-type: x mandatory;
&::-webkit-scrollbar { display: none; }
&:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
}
.ot__track {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 72%;
gap: $sp-2;
width: max-content;
height: 100%;
padding: 0 $sp-1;
}
.ot__card {
box-sizing: border-box;
display: grid;
align-content: center;
justify-items: center;
gap: $sp-1;
padding: $sp-2;
border-radius: $r-control;
background: #f0ece2;
scroll-snap-align: center;
}
.ot__card b { font-size: 11px; line-height: 1.3; }
.ot__card span { font-size: 16px; font-weight: 800; line-height: 1.2; color: $subject-blue-deep; }
.ot__card i { font-size: 9px; font-style: normal; line-height: 1.3; color: rgba($stage-ink, .72); }
// ── 가장자리 그늘 — 뷰 위에 얹힌다. linear-gradient 로 카드 끝을 감쪽같이 녹인다 ──
// 그늘의 세로 범위는 스크롤 상자와 정확히 같아야 한다 — 머리말·아래 설명까지 덮으면
// 카드 전체가 흐려 보인다 (실측: 상자는 카드 위에서 32px, 아래에서 30px 자리에 있다)
.ot__shade {
position: absolute;
top: 32px;
bottom: 30px;
width: 32px;
pointer-events: none;
opacity: 1;
// 정적 경로 — 한쪽 끝에 닿으면 그쪽만 .is-off 로 꺼진다
transition: opacity $dur-quick $easing;
}
.ot__shade--l {
left: $sp-2;
background: linear-gradient(to right, $subject-cream, rgba(255, 247, 230, 0));
}
.ot__shade--r {
right: $sp-2;
background: linear-gradient(to left, $subject-cream, rgba(255, 247, 230, 0));
display: grid;
align-items: end;
justify-items: center;
}
.ot__shade.is-off { opacity: 0; }
.ot__nudge {
display: inline-grid;
place-items: center;
width: 24px;
height: 24px;
margin-bottom: $sp-2;
border-radius: 50%;
background: $subject-ink;
color: $stage-yellow;
font-size: 13px;
line-height: 1;
}
.ot__note {
margin: 0;
padding: $sp-2 $sp-3;
font-size: 10px;
line-height: 1.4;
color: rgba($stage-ink, .72);
}
// ── 격자 미리보기 루프 — 트랙이 오른쪽으로 살짝 밀렸다 돌아오고 그늘·화살표가 호흡한다 ──
@keyframes ot-slide {
0%, 16% { transform: translateX(0); }
44%, 72% { transform: translateX(-56%); }
92%, 100% { transform: translateX(0); }
}
@keyframes ot-shade-l {
0%, 16% { opacity: 1; }
20%, 72% { opacity: 0; }
80%, 100% { opacity: 1; }
}
@keyframes ot-shade-r {
0% { opacity: 1; }
44%, 72% { opacity: 0; }
80%, 100% { opacity: 1; }
}
@keyframes ot-nudge {
0%, 10% { transform: translateX(0); }
16% { transform: translateX(4px); }
24%, 90% { transform: translateX(0); }
94% { transform: translateX(4px); }
100% { transform: translateX(0); }
}
@keyframes ot-say {
0%, 42% { opacity: 1; }
43%, 71% { opacity: .35; }
72%, 100% { opacity: 1; }
}
.ot.is-demo {
.ot__track {
animation-name: ot-slide;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
// 미리보기 동안 스냅이 트랙을 붙잡지 않게 (262 실측)
scroll-snap-type: none;
}
.ot__shade--l {
animation-name: ot-shade-l;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ot__shade--r {
animation-name: ot-shade-r;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ot__nudge {
animation-name: ot-nudge;
animation-duration: $duration;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
}
.ot__state {
animation-name: ot-say;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.ot__view { height: 104px; }
.ot__track { grid-auto-columns: 78%; }
.ot__shade { width: 24px; }
}
@media (prefers-reduced-motion: reduce) {
.ot.is-demo .ot__track,
.ot.is-demo .ot__nudge,
.ot.is-demo .ot__state { animation: none !important; transform: none !important; }
// 정지 상태 — 왼쪽 끝에 있으니 왼쪽 그늘은 꺼지고 오른쪽만 켜진 채 머문다
.ot.is-demo .ot__shade--l { animation: none !important; opacity: 0 !important; }
.ot.is-demo .ot__shade--r { animation: none !important; opacity: 1 !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);
}
.ot {
position: relative;
box-sizing: border-box;
width: min(300px, 92vw);
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
overflow: hidden;
}
.ot__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 8px 12px 0;
}
.ot__title {
margin: 0;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.ot__state {
margin: 0;
font-size: 10px;
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
}
.ot__view {
box-sizing: border-box;
height: 116px;
margin: 8px 8px 0;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
scroll-snap-type: x mandatory;
}
.ot__view::-webkit-scrollbar {
display: none;
}
.ot__view:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.ot__track {
display: grid;
grid-auto-flow: column;
grid-auto-columns: 72%;
gap: 8px;
width: max-content;
height: 100%;
padding: 0 4px;
}
.ot__card {
box-sizing: border-box;
display: grid;
align-content: center;
justify-items: center;
gap: 4px;
padding: 8px;
border-radius: 12px;
background: #f0ece2;
scroll-snap-align: center;
}
.ot__card b {
font-size: 11px;
line-height: 1.3;
}
.ot__card span {
font-size: 16px;
font-weight: 800;
line-height: 1.2;
color: #0a4fe8;
}
.ot__card i {
font-size: 9px;
font-style: normal;
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
}
.ot__shade {
position: absolute;
top: 32px;
bottom: 30px;
width: 32px;
pointer-events: none;
opacity: 1;
transition: opacity 220ms ease-out;
}
.ot__shade--l {
left: 8px;
background: linear-gradient(to right, #fff7e6, rgba(255, 247, 230, 0));
}
.ot__shade--r {
right: 8px;
background: linear-gradient(to left, #fff7e6, rgba(255, 247, 230, 0));
display: grid;
align-items: end;
justify-items: center;
}
.ot__shade.is-off {
opacity: 0;
}
.ot__nudge {
display: inline-grid;
place-items: center;
width: 24px;
height: 24px;
margin-bottom: 8px;
border-radius: 50%;
background: #17141a;
color: #ffd23f;
font-size: 13px;
line-height: 1;
}
.ot__note {
margin: 0;
padding: 8px 12px;
font-size: 10px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.72);
}
@keyframes ot-slide {
0%, 16% {
transform: translateX(0);
}
44%, 72% {
transform: translateX(-56%);
}
92%, 100% {
transform: translateX(0);
}
}
@keyframes ot-shade-l {
0%, 16% {
opacity: 1;
}
20%, 72% {
opacity: 0;
}
80%, 100% {
opacity: 1;
}
}
@keyframes ot-shade-r {
0% {
opacity: 1;
}
44%, 72% {
opacity: 0;
}
80%, 100% {
opacity: 1;
}
}
@keyframes ot-nudge {
0%, 10% {
transform: translateX(0);
}
16% {
transform: translateX(4px);
}
24%, 90% {
transform: translateX(0);
}
94% {
transform: translateX(4px);
}
100% {
transform: translateX(0);
}
}
@keyframes ot-say {
0%, 42% {
opacity: 1;
}
43%, 71% {
opacity: 0.35;
}
72%, 100% {
opacity: 1;
}
}
.ot.is-demo .ot__track {
animation-name: ot-slide;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
scroll-snap-type: none;
}
.ot.is-demo .ot__shade--l {
animation-name: ot-shade-l;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ot.is-demo .ot__shade--r {
animation-name: ot-shade-r;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ot.is-demo .ot__nudge {
animation-name: ot-nudge;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ot.is-demo .ot__state {
animation-name: ot-say;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.ot__view {
height: 104px;
}
.ot__track {
grid-auto-columns: 78%;
}
.ot__shade {
width: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.ot.is-demo .ot__track,
.ot.is-demo .ot__nudge,
.ot.is-demo .ot__state {
animation: none !important;
transform: none !important;
}
.ot.is-demo .ot__shade--l {
animation: none !important;
opacity: 0 !important;
}
.ot.is-demo .ot__shade--r {
animation: none !important;
opacity: 1 !important;
}
}// 옆으로 더 있다고 알려 주는 가장자리 그늘 — scrollLeft/scrollWidth 로 양 끝을 각각 지운다
var ot = document.getElementById('ot');
var view = document.getElementById('ot-view');
var l = document.getElementById('ot-l');
var r = document.getElementById('ot-r');
var state = document.getElementById('ot-state');
view.addEventListener('scroll', function () {
ot.classList.remove('is-demo');
var max = view.scrollWidth - view.clientWidth;
var atLeft = view.scrollLeft <= 4;
var atRight = view.scrollLeft >= max - 4;
l.classList.toggle('is-off', atLeft);
r.classList.toggle('is-off', atRight);
state.textContent = atRight ? '끝까지 왔어요' : '옆으로 더 있어요';
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./OverflowEdgeHint.module.scss";
export type OverflowEdgeHintProps = {
title?: string;
stateMore?: string;
stateEnd?: string;
plans?: [string, string, string][];
note?: string;
groupLabel?: string;
tableLabel?: string;
/** 격자 미리보기용 2초 자동 루프. 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// ambient 가 얇아 DOM 요소 타입을 못 쓴다 — 필요한 꼴만 로컬 타입으로 선언
type ScrollBox = { scrollLeft: number; scrollWidth: number; clientWidth: number };
const DEFAULT_PLANS: [string, string, string][] = [
["무료", "0원", "한 명용"],
["기본", "9,000원", "다섯 명용"],
["팀", "19,000원", "열 명용"],
["단체", "39,000원", "제한 없음"],
];
function CaretRightIcon() {
return <svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M176,128,96,208V48Z" opacity="0.2"/><path d="M181.66,122.34l-80-80A8,8,0,0,0,88,48V208a8,8,0,0,0,13.66,5.66l80-80A8,8,0,0,0,181.66,122.34ZM104,188.69V67.31L164.69,128Z"/></svg>;
}
export default function OverflowEdgeHint({
title = "요금 비교표",
stateMore = "옆으로 더 있어요",
stateEnd = "끝까지 왔어요",
plans = DEFAULT_PLANS,
note = "양 끝이 흐려진 곳까지가 아직 안 읽은 부분이에요",
groupLabel = "옆으로 더 있다는 가장자리 알림",
tableLabel = "요금표 — 좌우로 살펴보기",
demo = true,
duration = 2000,
easing = "ease-out",
color = "#17141a",
}: OverflowEdgeHintProps) {
const [atLeft, setAtLeft] = useState(true);
const [atRight, setAtRight] = useState(false);
const [live, setLive] = useState(demo);
const viewRef = useRef<ScrollBox>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// scrollLeft/scrollWidth 로 양 끝을 각각 지운다
function onScroll() {
const el = viewRef.current;
if (!el) return;
setLive(false);
const max = el.scrollWidth - el.clientWidth;
setAtLeft(el.scrollLeft <= 4);
setAtRight(el.scrollLeft >= max - 4);
}
const cls = [styles.ot, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.head}>
<p className={styles.otitle}>{title}</p>
<p className={styles.state}>{atRight ? stateEnd : stateMore}</p>
</header>
<div className={styles.view} ref={viewRef} onScroll={onScroll} tabIndex={0} role="region" aria-label={tableLabel}>
<div className={styles.track}>
{plans.map(([name, price, who]) => (
<div className={styles.card} key={name}>
<b>{name}</b>
<span>{price}</span>
<i>{who}</i>
</div>
))}
</div>
</div>
<div className={`${styles.shade} ${styles.shadeL} ${live || !atLeft ? "" : styles.off}`} aria-hidden="true"></div>
<div className={`${styles.shade} ${styles.shadeR} ${live || !atRight ? "" : styles.off}`} aria-hidden="true">
<span className={styles.nudge}><CaretRightIcon /></span>
</div>
<p className={styles.onote} aria-hidden="true">{note}</p>
</section>
);
}06 · 옆으로 더 있다고 알려 주는 가장자리 그늘 — 측정 누적면적 11.281% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
폰에서 옆으로 미는 요금 비교표나 칸이 많은 통계 표에 씁니다.
.ot__shade {
position: absolute;
top: 32px;
bottom: 30px;
width: 32px;
pointer-events: none;
opacity: 1;
/* 정적 경로 — 한쪽 끝에 닿으면 그쪽만 .is-off 로 꺼진다 */
transition: opacity 220ms ease-out;
}
.ot__shade--l {
left: 8px;
background: linear-gradient(to right, #fff7e6, rgba(255, 247, 230, 0));
}
.ot__shade--r {
right: 8px;
background: linear-gradient(to left, #fff7e6, rgba(255, 247, 230, 0));
}
.ot__shade.is-off { opacity: 0; }
07브라우저 탭 이름에 적히는 진행률
무엇이 보이나
읽은 비율이 document.title 앞머리에 34% · 처럼 붙어, 탭을 여러 개 열어 둔 사람도 제목만 보고 어디까지 읽었는지 압니다. 탭 아이콘 자리의 고리는 @property 로 등록한 --p 를 conic-gradient 의 정지점에 넣어 채우는데, 이 속성은 상속되지 않으므로 값을 고리 요소 자신에게 얹어야 합니다.

<main class="stage">
<section class="tt is-demo" id="tt" aria-label="탭 이름에 적히는 진행률">
<div class="tt__bar" role="progressbar" aria-label="읽기 진행" 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">주말에 읽는 긴 글</b>
<b class="tt__t tt__t--1">34% · 주말에 읽는 긴 글</b>
<b class="tt__t tt__t--2">78% · 주말에 읽는 긴 글</b>
<b class="tt__t tt__t--3">100% · 주말에 읽는 긴 글</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="긴 글 본문">
<div class="tt__pad">
<p class="tt__lede">탭을 여러 개 열어 두고 읽는 글이에요.</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">브라우저 탭 이름이 지금 몇 % 인지 따라 바뀌어요.</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;
}
}// 탭 이름에 적히는 진행률 — 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 · 브라우저 탭 이름에 적히는 진행률 — 측정 누적면적 0.849% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
탭을 여러 개 띄워 두고 오가며 읽는 긴 문서나 사내 위키에 씁니다.
// 탭 이름에 적히는 진행률 — document.title 앞에 숫자를 붙이고 고리는 --p 로 채운다
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 — 링 요소 자신에 얹는다
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;
});
08읽던 자리를 남겨 두는 이어읽기 표시
화면에서 일어나는 일
가장 깊이 내려갔던 자리에 파란 점선 한 줄이 남고, 그 줄이 창 밖으로 밀려나면 오른쪽 아래에서 알약이 올라옵니다. 점선의 top 은 Math.max(deepest, scrollTop) 이라 위로 올라가는 일이 없고, 알약은 translateY(150%) 자리에 숨어 있다가 is-up 이 붙으면 300ms 스프링 곡선으로 올라옵니다.

<main class="stage">
<section class="rs is-demo" id="rs" aria-label="읽던 자리 이어읽기">
<header class="rs__head">
<p class="rs__title">도움말 · 자주 묻는 질문</p>
<p class="rs__state" id="rs-state">처음부터 읽는 중</p>
</header>
<div class="rs__view" id="rs-view" tabindex="0" role="region" aria-label="도움말 본문">
<div class="rs__pad" id="rs-pad">
<p class="rs__p">이 문서는 중간에 두고 나갔다 돌아오는 사람을 위한 문서예요.</p>
<p class="rs__p">읽던 깊이가 점선으로 남고, 그 줄이 화면 위로 밀려나면 알약이 올라와요.</p>
<p class="rs__p">세 번째 문단쯤에서 잠깐 나갔다 왔다고 상상해 보세요.</p>
<p class="rs__p">네 번째 문단은 환불 규칙에 대한 이야기입니다.</p>
<p class="rs__p">다섯 번째 문단은 문의처와 운영 시간을 안내합니다.</p>
<p class="rs__p">여기까지 내려오면 거의 다 읽은 거예요.</p>
<span class="rs__mark" id="rs-mark" aria-hidden="true"></span>
</div>
</div>
<button class="rs__pill" type="button" id="rs-pill">
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M192,176v48l-64-40L64,224V176l64-40Z" opacity="0.2"/><path d="M184,32H72A16,16,0,0,0,56,48V224a8,8,0,0,0,12.24,6.78L128,193.43l59.77,37.35A8,8,0,0,0,200,224V48A16,16,0,0,0,184,32Zm0,16V161.57l-51.77-32.35a8,8,0,0,0-8.48,0L72,161.56V48ZM132.23,177.22a8,8,0,0,0-8.48,0L72,209.57V180.43l56-35,56,35v29.14Z"/></svg>
<span class="rs__say" id="rs-say">읽던 자리로 돌아갈까요?</span>
</button>
<p class="rs__live" id="rs-live" aria-live="polite">읽기 시작</p>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 읽던 자리를 남겨 두는 이어읽기 표시 — 가장 깊이 내려갔던 자리의 점선과, 그 자리로 돌아가는 알약
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.rs {
position: relative;
box-sizing: border-box;
width: min(300px, 92vw);
border-radius: $r-card;
background: #ffffff;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
border: 1px solid rgba(23, 20, 26, .08);
overflow: hidden;
}
.rs__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
padding: $sp-2 $sp-3 0;
}
.rs__title {
margin: 0;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.rs__state {
margin: 0;
font-size: 10px;
line-height: 1.3;
color: rgba($stage-ink, .72);
}
.rs__view {
box-sizing: border-box;
height: 148px;
margin: $sp-2 $sp-2 0;
padding-right: $sp-2;
overflow-y: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
&:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
}
.rs__pad {
position: relative;
display: grid;
gap: $sp-2;
// 아래 여백이 창 높이(148px)보다 넉넉해야 「점선이 화면 위로 밀려난」 상태가 실제로 만들어진다
padding: $sp-1 $sp-1 160px;
}
.rs__p {
margin: 0;
font-size: 11px;
line-height: 1.55;
}
// 읽던 자리 점선 — JS 가 가장 깊었던 scrollTop px 를 top 에 얹는다
.rs__mark {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 0;
border-top: 2px dashed $color;
pointer-events: none;
}
// 이어읽기 알약 — 카드 밑에서 기다리다 .is-up 이 되면 올라온다
.rs__pill {
position: absolute;
right: $sp-3;
bottom: $sp-6;
display: inline-flex;
align-items: center;
gap: $sp-1;
margin: 0;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-ink;
color: $stage-yellow;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
cursor: pointer;
box-shadow: $shadow-press;
// 정적 경로 — 기본은 카드 아래에 숨어 있다
transform: translateY(150%);
opacity: 0;
transition: transform $dur-base $easing, opacity $dur-quick $ease-out;
&:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
}
.rs__pill.is-up {
transform: translateY(0);
opacity: 1;
}
.rs__pill svg { font-size: 14px; }
.rs__live {
position: absolute;
left: $sp-3;
bottom: $sp-1;
margin: 0;
font-size: 9px;
line-height: 1.4;
color: rgba($stage-ink, .72);
}
// ── 격자 미리보기 루프 — 본문이 흘러 내려가고 점선이 남은 뒤 알약이 올라온다 ──
@keyframes rs-flow {
0%, 24% { transform: translateY(0); }
56%, 78% { transform: translateY(-28%); }
88%, 100% { transform: translateY(0); }
}
// 점선은 pad 안에 있어 흐름을 따라가되, 중간에 멈춘 자리를 보여준다
@keyframes rs-mark {
0%, 24% { opacity: 0; }
28%, 82% { opacity: 1; }
84%, 100% { opacity: 0; }
}
@keyframes rs-pill-up {
0%, 36% { transform: translateY(150%); opacity: 0; }
42% { opacity: 1; }
48%, 78% { transform: translateY(0); opacity: 1; }
88%, 100% { transform: translateY(150%); opacity: 0; }
}
@keyframes rs-say {
0%, 36% { opacity: .3; }
40%, 82% { opacity: 1; }
86%, 100% { opacity: .3; }
}
.rs.is-demo {
.rs__pad {
animation-name: rs-flow;
animation-duration: $duration;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
.rs__mark {
top: 96px;
animation-name: rs-mark;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs__pill {
animation-name: rs-pill-up;
animation-duration: $duration;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rs__state {
animation-name: rs-say;
animation-duration: $duration;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
}
@media (max-width: 340px) {
.rs__view { height: 124px; }
.rs__p { font-size: 10px; }
.rs__pill { bottom: $sp-5; }
}
@media (prefers-reduced-motion: reduce) {
.rs.is-demo .rs__pad,
.rs.is-demo .rs__pill,
.rs.is-demo .rs__state { animation: none !important; transform: none !important; }
// 정지 상태 — 점선은 중간쯤, 알약은 올라온 모습으로 머문다
.rs.is-demo .rs__pad { transform: translateY(-14%) !important; }
.rs.is-demo .rs__mark { opacity: 1 !important; top: 96px; }
.rs.is-demo .rs__pill { transform: translateY(0) !important; opacity: 1 !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rs {
position: relative;
box-sizing: border-box;
width: min(300px, 92vw);
border-radius: 16px;
background: #ffffff;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
border: 1px solid rgba(23, 20, 26, 0.08);
overflow: hidden;
}
.rs__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 8px 12px 0;
}
.rs__title {
margin: 0;
font-size: 12px;
font-weight: 700;
line-height: 1.3;
}
.rs__state {
margin: 0;
font-size: 10px;
line-height: 1.3;
color: rgba(23, 20, 26, 0.72);
}
.rs__view {
box-sizing: border-box;
height: 148px;
margin: 8px 8px 0;
padding-right: 8px;
overflow-y: auto;
scrollbar-width: none;
}
.rs__view::-webkit-scrollbar {
display: none;
}
.rs__view:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.rs__pad {
position: relative;
display: grid;
gap: 8px;
padding: 4px 4px 160px;
}
.rs__p {
margin: 0;
font-size: 11px;
line-height: 1.55;
}
.rs__mark {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 0;
border-top: 2px dashed #2f6df6;
pointer-events: none;
}
.rs__pill {
position: absolute;
right: 12px;
bottom: 24px;
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: #17141a;
color: #ffd23f;
font-family: inherit;
font-size: 11px;
font-weight: 700;
line-height: 1.4;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: translateY(150%);
opacity: 0;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 220ms ease-out;
}
.rs__pill:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.rs__pill.is-up {
transform: translateY(0);
opacity: 1;
}
.rs__pill svg {
font-size: 14px;
}
.rs__live {
position: absolute;
left: 12px;
bottom: 4px;
margin: 0;
font-size: 9px;
line-height: 1.4;
color: rgba(23, 20, 26, 0.72);
}
@keyframes rs-flow {
0%, 24% {
transform: translateY(0);
}
56%, 78% {
transform: translateY(-28%);
}
88%, 100% {
transform: translateY(0);
}
}
@keyframes rs-mark {
0%, 24% {
opacity: 0;
}
28%, 82% {
opacity: 1;
}
84%, 100% {
opacity: 0;
}
}
@keyframes rs-pill-up {
0%, 36% {
transform: translateY(150%);
opacity: 0;
}
42% {
opacity: 1;
}
48%, 78% {
transform: translateY(0);
opacity: 1;
}
88%, 100% {
transform: translateY(150%);
opacity: 0;
}
}
@keyframes rs-say {
0%, 36% {
opacity: 0.3;
}
40%, 82% {
opacity: 1;
}
86%, 100% {
opacity: 0.3;
}
}
.rs.is-demo .rs__pad {
animation-name: rs-flow;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.rs.is-demo .rs__mark {
top: 96px;
animation-name: rs-mark;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__pill {
animation-name: rs-pill-up;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rs.is-demo .rs__state {
animation-name: rs-say;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.rs__view {
height: 124px;
}
.rs__p {
font-size: 10px;
}
.rs__pill {
bottom: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.rs.is-demo .rs__pad,
.rs.is-demo .rs__pill,
.rs.is-demo .rs__state {
animation: none !important;
transform: none !important;
}
.rs.is-demo .rs__pad {
transform: translateY(-14%) !important;
}
.rs.is-demo .rs__mark {
opacity: 1 !important;
top: 96px;
}
.rs.is-demo .rs__pill {
transform: translateY(0) !important;
opacity: 1 !important;
}
}// 읽던 자리를 남겨 두는 이어읽기 표시 — 가장 깊이 내려갔던 scrollTop 자리에 점선이 남는다
var rs = document.getElementById('rs');
var view = document.getElementById('rs-view');
var mark = document.getElementById('rs-mark');
var pill = document.getElementById('rs-pill');
var say = document.getElementById('rs-say');
var live = document.getElementById('rs-live');
var state = document.getElementById('rs-state');
var deepest = 0;
function paint() {
var max = view.scrollHeight - view.clientHeight;
var pct = max > 0 ? Math.round(view.scrollTop / max * 100) : 0;
// 점선은 가장 깊이 갔던 자리에 — 위로 올려 쓰지 않는다
mark.style.top = (Math.max(deepest, view.scrollTop)) + 'px';
deepest = Math.max(deepest, view.scrollTop);
// 점선이 창 아래로 벗어났을 때만 — 「한 번이라도 깊이 갔다」 가 아니라 「지금 안 보인다」 가 조건이다
var pushedOut = view.scrollTop + view.clientHeight < deepest + 8 && view.scrollTop < deepest - 8;
var showPill = pushedOut && deepest > 24;
pill.classList.toggle('is-up', showPill);
rs.classList.toggle('rs--deep', showPill);
// 머리말도 정적 경로에서 같이 움직인다 — 루프를 뗀 뒤 「처음부터」 로 굳지 않게
state.textContent = showPill ? '읽던 자리가 남아 있어요' : '처음부터 읽는 중';
if (showPill) {
var phrase = '읽던 자리로 돌아갈까요?';
if (say.textContent !== phrase) { say.textContent = phrase; live.textContent = '읽던 자리가 ' + pct + '% 지점에 남아 있어요'; }
} else {
live.textContent = pct > 8 ? '읽는 중 · ' + pct + '%' : '읽기 시작';
}
}
view.addEventListener('scroll', function () { rs.classList.remove('is-demo'); paint(); }, { passive: true });
pill.addEventListener('click', function () {
rs.classList.remove('is-demo');
view.scrollTo({ top: deepest, behavior: 'smooth' });
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ResumeMarker.module.scss";
export type ResumeMarkerProps = {
title?: string;
stateReading?: string;
stateSaved?: string;
paragraphs?: string[];
say?: string;
pillLabel?: 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; scrollTo: (o: { top: number; behavior: string }) => void };
const DEFAULT_PARAGRAPHS = [
"이 문서는 중간에 두고 나갔다 돌아오는 사람을 위한 문서예요.",
"읽던 깊이가 점선으로 남고, 그 줄이 화면 위로 밀려나면 알약이 올라와요.",
"세 번째 문단쯤에서 잠깐 나갔다 왔다고 상상해 보세요.",
"네 번째 문단은 환불 규칙에 대한 이야기입니다.",
"다섯 번째 문단은 문의처와 운영 시간을 안내합니다.",
"여기까지 내려오면 거의 다 읽은 거예요.",
];
function BookmarkIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M192,176v48l-64-40L64,224V176l64-40Z" opacity="0.2"/><path d="M184,32H72A16,16,0,0,0,56,48V224a8,8,0,0,0,12.24,6.78L128,193.43l59.77,37.35A8,8,0,0,0,200,224V48A16,16,0,0,0,184,32Zm0,16V161.57l-51.77-32.35a8,8,0,0,0-8.48,0L72,161.56V48ZM132.23,177.22a8,8,0,0,0-8.48,0L72,209.57V180.43l56-35,56,35v29.14Z"/></svg>
);
}
export default function ResumeMarker({
title = "도움말 · 자주 묻는 질문",
stateReading = "처음부터 읽는 중",
stateSaved = "읽던 자리가 남아 있어요",
paragraphs = DEFAULT_PARAGRAPHS,
say = "읽던 자리로 돌아갈까요?",
pillLabel = "읽던 자리로 돌아가기",
groupLabel = "읽던 자리 이어읽기",
bodyLabel = "도움말 본문",
demo = true,
duration = 2000,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ResumeMarkerProps) {
const [deep, setDeep] = useState(false);
const [live, setLive] = useState(demo);
const [liveText, setLiveText] = useState("읽기 시작");
const [markTop, setMarkTop] = useState(0);
const viewRef = useRef<ScrollBox>(null);
const deepestRef = useRef(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 가장 깊이 내려갔던 scrollTop 자리에 점선이 남는다
function onScroll() {
const el = viewRef.current;
if (!el) return;
setLive(false);
const next = Math.max(deepestRef.current || 0, el.scrollTop);
deepestRef.current = next;
setMarkTop(next);
const max = el.scrollHeight - el.clientHeight;
const pct = max > 0 ? Math.round((el.scrollTop / max) * 100) : 0;
const pushed = next > 24 && el.scrollTop + el.clientHeight < next + 8 && el.scrollTop < next - 8;
setDeep(pushed);
setLiveText(pushed ? "읽던 자리가 " + Math.round((next / (max || 1)) * 100) + "% 지점에 남아 있어요" : pct > 8 ? "읽는 중 · " + pct + "%" : "읽기 시작");
}
function back() {
setLive(false);
const el = viewRef.current;
if (!el) return;
el.scrollTo({ top: deepestRef.current || 0, behavior: "smooth" });
}
const cls = [styles.rs, live ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.head}>
<p className={styles.rtitle}>{title}</p>
<p className={styles.state}>{deep ? stateSaved : stateReading}</p>
</header>
<div className={styles.view} ref={viewRef} onScroll={onScroll} tabIndex={0} role="region" aria-label={bodyLabel}>
<div className={styles.pad}>
{paragraphs.map((p) => (
<p className={styles.p} key={p}>{p}</p>
))}
<span className={styles.mark} style={{ top: (live ? 96 : markTop) + "px" }} aria-hidden="true"></span>
</div>
</div>
<button className={`${styles.pill} ${deep ? styles.up : ""}`} type="button" aria-label={pillLabel} onClick={back}>
<BookmarkIcon />
<span className={styles.psay}>{say}</span>
</button>
<p className={styles.plive} aria-live="polite">{liveText}</p>
</section>
);
}08 · 읽던 자리를 남겨 두는 이어읽기 표시 — 측정 누적면적 24.433% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
중간에 끊고 나갔다 돌아오는 도움말 문서나 회차가 긴 연재 글에 씁니다.
function paint() {
var max = view.scrollHeight - view.clientHeight;
var pct = max > 0 ? Math.round(view.scrollTop / max * 100) : 0;
// 점선은 가장 깊이 갔던 자리에 — 위로 올려 쓰지 않는다
mark.style.top = (Math.max(deepest, view.scrollTop)) + 'px';
deepest = Math.max(deepest, view.scrollTop);
// 「한 번이라도 깊이 갔다」 가 아니라 「지금 안 보인다」 가 조건이다
var pushedOut = view.scrollTop + view.clientHeight < deepest + 8 && view.scrollTop < deepest - 8;
var showPill = pushedOut && deepest > 24;
pill.classList.toggle('is-up', showPill);
rs.classList.toggle('rs--deep', showPill);
state.textContent = showPill ? '읽던 자리가 남아 있어요' : '처음부터 읽는 중';
if (showPill) {
var phrase = '읽던 자리로 돌아갈까요?';
if (say.textContent !== phrase) { say.textContent = phrase; live.textContent = '읽던 자리가 ' + pct + '% 지점에 남아 있어요'; }
} else {
live.textContent = pct > 8 ? '읽는 중 · ' + pct + '%' : '읽기 시작';
}
}
09끝에서 다음 글을 당겨 오는 진행 링
어떤 움직임인가
글이 끝난 뒤 남은 여백을 더 내리면 반지름 42 짜리 원의 stroke-dashoffset 이 263.9 에서 0 으로 줄며 고리가 한 바퀴를 채웁니다. 본문을 지나는 앞쪽 55% 는 세지 않고 남은 45% 만 0~1 로 다시 매핑하기 때문에, 글을 읽는 동안에는 고리가 비어 있다가 끝머리에서만 차오릅니다.

<main class="stage">
<section class="nx is-demo" id="nx" aria-label="다음 글 당기는 고리">
<header class="nx__head">
<p class="nx__title">지금 읽은 글 · 데모로 배우는 인디케이터</p>
<p class="nx__state" id="nx-state">끝!</p>
</header>
<div class="nx__view" id="nx-view" tabindex="0" role="region" aria-label="글 끝과 당기는 칸">
<div class="nx__pad">
<p class="nx__p">글이 여기서 끝났어요. 그래도 계속 내릴 수 있어요.</p>
<div class="nx__pull" role="progressbar" aria-label="다음 글 당기기" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" id="nx-role">
<span class="nx__ring" aria-hidden="true">
<svg viewBox="0 0 96 96">
<circle class="nx__track" cx="48" cy="48" r="42"></circle>
<circle class="nx__fill" cx="48" cy="48" r="42" id="nx-fill"></circle>
</svg>
</span>
<span class="nx__labels">
<b class="nx__l nx__l--0">아래로 당겨 보세요</b>
<b class="nx__l nx__l--1"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M216,128l-72,72V56Z" opacity="0.2"/><path d="M221.66,122.34l-72-72A8,8,0,0,0,136,56v64H40a8,8,0,0,0,0,16h96v64a8,8,0,0,0,13.66,5.66l72-72A8,8,0,0,0,221.66,122.34ZM152,180.69V75.31L204.69,128Z"/></svg> 다음 글로</b>
</span>
</div>
</div>
</div>
</section>
</main>@use "../../../../site/static/demo-tokens" as *;
// 끝에서 다음 글을 당겨 오는 진행 링 — stroke-dashoffset 이 줄며 고리가 차고, 다 차면 글자가 갈린다
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-cream;
@include stage($stage-ink);
.nx {
box-sizing: border-box;
width: min(300px, 92vw);
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
overflow: hidden;
}
.nx__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: $sp-2;
padding: $sp-2 $sp-3 0;
}
.nx__title {
margin: 0;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.nx__state {
margin: 0;
font-size: 10px;
line-height: 1.3;
color: $subject-orange-deep;
font-weight: 700;
}
.nx__view {
box-sizing: border-box;
height: 168px;
margin: $sp-2 $sp-2 $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; }
}
// 스크롤 여유 — 끝머리 여백이 곧 「당기는 칸」
.nx__pad {
display: grid;
justify-items: center;
gap: $sp-3;
padding: $sp-2 $sp-1 64px;
}
.nx__p {
margin: 0;
justify-self: start;
font-size: 11px;
line-height: 1.55;
}
.nx__pull {
position: relative;
display: grid;
place-items: center;
width: 108px;
height: 108px;
margin-top: $sp-2;
}
.nx__ring { position: absolute; inset: 0; }
.nx__ring svg { width: 108px; height: 108px; transform: rotate(-90deg); }
.nx__track,
.nx__fill {
fill: none;
stroke-width: 6;
}
.nx__track { stroke: rgba(23, 20, 26, .12); }
.nx__fill {
stroke: $subject-blue;
stroke-linecap: round;
stroke-dasharray: 263.9;
// 정적 경로 — JS 가 둘레를 다시 세팅하고 스크롤 비율만큼 offset 을 줄인다
stroke-dashoffset: 263.9;
}
// 두 문구를 같은 자리에 겹쳐 두고 컷으로 갈아 낀다
.nx__labels {
position: relative;
display: grid;
place-items: center;
width: 84px;
height: 84px;
text-align: center;
}
.nx__l {
position: absolute;
inset: 0;
display: grid;
place-items: center;
gap: $sp-1;
margin: 0;
font-size: 10px;
font-weight: 700;
line-height: 1.35;
color: $subject-ink;
opacity: 0;
}
.nx__l svg { font-size: 14px; color: $subject-blue; }
.nx__l--0 { opacity: 1; }
.nx--next .nx__l--0 { opacity: 0; }
.nx--next .nx__l--1 { opacity: 1; color: $subject-blue; }
// ── 격자 미리보기 루프 — 고리가 차오르고 다 차면 글자가 컷으로 바뀐다 ──
// 24프레임 샘플은 4.1667% 눈금 — 되돌아가는 구간을 87.5% 와 91.67% 사이(88~90%)에 둔다.
// 그러지 않으면 「고리는 비었는데 글자는 다음 글로」 인 장이 대표 이미지로 뽑힌다
@keyframes nx-draw {
0%, 30% { stroke-dashoffset: 263.9; }
78%, 88% { stroke-dashoffset: 0; }
90%, 100% { stroke-dashoffset: 263.9; }
}
@keyframes nx-l0 { 0%, 78% { opacity: 1; } 80%, 88% { opacity: 0; } 89%, 100% { opacity: 1; } }
@keyframes nx-l1 {
0%, 78% { opacity: 0; transform: scale(.92); }
80% { opacity: 1; transform: scale(1.04); }
84%, 88% { opacity: 1; transform: scale(1); }
89%, 100% { opacity: 0; transform: scale(.92); }
}
@keyframes nx-say {
0%, 77% { opacity: .3; }
80%, 88% { opacity: 1; }
89%, 100% { opacity: .3; }
}
.nx.is-demo {
.nx__fill {
animation-name: nx-draw;
animation-duration: $duration;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
.nx__l--0 { animation-name: nx-l0; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.nx__l--1 { animation-name: nx-l1; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
.nx__state { animation-name: nx-say; animation-duration: $duration; animation-timing-function: steps(1, end); animation-iteration-count: infinite; }
}
@media (max-width: 340px) {
// 폰 320×200 무대의 세로 여유는 174px — 머리 22 + 창 위아래 여백 20 을 빼면 창은 128px 이 상한
.nx__view { height: 128px; }
.nx__pull { width: 96px; height: 96px; }
.nx__ring svg { width: 96px; height: 96px; }
.nx__labels { width: 72px; height: 72px; }
}
@media (prefers-reduced-motion: reduce) {
.nx.is-demo .nx__fill,
.nx.is-demo .nx__l,
.nx.is-demo .nx__state { animation: none !important; }
// 정지 상태 — 고리는 절반, 첫 문구
.nx.is-demo .nx__fill { stroke-dashoffset: 132 !important; }
.nx.is-demo .nx__l--0 { opacity: 1 !important; }
.nx.is-demo .nx__l--1 { opacity: 0 !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);
}
.nx {
box-sizing: border-box;
width: min(300px, 92vw);
border-radius: 16px;
background: #fff7e6;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
overflow: hidden;
}
.nx__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 8px 12px 0;
}
.nx__title {
margin: 0;
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.nx__state {
margin: 0;
font-size: 10px;
line-height: 1.3;
color: #c93000;
font-weight: 700;
}
.nx__view {
box-sizing: border-box;
height: 168px;
margin: 8px 8px 12px;
padding-right: 8px;
overflow-y: auto;
scrollbar-width: none;
}
.nx__view::-webkit-scrollbar {
display: none;
}
.nx__view:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.nx__pad {
display: grid;
justify-items: center;
gap: 12px;
padding: 8px 4px 64px;
}
.nx__p {
margin: 0;
justify-self: start;
font-size: 11px;
line-height: 1.55;
}
.nx__pull {
position: relative;
display: grid;
place-items: center;
width: 108px;
height: 108px;
margin-top: 8px;
}
.nx__ring {
position: absolute;
inset: 0;
}
.nx__ring svg {
width: 108px;
height: 108px;
transform: rotate(-90deg);
}
.nx__track,
.nx__fill {
fill: none;
stroke-width: 6;
}
.nx__track {
stroke: rgba(23, 20, 26, 0.12);
}
.nx__fill {
stroke: #2f6df6;
stroke-linecap: round;
stroke-dasharray: 263.9;
stroke-dashoffset: 263.9;
}
.nx__labels {
position: relative;
display: grid;
place-items: center;
width: 84px;
height: 84px;
text-align: center;
}
.nx__l {
position: absolute;
inset: 0;
display: grid;
place-items: center;
gap: 4px;
margin: 0;
font-size: 10px;
font-weight: 700;
line-height: 1.35;
color: #17141a;
opacity: 0;
}
.nx__l svg {
font-size: 14px;
color: #2f6df6;
}
.nx__l--0 {
opacity: 1;
}
.nx--next .nx__l--0 {
opacity: 0;
}
.nx--next .nx__l--1 {
opacity: 1;
color: #2f6df6;
}
@keyframes nx-draw {
0%, 30% {
stroke-dashoffset: 263.9;
}
78%, 88% {
stroke-dashoffset: 0;
}
90%, 100% {
stroke-dashoffset: 263.9;
}
}
@keyframes nx-l0 {
0%, 78% {
opacity: 1;
}
80%, 88% {
opacity: 0;
}
89%, 100% {
opacity: 1;
}
}
@keyframes nx-l1 {
0%, 78% {
opacity: 0;
transform: scale(0.92);
}
80% {
opacity: 1;
transform: scale(1.04);
}
84%, 88% {
opacity: 1;
transform: scale(1);
}
89%, 100% {
opacity: 0;
transform: scale(0.92);
}
}
@keyframes nx-say {
0%, 77% {
opacity: 0.3;
}
80%, 88% {
opacity: 1;
}
89%, 100% {
opacity: 0.3;
}
}
.nx.is-demo .nx__fill {
animation-name: nx-draw;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.nx.is-demo .nx__l--0 {
animation-name: nx-l0;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.nx.is-demo .nx__l--1 {
animation-name: nx-l1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.nx.is-demo .nx__state {
animation-name: nx-say;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@media (max-width: 340px) {
.nx__view {
height: 128px;
}
.nx__pull {
width: 96px;
height: 96px;
}
.nx__ring svg {
width: 96px;
height: 96px;
}
.nx__labels {
width: 72px;
height: 72px;
}
}
@media (prefers-reduced-motion: reduce) {
.nx.is-demo .nx__fill,
.nx.is-demo .nx__l,
.nx.is-demo .nx__state {
animation: none !important;
}
.nx.is-demo .nx__fill {
stroke-dashoffset: 132 !important;
}
.nx.is-demo .nx__l--0 {
opacity: 1 !important;
}
.nx.is-demo .nx__l--1 {
opacity: 0 !important;
}
}// 끝에서 다음 글을 당겨 오는 진행 링 — 끝머리 여백을 스크롤한 비율로 stroke-dashoffset 을 줄인다
var nx = document.getElementById('nx');
var view = document.getElementById('nx-view');
var fill = document.getElementById('nx-fill');
var role = document.getElementById('nx-role');
var state = document.getElementById('nx-state');
var circumference = 2 * Math.PI * 42;
fill.style.strokeDasharray = circumference;
view.addEventListener('scroll', function () {
nx.classList.remove('is-demo');
var max = view.scrollHeight - view.clientHeight;
var r = max > 0 ? view.scrollTop / max : 0;
// 글 본문(패드 위쪽)을 지나고 나서부터 링이 차오른다
var p = Math.max(0, Math.min(1, (r - 0.55) / 0.45));
fill.style.strokeDashoffset = circumference * (1 - p);
role.setAttribute('aria-valuenow', Math.round(p * 100));
var isNext = p >= 0.95;
nx.classList.toggle('nx--next', isNext);
state.textContent = isNext ? '놓으면 다음 글!' : '끝!';
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./NextArticleRing.module.scss";
export type NextArticleRingProps = {
title?: string;
stateEnd?: string;
stateNext?: string;
body?: string;
pullLabel?: string;
nextLabel?: string;
groupLabel?: string;
viewLabel?: string;
ringLabel?: 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 };
// 반지름 42 인 원의 둘레. 한 곳에만 적고 CSS 의 stroke-dasharray 도 같은 값을 쓴다
const R = 42;
const CIRCUMFERENCE = 2 * Math.PI * R;
export default function NextArticleRing({
title = "지금 읽은 글 · 데모로 배우는 인디케이터",
stateEnd = "끝!",
stateNext = "놓으면 다음 글!",
body = "글이 여기서 끝났어요. 그래도 계속 내릴 수 있어요.",
pullLabel = "아래로 당겨 보세요",
nextLabel = "다음 글로",
groupLabel = "다음 글 당기는 고리",
viewLabel = "글 끝과 당기는 칸",
ringLabel = "다음 글 당기기",
demo = true,
duration = 2000,
easing = "ease-out",
color = "#2f6df6",
}: NextArticleRingProps) {
const [live, setLive] = useState(demo);
const [pct, setPct] = useState(0);
const viewRef = useRef<ScrollBox>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const isNext = pct >= 95;
// 글 본문(패드 위쪽)을 지나고 나서부터 링이 차오른다 — 끝머리 여백이 곧 「당기는 칸」
function onScroll() {
const el = viewRef.current;
if (!el) return;
setLive(false);
const max = el.scrollHeight - el.clientHeight;
const r = max > 0 ? el.scrollTop / max : 0;
const p = Math.max(0, Math.min(1, (r - 0.55) / 0.45));
setPct(Math.round(p * 100));
}
const cls = [styles.nx, live ? styles.isDemo : "", isNext ? styles.next : ""].filter(Boolean).join(" ");
return (
<section className={cls} style={vars} aria-label={groupLabel}>
<header className={styles.head}>
<p className={styles.ntitle}>{title}</p>
<p className={styles.state}>{isNext ? stateNext : stateEnd}</p>
</header>
<div className={styles.view} ref={viewRef} onScroll={onScroll} tabIndex={0} role="region" aria-label={viewLabel}>
<div className={styles.pad}>
<p className={styles.p}>{body}</p>
<div
className={styles.pull}
role="progressbar"
aria-label={ringLabel}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={pct}
>
<span className={styles.ring} aria-hidden="true">
<svg viewBox="0 0 96 96">
<circle className={styles.track} cx="48" cy="48" r={R}></circle>
<circle
className={styles.fill}
cx="48"
cy="48"
r={R}
style={{ strokeDasharray: CIRCUMFERENCE, strokeDashoffset: (CIRCUMFERENCE * (100 - pct)) / 100 }}
></circle>
</svg>
</span>
<span className={styles.labels}>
<b className={`${styles.l} ${styles.l0}`}>{pullLabel}</b>
<b className={`${styles.l} ${styles.l1}`}>
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor" aria-hidden="true"><path d="M216,128l-72,72V56Z" opacity="0.2"/><path d="M221.66,122.34l-72-72A8,8,0,0,0,136,56v64H40a8,8,0,0,0,0,16h96v64a8,8,0,0,0,13.66,5.66l72-72A8,8,0,0,0,221.66,122.34ZM152,180.69V75.31L204.69,128Z"/></svg>{" "}
{nextLabel}
</b>
</span>
</div>
</div>
</div>
</section>
);
}09 · 끝에서 다음 글을 당겨 오는 진행 링 — 측정 누적면적 2.317% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
다음 글로 이어 읽게 하는 매체 기사나 회차가 이어지는 강의 목록에 씁니다.
.nx__fill {
fill: none;
stroke: #2f6df6;
stroke-width: 6;
stroke-linecap: round;
/* 반지름 42 원의 둘레 — JS 가 다시 세팅하고 비율만큼 offset 을 줄인다 */
stroke-dasharray: 263.9;
stroke-dashoffset: 263.9;
}
/* 24프레임 샘플은 4.1667% 눈금 — 되돌아가는 구간을 88~90% 에 둔다 */
@keyframes nx-draw {
0%, 30% { stroke-dashoffset: 263.9; }
78%, 88% { stroke-dashoffset: 0; }
90%, 100% { stroke-dashoffset: 263.9; }
}
@keyframes nx-l1 {
0%, 78% { opacity: 0; transform: scale(.92); }
80% { opacity: 1; transform: scale(1.04); }
84%, 88% { opacity: 1; transform: scale(1); }
89%, 100% { opacity: 0; transform: scale(.92); }
}
어디서 깨지는가 — 함정
가장 먼저 걸린 것은 가로가 아니라 세로였습니다. 데모 무대는 폰 폭에서 320×200 으로 렌더되고 위아래 여백 25.6px 을 빼면 실제로 쓸 수 있는 세로가 174px 뿐인데, 스크롤 표시는 스크롤할 거리가 있어야 하니 창을 키우고 싶어집니다. 좁은 화면에서는 반대로 내려야 합니다. 03 읽기 깊이 배지는 머리 24px 과 배지 줄 56px 을 빼고 나면 읽기 창이 84px 이 상한이라 @media (max-width: 340px) 에서 132px 을 84px 로 내렸습니다. 05 구간 카운터도 머리 31px 과 키 줄 48px 을 빼 장면 창을 128px 에서 84px 로 줄였는데, 여기서 하나가 더 따라옵니다 — 장면 높이가 바뀌면 미리보기 루프가 밀어야 할 거리도 같이 바뀌므로 -256px · -640px · -1024px 를 84 × 2 · 5 · 8 인 -168px · -420px · -672px 로 다시 계산해 좁은 화면용 키프레임을 따로 썼습니다. 09 는 168px 창을 128px 로, 01·04·07·08 은 148px 을 124px 로, 06 은 116px 을 104px 로 내렸습니다.
두 번째는 미리보기 루프를 뗀 뒤에야 드러났습니다. 07 탭 이름 진행률은 탭 문구 네 장을 같은 자리에 포개 두고 steps(1, end) 로 갈아 끼우는데, 포갠 장들을 display: none 으로 숨겨 둔 탓에 사람이 실제로 스크롤해 .is-demo 가 떨어진 순간 탭 이름 칸이 통째로 빈 칸이 됐습니다. 고친 방법은 첫 장 하나를 정적 경로에 남기는 것입니다 — .tt__t--0 { display: block; } 만 남겨 두고, JS 가 그 요소의 글자를 34% · 주말에 읽는 긴 글 처럼 고쳐 씁니다. 나머지 셋은 .tt.is-demo .tt__t { display: block; } 로 미리보기에서만 꺼내 옵니다. 데모용 루프와 실제 조작이 같은 요소를 나눠 쓸 때는 루프 클래스를 뗀 상태에서 화면에 무엇이 남는지를 반드시 한 번 더 봐야 합니다.
포개 둔 장을 감추는 방법에서도 같은 종류의 사고가 났습니다. 05 구간 카운터는 머리의 알약 안에 「1 / 9」 를 넣고 숫자 넷을 같은 자리에 겹쳐 두는데, 원래 숫자를 display: none 으로 빼자 알약이 슬래시 폭만큼 쪼그라들면서 겹쳐 둔 숫자가 「/ 9」 위로 올라앉아 「/6」 처럼 보였습니다. 폭을 잡아 주던 요소를 배치에서 아예 빼면 그 자리가 사라지므로, 자리는 남기고 color: transparent 로 글자만 지워야 알약 폭이 흔들리지 않습니다. 07 은 반대로 display: none 이 필요한 자리였고 05 는 그러면 안 되는 자리였는데, 둘을 가르는 기준은 그 요소가 부모의 크기를 잡고 있느냐 하나였습니다.
미리보기 루프가 스크롤 기능과 부딪히는 자리도 있었습니다. 05 와 06 은 각각 scroll-snap-type: y mandatory 와 x mandatory 를 쓰는데, 루프가 트랙을 transform 으로 밀면 스냅이 트랙을 원래 칸으로 도로 붙잡아 장면이 제자리에서 떨었습니다. 두 항목 모두 미리보기 동안에만 scroll-snap-type: none 을 얹어 스냅을 쉬게 했습니다. 06 은 한 가지가 더 있는데, transform 을 스크롤포트인 .ot__view 가 아니라 그 안의 .ot__track 에만 겁니다 — 스크롤되는 상자 자체를 옮기면 scrollLeft 가 가리키는 자리와 눈에 보이는 자리가 어긋납니다.
세 번째는 조건이 영영 참이 되지 않는 문제였습니다. 08 이어읽기 알약이 올라오는 조건은 view.scrollTop + view.clientHeight < deepest + 8, 즉 읽던 자리 점선이 지금 창 아래로 밀려나 보이지 않는 상태입니다. 그런데 deepest 가 커질 수 있는 한계는 결국 scrollHeight - clientHeight 이므로, 스크롤 여유가 창 높이보다 짧으면 좌변이 우변보다 작아지는 일이 수학적으로 일어나지 않습니다. 창 높이가 148px 인 이 데모에서 아래 여백을 160px 로 넉넉히 잡고 나서야 알약이 실제로 올라왔습니다. 스크롤 조건은 부등식으로 적어 두고 양변이 가질 수 있는 범위를 겹쳐 보면, 브라우저를 열기 전에 성립할 수 없는 조건을 걸러낼 수 있습니다.
네 번째는 대표 이미지입니다. 2초짜리 루프를 24장으로 뽑으면 표본이 4.1667% 간격이라 87.5% 와 91.67% 사이에는 표본이 한 장도 없습니다. 되돌아가는 구간을 이 틈 밖에 두면 09 에서 고리는 비었는데 안쪽 글자는 「다음 글로」 인 장이 그대로 썸네일이 됩니다. 그래서 nx-draw 의 복귀를 88~90% 에, 글자 nx-l1 의 퇴장을 89% 에 두어 두 변화를 같은 틈에 몰아넣었고, 07 의 고리 복귀와 탭 문구 복귀도 88~89% 로 맞췄습니다. 01 은 방향이 반대였습니다. 힌트가 물러나는 장면을 37% 에서 54% 사이에 걸쳐 여러 프레임에 나눠 두면 사라지는 쪽이 표본을 더 많이 차지해, 정작 힌트가 서 있는 장이 아니라 텅 빈 화면이 대표로 뽑힙니다. 물러남은 그대로 두되 돌아옴을 79% 에서 80% 사이의 한 프레임 컷으로 바꾸자 힌트가 있는 장이 다시 이겼습니다. 같은 이유로 머리말 문구 두 장도 steps(1, end) 컷으로 갈아 끼워, 힌트는 물러났는데 「보이는 중」 이라고 적힌 장이 나오지 않게 했습니다.
마지막은 02 입니다. 아홉 개 중 02 만 미리보기용 시간 루프가 없습니다 — 네 변과 가운데 숫자가 전부 scroll-timeline-name: --pv-scroll 하나에서 나오므로 격자에서도 상세 화면에서도 같은 진행률이 보입니다. 이름을 지어 붙인 이유는 animation-timeline: scroll() 를 이름 없이 쓰면 overflow: hidden 상자가 엉뚱한 스크롤포트에 묶이는 일이 있어서고, position: sticky 가 걸릴 여유도 padding 이 아니라 안쪽 상자의 height: 340% 로 줍니다. 숫자는 @property --pv-pct 를 <integer> 로 등록해 counter-reset: pv-n var(--pv-pct) 로 찍으므로, 테두리와 숫자가 서로 다른 값을 말하는 장면이 아예 생기지 않습니다. 대신 이 방식은 브라우저를 가립니다 — caniuse 의 animation-timeline 지원표를 보면 Chrome 은 115, Safari 는 26 부터 돌고 Firefox 는 158 에 들어오며 전역 지원은 87.22% 입니다. 나머지 여덟 개는 평범한 JS 와 CSS 라 어디서든 같은 그림이 나오니, 02 만 따로 대체 표시를 준비하면 됩니다. 표시가 가리키는 대상과 다른 값을 말하는 순간 신뢰가 깨진다는 점은 툴팁 말풍선에서도 같은 원리로 지켜야 합니다. 이 아홉 벌은 지금까지 적은 값이 그대로 박힌 채 zip 안에 들어 있고, 여는 비밀번호는 exsgeatr 입니다.
접근성
움직임을 줄여 달라는 설정(prefers-reduced-motion: reduce)에서 아홉 개가 무엇을 끄고 무엇을 남기는지는 전부 다릅니다. 공통 원칙은 하나입니다 — 스스로 도는 움직임은 끄고, 사람이 스크롤한 만큼만 바뀌는 정보는 남깁니다. 01 은 화살표 흔들림과 본문 흐름과 머리말 컷을 모두 멈추고 힌트 묶음을 opacity: 1 로 세워 두어 정지 화면에서도 「아래로 내려 보세요」 가 읽힙니다. 02 는 네 변의 애니메이션만 끄고 윗변을 scaleX(1) 로 그어 둔 채 가운데 숫자는 손대지 않습니다. 03 은 배지·게이지·안내 문구를 멈추고 25 와 50 배지를 켠 채 게이지를 scaleX(.5) 로 세웁니다. 04 는 문단 초점과 레일 점을 멈추고 세 번째 문단만 진하게 남겨 초점이라는 장치의 뜻을 보여 줍니다. 05 는 트랙 이동과 숫자 컷을 끄고 카운터 「1」 과 첫 장면의 파란 강조만 남깁니다. 06 은 트랙 슬라이드와 화살표 밀림을 끄고, 왼쪽 끝에 서 있는 상태에 맞춰 왼쪽 그늘은 끄고 오른쪽 그늘만 켜 둡니다. 07 은 고리 채움과 탭 문구 컷을 멈추고 고리를 --p: .5 로, 문구는 첫 제목으로 고정합니다. 08 은 본문을 translateY(-14%) 한 자리에 두고 점선과 알약을 올라온 모습 그대로 남깁니다. 09 는 stroke-dashoffset: 132 로 절반쯤 찬 고리와 첫 문구를 보여 줍니다.
이 reduce 블록의 선언에는 거의 전부 !important 가 붙어 있습니다. 미리보기 루프 쪽 규칙이 .db.is-demo .db__chip[data-milestone="25"] 처럼 클래스 둘에 속성 선택자까지 얹은 모양이라 특이도에서 이기기 때문입니다. 무게를 맞추려고 reduce 쪽 선택자를 똑같이 길게 늘이면 클래스 이름이 바뀔 때마다 두 곳을 같이 고쳐야 하므로, 선언 하나에 표를 던지는 편이 오래갑니다. 기준 문서는 MDN 의 prefers-reduced-motion 설명을 따랐고, 04 가 쓰는 관찰자의 동작은 MDN 의 Intersection Observer API 문서에 정리되어 있습니다.
화면을 못 보는 사람에게 진행률을 전하는 장치는 항목마다 다르게 달렸습니다.
| 항목 | 실제로 붙은 것 | 무엇을 알리나 |
|---|---|---|
| 02 테두리 진행선 | role="progressbar" · aria-valuenow |
읽은 비율 0~100 |
| 03 깊이 배지 | role="progressbar" · aria-live="polite" |
비율과 새로 켜진 깊이 |
| 04 문단 초점 | aria-current="true" |
지금 읽는 문단 하나 |
| 05 구간 카운터 | aria-current="true" · 버튼 aria-label |
지금 구간, 앞뒤 이동 버튼의 이름 |
| 07 탭 이름 진행률 | role="progressbar" · document.title |
탭 제목 앞머리의 숫자 |
| 08 이어읽기 표시 | aria-live="polite" |
읽던 자리가 몇 % 지점인지 |
| 09 다음 글 링 | role="progressbar" · aria-valuenow |
당긴 비율 0~100 |
01 과 06 은 진행률 자체가 없는 안내형이라 힌트·그늘·화살표에 aria-hidden="true" 를 달아 스크린 리더가 장식을 읽지 않게 했습니다. 아홉 개 모두 스크롤 창에 tabindex="0" 과 role="region" 에 이름을 붙여 두어, 키보드만 쓰는 사람도 그 창에 들어가 방향키로 읽을 수 있습니다. 아홉 개 모두 스크롤 창에 :focus-visible 아웃라인을 무대 색에 맞게 따로 지정했고, 05 의 앞뒤 이동 버튼과 08 의 이어읽기 알약도 같은 규칙을 받습니다. 읽기 진행 표시만 더 보고 싶다면 CSS 프로그레스바 애니메이션 9종이 이웃 편이고, 스크롤로 움직이는 것들은 스크롤 트리거 모음에 모여 있습니다. 데모를 실제로 렌더해 측정한 뒤에야 글을 쓰는 이 공정은 소개에 정리해 두었습니다.
FAQ
스크롤바 커스텀 인디케이터를 한 페이지에 여러 개 써도 되나요?
성격이 다른 것끼리는 겹쳐도 괜찮지만 같은 말을 하는 둘은 고르세요. 02 테두리 진행선과 03 깊이 배지는 둘 다 「얼마나 읽었나」 를 말하므로 함께 쓰면 시선이 둘로 갈립니다. 반대로 05 구간 카운터와 06 가장자리 그늘은 각각 세로와 가로를 맡으니 한 화면에 같이 있어도 서로 방해하지 않습니다. 막대 모양만 모아 다룬 축소판은 프로그레스바 애니메이션에 따로 있습니다.
진행률이 100% 가 안 되고 97% 에서 멈추는데 왜 그런가요?
비율의 분모가 scrollHeight 가 아니라 scrollHeight - clientHeight 여야 하는데 이 부분을 빠뜨린 경우가 대부분입니다. 창에 보이는 높이만큼은 스크롤할 수 없는 몫이라 분모에서 빼야 맨 아래에서 정확히 1 이 됩니다. 이 아홉 벌은 전부 var max = view.scrollHeight - view.clientHeight 로 시작하고, max 가 0 인 짧은 문서에서는 비율을 0 으로 두어 나눗셈이 깨지지 않게 했습니다.
스크롤 이벤트를 그대로 써도 성능에 문제가 없나요?
값을 읽기만 하고 레이아웃을 다시 재지 않으면 대체로 괜찮습니다. 여기서 바꾸는 것은 transform·opacity·커스텀 속성 하나뿐이라 배치 계산이 다시 일어나지 않고, 값을 읽는 쪽도 scrollTop 처럼 이미 계산된 값입니다. 04 처럼 「어느 문단이 가운데인가」 를 알아야 할 때는 스크롤마다 위치를 재는 대신 IntersectionObserver 에 맡기면 브라우저가 알아서 필요한 순간에만 알려 줍니다.








