08읽던 자리를 남겨 두는 이어읽기 표시
가장 깊이 내려갔던 scrollTop 자리에 점선 한 줄이 남고, 그 줄이 화면 밖으로 밀려나면 알약 하나가 translateY 로 올라와 그 자리로 돌아가자고 합니다. 알약의 말이 바뀔 때마다 aria-live 가 새 문구를 읽습니다.
쓰임중간에 끊고 나갔다 돌아오는 도움말 문서, 회차가 긴 연재 글
scrollToparia-livetranslateY

<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 포함 · 스크롤 인디케이터 9종 — 어디까지 읽었는지 보여주는
이 데모가 속한 글
스크롤 인디케이터 9종 — 어디까지 읽었는지 보여주는스크롤 인디케이터는 지금 이 화면의 어디쯤을 보고 있는지, 아래로 얼마나 더 남았는지를 눈에 보이게 그려 주는 장치입니다. 여기 모은 아홉 벌은 HTML·SCSS·JS 만으로 짜여 있어 복사해 붙이면 그 자리에서 움 이 글의 데모 전부 보기


