09끝에서 다음 글을 당겨 오는 진행 링
글이 끝난 뒤에도 더 내리면 고리의 stroke-dashoffset 이 줄면서 한 바퀴를 채우고, 다 채운 비율은 aria-valuenow 로 남습니다. 다 차는 순간 안쪽 글자는 steps(1, end) 로 한 프레임에 「다음 글로」 로 갈립니다.
쓰임다음 글로 이어 읽게 하는 매체 기사, 회차가 이어지는 강의 목록
stroke-dashoffsetaria-valuenowsteps(1, end)

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


