스크롤 인디케이터 9종 — 어디까지 읽었는지 보여주는
스크롤 인디케이터는 지금 이 화면의 어디쯤을 보고 있는지, 아래로 얼마나 더 남았는지를 눈에 보이게 그려 주는 장치입니다. 여기 모은 아홉 벌은 HTML·SCSS·JS 만으로 짜여 있어 복사해 붙이면 그 자리에서 움직입니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 첫 화면에서만 흔들리는 아래로 힌트
- 02 테두리를 한 바퀴 도는 읽기 진행선
- 03 4분의 1씩 켜지는 읽기 깊이 배지
- 04 지금 읽는 문단만 또렷해지는 초점
- 05 몇 번째 구간인지 세어 주는 카운터
- 06 옆으로 더 있다고 알려 주는 가장자리 그늘
- 07 브라우저 탭 이름에 적히는 진행률
- 08 읽던 자리를 남겨 두는 이어읽기 표시
- 09 끝에서 다음 글을 당겨 오는 진행 링
아홉 개는 기술 난이도가 아니라 읽는 사람이 한 편의 글에서 겪는 시간 순서로 놓았습니다. 화면을 열자마자 더 내려갈 것이 있는지 알려 주는 첫 화면 힌트(01)에서 시작해, 읽는 동안의 진행률을 테두리(02)와 깊이 배지(03)로 계속 보여 줍니다. 그다음 셋은 위치가 아니라 초점을 다룹니다 — 지금 읽는 문단만 또렷하게 남기고(04), 몇 번째 구간인지 숫자로 세고(05), 아래가 아니라 옆으로 더 있다는 사실을 가장자리 그늘로 알립니다(06). 마지막 셋은 화면 밖과 글 밖으로 나갑니다. 탭을 여러 개 띄워 둔 사람에게는 탭 이름 자체가 표시를 맡고(07), 자리를 비웠다 돌아온 사람에게는 읽던 자리가 점선으로 남아 있고(08), 끝까지 읽은 사람에게는 다음 글을 당겨 오는 고리(09)가 기다립니다. 아홉 벌이 같은 색·간격·이징 값을 나눠 쓰기 때문에 둘셋을 한 페이지에 섞어도 톤이 어긋나지 않습니다.
01첫 화면에서만 흔들리는 아래로 힌트
글이 아직 한 줄도 움직이지 않았을 때만 아래 화살표가 1초 주기로 5px 만큼 위아래를 오갑니다. scrollTop 이 8px 을 넘는 순간 힌트 묶음이 translateY(120%) 로 카드 밖으로 내려가며 흐려지고, 맨 위로 돌아오면 같은 길로 제자리에 섭니다. 첫 화면이 꽉 차 보여서 아래가 더 있다는 것을 아무도 모르는 소개 페이지에 씁니다.
@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% 를 읽으면 윗변만, 절반을 읽으면 윗변과 오른쪽 변이 그어진 상태가 됩니다. 상단 바를 놓을 자리가 없는 전면 읽기 화면이나 사진 위에 글을 얹는 기사에 씁니다.
/* 스크롤포트 자체에 타임라인 이름을 붙인다 — 이름 없는 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배까지 커졌다 돌아옵니다. 끝까지 읽으면 쿠폰을 주는 안내문이나 읽은 깊이를 재는 사내 교육 문서에 씁니다.
.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 으로 정하므로, 창 한가운데의 얇은 띠에 걸친 문단 하나만 뽑힙니다. 긴 계약서나 약관처럼 줄을 놓치면 곤란한 문서, 소리 내어 읽는 대본에 씁니다.
// 가운데 띠를 지나는 문단 하나만 골라 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 에 정확히 내려놓습니다. 한 화면에 한 장면씩 넘기는 발표 자료나 단계가 정해진 설명 문서에 씁니다.
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 밀렸다 돌아와 아직 남았다고 말합니다. 폰에서 옆으로 미는 요금 비교표나 칸이 많은 통계 표에 씁니다.
.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 의 정지점에 넣어 채우는데, 이 속성은 상속되지 않으므로 값을 고리 요소 자신에게 얹어야 합니다. 탭을 여러 개 띄워 두고 오가며 읽는 긴 문서나 사내 위키에 씁니다.
// 탭 이름에 적히는 진행률 — 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 스프링 곡선으로 올라옵니다. 중간에 끊고 나갔다 돌아오는 도움말 문서나 회차가 긴 연재 글에 씁니다.
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 로 다시 매핑하기 때문에, 글을 읽는 동안에는 고리가 비어 있다가 끝머리에서만 차오릅니다. 다음 글로 이어 읽게 하는 매체 기사나 회차가 이어지는 강의 목록에 씁니다.
.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 에 맡기면 브라우저가 알아서 필요한 순간에만 알려 줍니다.