당겨서 새로고침 9종 — 당긴 거리 값이 새로고침을 만드는 방식
당겨서 새로고침은 리스트를 위로 당겼다가 놓는 손짓에 거리 값을 계산해 새로고침을 트리거하는 동작인데, 아홉 개 중 하나는 당긴 거리 값이 커질수록 실제로 밀리는 폭 값이 점점 좁아지고, 하나는 그 거리의 비율 값만큼 회전 값이 함께 돌며, 또 하나는 기준을 넘은 채로 손을 뗀 상태가
아홉 화면 모두 실제 fetch 요청이나 navigator.onLine 없이 값 계산 시뮬레이션이고, 새로고침 뒤 나오는 목록·메시지는 전부 로컬 setTimeout 으로 만든 가상 데이터입니다. 풀 투 리프레시 아홉 개 전부 리스너가 pointerdown·pointermove·pointerup 으로 같아 자동으로만 재생되고 마는 항목은 하나도 없이, 화면을 직접 눌러 당기는 순간 자동 재생이 멈추고 실제 손 입력 값으로 같은 함수가 다시 계산합니다.
01 pull-refresh-elastic-drag
02 pull-refresh-rotation-bind
03 pull-refresh-threshold-commit
04 pull-refresh-rubber-band-overscroll
06 pull-refresh-ratchet-tick-snap
07 pull-refresh-release-spring-settle
08 pull-refresh-row-stagger-reveal
09 pull-refresh-offline-retry-shake
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 탄성 당김 — 당긴 거리 값이 늘어날수록 저항이 커지는 새로고침 트리거
- 02 회전 연동 — 당긴 거리 값의 비율만큼 회전 값이 함께 도는 표시
- 03 기준 확정 — 당긴 거리 값이 기준을 넘으면 확정 상태로 바뀌는 트리거
- 04 고무줄 한계 — 최대 거리 값을 넘어서면 늘어나는 폭이 급격히 줄어드는 당김
- 05 아이콘 진행 링 — 당긴 거리 값만큼 원형 진행 값이 채워지다 완료되면 체크 표시로 바뀌는 표시
- 06 래칫 눈금 — 당긴 거리 값이 눈금 값을 지날 때마다 위치가 짧게 튀는 표시
- 07 스프링 안착 — 손을 뗀 뒤 위치 값이 목표를 살짝 지나쳤다가 제자리로 돌아오는 표시
- 08 행 순차 등장 — 새로고침이 끝나면 목록의 각 행이 순서 값을 따라 나타나는 효과
- 09 실패 흔들림 — 새로고침이 실패한 상태가 되면 표시가 좌우로 짧게 흔들리는 효과
모바일 웹 당겨서 새로고침을 직접 만들 때는 당긴 거리를 어디서 꺾고 어디서 확정 짓는지가 손맛을 가릅니다 — 아래 표의 누적면적 값은 05 를 빼면 대체로 두 자리인데, 05 처럼 작은 링 하나만 그리는 항목은 면적이 아니라 진행 값이 채워지는 계산 한 겹의 정교함으로 재는 것이 맞습니다. 아홉 개는 당기는 동안 값이 계속 재계산되는 것(01·02·05·06) → 손을 뗀 순간의 기준으로 확정 여부가 갈리는 것(03·04) → 손을 뗀 뒤 시간에 따라 값이 스스로 바뀌는 것(07·08·09) 순으로 묶었습니다.
아홉 개의 해부 — 어떤 값이 언제 바뀌는가
실측 한눈에
| 항목 | 초점 | 누적면적% | 강도 평균 | 움직인 프레임 |
|---|---|---|---|---|
| 01 탄성 당김 | 저항 곡선 값 → resist() 재계산 | 15.39 | 30.6 | 14/23 |
| 02 회전 연동 | 비율 값 → knob 각도 재계산 | 14.13 | 125.9 | 20/23 |
| 03 기준 확정 | 히스테리시스 값 → candidate 재계산 | 13.56 | 35.0 | 21/23 |
| 04 고무줄 한계 | 점근 곡선 값 → displayedOf() 재계산 | 9.80 | 50.2 | 15/23 |
| 05 아이콘 진행 링 | 진행 값 → stroke-dashoffset 재계산 | 1.13 | 133.0 | 13/23 |
| 06 래칫 눈금 | 눈금 통과 값 → burst 재계산 | 8.08 | 74.2 | 22/23 |
| 07 스프링 안착 | 가속도 값 → pos·vel 재계산 | 7.86 | 76.8 | 17/23 |
| 08 행 순차 등장 | 순서 값 → setTimeout 지연 재계산 | 8.64 | 46.2 | 15/23 |
| 09 실패 흔들림 | 시도 횟수 값 → 흔들림 재계산 | 2.94 | 55.5 | 17/23 |
01 이 15.39%로 아홉 중 누적면적이 가장 커 그림자처럼 목록 전체가 밀려나는 폭이 크다는 뜻이고, 05 는 1.13%로 가장 작아 원형 링 하나만 채워진다는 뜻입니다. 강도 평균은 05 가 133.0으로 가장 높아 진행 링 바탕과 채움 사이 픽셀 값 차이가 뚜렷하다는 뜻이고, 01 은 30.6으로 가장 낮아 저항 곡선을 따라 완만하게 밀린다는 뜻입니다. 움직인 프레임은 06 이 22/23으로 가장 높아 눈금을 지날 때마다 거의 매 프레임 값이 갱신된다는 뜻이고, 05 는 13/23으로 가장 낮아 진행 값이 짧게 채워지고 멈춘다는 뜻입니다.
당기는 동안 값이 계속 재계산되는 것 — 01·02·05·06
01 은 resist(raw)가 D_MAX * raw / (raw + K)(D_MAX 56·K 80)로 당긴 원 거리를 밀림 거리로 바꿔, raw 가 커질수록 분모가 함께 커져 밀림 값이 늘어나는 폭이 점점 좁아지는 포화 곡선을 매 프레임 그립니다. 02 는 당긴 거리를 기준 거리(REF 120)로 나눈 비율(ratio)만큼 knob을 궤도(SWEEP 270도)로 돌리다가, 비율이 1 이 되면 spinFrame()이 거리 연동을 놓고 각속도(SPIN_SPEED 0.62)로 계속 도는 로딩 상태로 넘어갑니다. 05 는 당긴 거리를 REF 로 나눈 진행 값(progress)만큼 ringOf()가 원둘레(C)를 따라 stroke-dashoffset을 채우고, 06 은 당긴 거리가 눈금 간격(TICK 22)의 배수를 새로 지날 때마다 triggerBurst()가 감쇠 진동(AMP 10·TAU 0.15·W 26)을 위치 값에 얹어 손가락이 칸을 지날 때마다 짧게 튀는 래칫감을 만듭니다.
손을 뗀 순간의 기준으로 확정 여부가 갈리는 것 — 03·04
03 은 setCandidate()가 raw 하나의 부등호가 아니라 두 문턱의 히스테리시스로 확정 후보를 정합니다 — ON(100px)을 넘어야 candidate 가 true 로 바뀌고, 다시 OFF(70px) 아래로 내려가야만 false 로 돌아와 경계 근처에서 값이 깜빡이지 않습니다. 04 는 displayedOf()가 LINEAR(70px)까지는 raw 에 0.9배로 거의 그대로 따라가다가, 그 뒤로는 지수 감쇠(1-e^(-x/K), K 42)로 MAX_D(120px)에 점근해 같은 힘이라도 한계 근처에선 늘어나는 폭 값이 급격히 줄어듭니다.
손을 뗀 뒤 시간에 따라 값이 스스로 바뀌는 것 — 07·08·09
07 은 손을 뗀 뒤 integrating이 true 가 되면 매 프레임 accel = -K * (pos - target) - C * vel(K 170·C 9)로 스프링 힘과 감쇠를 더해 위치 값이 목표를 살짝 지나쳤다가 제자리에 앉는 값을 계산합니다. 08 은 새로고침이 끝난 상태가 되면 refreshCycle()이 행마다 idx * STAGGER_MS(90ms)만큼 지연된 setTimeout을 따로 걸어 순서 값이 큰 행일수록 나중에 나타나게 하고, 09 는 클릭이 아니라 당김이 확정될 때마다 attempt()가 attemptCount의 홀짝(attemptCount % 2)으로 성공·실패를 결정적으로 번갈아 정하고 실패 시에만 감쇠 진동(AMP 10·TAU 0.12·FREQ 46)으로 흔들림 값을 계산합니다.
패턴별 코드
01탄성 당김 — 당긴 거리 값이 늘어날수록 저항이 커지는 새로고침 트리거
손가락으로 당긴 원 거리 값이 커질수록 실제로 밀리는 거리 값은 점점 더 적게 늘어나고, 손을 뗀 상태가 되면 그 거리 값이 다시 0으로 돌아옵니다. 모바일 웹 피드 새로고침, 리스트 최상단 당김 영역에 맞는 형태입니다.
D_MAX·K 로 저항 곡선을 매 프레임 계산하는 resist()
// displayed = D_MAX * raw / (raw + K) — raw 가 커질수록 늘어나는 폭이 점점 좁아지는 포화 곡선(Michaelis-Menten).
var D_MAX = 56, K = 80, RAW_MAX = 200;
function resist(raw) { return D_MAX * raw / (raw + K); }
function apply(raw) {
var displayed = resist(raw);
var ratio = displayed / D_MAX;
ghost.style.transform = 'translateY(' + Math.min(raw, D_MAX + 24).toFixed(1) + 'px)';
ghost.style.opacity = String(Math.min(1, raw / 30));
dot.style.transform = 'translateY(' + displayed.toFixed(1) + 'px) scale(' + (0.6 + 0.4 * ratio).toFixed(2) + ')';
dot.style.opacity = String(ratio);
list.style.transform = 'translateY(' + displayed.toFixed(1) + 'px)';
hint.textContent = raw > 4 ? ('당김 ' + Math.round(raw) + 'px · 밀림 ' + Math.round(displayed) + 'px') : '아래로 당겨보세요';
}
var raw = 0, target = 0, raf = null;
function settle() {
raw += (target - raw) * 0.28;
if (Math.abs(target - raw) < 0.3) { raw = target; }
apply(raw);
if (raw !== target) { raf = requestAnimationFrame(settle); } else { raf = null; }
}

<main class="stage">
<div class="pred is-demo" id="pred">
<div class="pred__phone" id="predPhone">
<div class="pred__zone">
<span class="pred__ghost" id="predGhost"></span>
<span class="pred__dot" id="predDot"></span>
</div>
<div class="pred__list" id="predList">
<div class="pred__row"></div>
<div class="pred__row"></div>
<div class="pred__row"></div>
</div>
</div>
<span class="pred__hint" id="predHint">아래로 당겨보세요</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(저항 곡선 resist())은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-paper);
.pred {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.pred__phone {
position: relative;
width: 220px;
height: 168px;
overflow: hidden;
border-radius: $r-card;
background: $subject-cream;
touch-action: none;
cursor: grab;
}
.pred__zone {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 48px;
display: grid;
place-items: center;
}
.pred__ghost {
position: absolute;
top: 6px;
width: 4px;
height: 4px;
border-radius: $r-pill;
background: rgba($subject-ink, .28);
opacity: 0;
}
.pred__dot {
position: relative;
width: 20px;
height: 20px;
border-radius: $r-pill;
background: $subject-blue-deep;
opacity: 0;
}
.pred__list {
position: relative;
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-3 $sp-4;
padding-top: 52px;
}
.pred__row {
height: 20px;
border-radius: $r-sm;
background: $stage-paper;
box-shadow: 0 1px 0 rgba($subject-ink, .1);
}
.pred__hint {
color: rgba($subject-ink, .72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.pred__phone { width: 176px; height: 132px; }
.pred__zone { height: 40px; }
.pred__list { padding: $sp-2 $sp-3; padding-top: 44px; gap: $sp-1; }
.pred__row { height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.pred__dot, .pred__list, .pred__ghost { transition: none !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);
}
.pred {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.pred__phone {
position: relative;
width: 220px;
height: 168px;
overflow: hidden;
border-radius: 16px;
background: #fff7e6;
touch-action: none;
cursor: grab;
}
.pred__zone {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 48px;
display: grid;
place-items: center;
}
.pred__ghost {
position: absolute;
top: 6px;
width: 4px;
height: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.28);
opacity: 0;
}
.pred__dot {
position: relative;
width: 20px;
height: 20px;
border-radius: 999px;
background: #0a4fe8;
opacity: 0;
}
.pred__list {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
padding-top: 52px;
}
.pred__row {
height: 20px;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 1px 0 rgba(23, 20, 26, 0.1);
}
.pred__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.pred__phone {
width: 176px;
height: 132px;
}
.pred__zone {
height: 40px;
}
.pred__list {
padding: 8px 12px;
padding-top: 44px;
gap: 4px;
}
.pred__row {
height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.pred__dot, .pred__list, .pred__ghost {
transition: none !important;
}
}(function () {
var phone = document.getElementById('predPhone');
var ghost = document.getElementById('predGhost');
var dot = document.getElementById('predDot');
var list = document.getElementById('predList');
var hint = document.getElementById('predHint');
var root = document.getElementById('pred');
// 계산 겹: 당긴 원 거리(raw)를 밀림 거리(displayed)로 바꾸는 저항 곡선.
// displayed = D_MAX * raw / (raw + K) — raw 가 커질수록 늘어나는 폭이 점점 좁아지는 포화 곡선(Michaelis-Menten).
var D_MAX = 56, K = 80, RAW_MAX = 200;
function resist(raw) { return D_MAX * raw / (raw + K); }
function apply(raw) {
var displayed = resist(raw);
var ratio = displayed / D_MAX;
ghost.style.transform = 'translateY(' + Math.min(raw, D_MAX + 24).toFixed(1) + 'px)';
ghost.style.opacity = String(Math.min(1, raw / 30));
dot.style.transform = 'translateY(' + displayed.toFixed(1) + 'px) scale(' + (0.6 + 0.4 * ratio).toFixed(2) + ')';
dot.style.opacity = String(ratio);
list.style.transform = 'translateY(' + displayed.toFixed(1) + 'px)';
hint.textContent = raw > 4 ? ('당김 ' + Math.round(raw) + 'px · 밀림 ' + Math.round(displayed) + 'px') : '아래로 당겨보세요';
}
var raw = 0, target = 0, raf = null;
function settle() {
raw += (target - raw) * 0.28;
if (Math.abs(target - raw) < 0.3) { raw = target; }
apply(raw);
if (raw !== target) { raf = requestAnimationFrame(settle); } else { raf = null; }
}
function goTo(v) { target = v; if (raf === null) { raf = requestAnimationFrame(settle); } }
var dragging = false, startY = 0, startRaw = 0;
function onDown(y) { dragging = true; startY = y; startRaw = raw; if (raf) { cancelAnimationFrame(raf); raf = null; } }
function onMove(y) {
if (!dragging) { return; }
raw = Math.max(0, Math.min(RAW_MAX, startRaw + (y - startY)));
apply(raw);
}
function onUp() { if (!dragging) { return; } dragging = false; goTo(0); }
function wireReal() {
phone.addEventListener('pointerdown', function (e) { root.classList.remove('is-demo'); stopAutoplay(); onDown(e.clientY); });
window.addEventListener('pointermove', function (e) { onMove(e.clientY); });
window.addEventListener('pointerup', onUp);
}
phone.style.touchAction = 'none';
wireReal();
apply(0);
// 자동 루프: raw 를 0→RAW_MAX 로 단계적으로 올렸다가 놓아 같은 resist()·settle() 경로를 탄다.
var autoRaw = 0, autoTimer = null, autoDir = 1;
function autoTick() {
autoRaw += autoDir * 40;
if (autoRaw >= RAW_MAX) { autoRaw = RAW_MAX; autoDir = -1; goTo(0); return; }
if (autoRaw <= 0 && autoDir < 0) { autoRaw = 0; autoDir = 1; }
if (autoDir > 0) { raw = autoRaw; apply(raw); }
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 180); }
})();import { useEffect, useRef } from "react";
import styles from "./PullRefreshElasticDrag.module.scss";
export type PullRefreshElasticDragProps = {
color?: string;
};
export default function PullRefreshElasticDrag({
color = "#0a4fe8",
}: PullRefreshElasticDragProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const phone = root.querySelector<HTMLDivElement>(`.${styles.phone}`);
const ghost = root.querySelector<HTMLSpanElement>(`.${styles.ghost}`);
const dot = root.querySelector<HTMLSpanElement>(`.${styles.dot}`);
const list = root.querySelector<HTMLDivElement>(`.${styles.list}`);
const hint = root.querySelector<HTMLSpanElement>(`.${styles.hint}`);
if (!phone || !ghost || !dot || !list || !hint) return;
// 계산 겹: 당긴 원 거리(raw)를 밀림 거리(displayed)로 바꾸는 저항 곡선.
// displayed = D_MAX * raw / (raw + K) — raw 가 커질수록 늘어나는 폭이 점점 좁아지는 포화 곡선(Michaelis-Menten).
const D_MAX = 56, K = 80, RAW_MAX = 200;
function resist(raw: number) { return D_MAX * raw / (raw + K); }
function apply(raw: number) {
const displayed = resist(raw);
const ratio = displayed / D_MAX;
ghost!.style.transform = "translateY(" + Math.min(raw, D_MAX + 24).toFixed(1) + "px)";
ghost!.style.opacity = String(Math.min(1, raw / 30));
dot!.style.transform = "translateY(" + displayed.toFixed(1) + "px) scale(" + (0.6 + 0.4 * ratio).toFixed(2) + ")";
dot!.style.opacity = String(ratio);
list!.style.transform = "translateY(" + displayed.toFixed(1) + "px)";
hint!.textContent = raw > 4 ? ("당김 " + Math.round(raw) + "px · 밀림 " + Math.round(displayed) + "px") : "아래로 당겨보세요";
}
let raw = 0, target = 0, raf: number | null = null;
function settle() {
raw += (target - raw) * 0.28;
if (Math.abs(target - raw) < 0.3) { raw = target; }
apply(raw);
raf = raw !== target ? requestAnimationFrame(settle) : null;
}
function goTo(v: number) { target = v; if (raf === null) { raf = requestAnimationFrame(settle); } }
let dragging = false, startY = 0, startRaw = 0;
function onDown(y: number) { dragging = true; startY = y; startRaw = raw; if (raf !== null) { cancelAnimationFrame(raf); raf = null; } }
function onMove(y: number) {
if (!dragging) return;
raw = Math.max(0, Math.min(RAW_MAX, startRaw + (y - startY)));
apply(raw);
}
function onUp() { if (!dragging) return; dragging = false; goTo(0); }
function onPointerDown(e: PointerEvent) { stopAutoplay(); onDown(e.clientY); }
function onPointerMove(e: PointerEvent) { onMove(e.clientY); }
phone.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onUp);
apply(0);
let autoRaw = 0, autoTimer: number | null = null, autoDir = 1;
function autoTick() {
autoRaw += autoDir * 40;
if (autoRaw >= RAW_MAX) { autoRaw = RAW_MAX; autoDir = -1; goTo(0); return; }
if (autoRaw <= 0 && autoDir < 0) { autoRaw = 0; autoDir = 1; }
if (autoDir > 0) { raw = autoRaw; apply(raw); }
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 180); }
return () => {
stopAutoplay();
if (raf !== null) { cancelAnimationFrame(raf); }
phone.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onUp);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.pred}>
<div className={styles.phone}>
<div className={styles.zone}>
<span className={styles.ghost} />
<span className={styles.dot} style={{ background: color }} />
</div>
<div className={styles.list}>
<div className={styles.row} />
<div className={styles.row} />
<div className={styles.row} />
</div>
</div>
<span className={styles.hint}>아래로 당겨보세요</span>
</div>
</div>
);
}01 · 탄성 당김 — 당긴 거리 값이 늘어날수록 저항이 커지는 새로고침 트리거 — 측정 누적면적 15.385% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
02회전 연동 — 당긴 거리 값의 비율만큼 회전 값이 함께 도는 표시
당긴 거리 값을 기준 거리로 나눈 비율 값만큼 표시의 회전 값이 함께 돌아가고, 비율 값이 1이 된 상태가 되면 회전이 계속 이어지는 상태로 바뀝니다. 뉴스 피드 상단 표시, 받은편지함 새로고침에 맞는 형태입니다.
REF·SWEEP 비율로 회전 각도를 계산하는 spinFrame()
function place(angleDeg, pullPx) {
var rad = (angleDeg - 90) * Math.PI / 180;
var x = Math.cos(rad) * RADIUS, y = Math.sin(rad) * RADIUS;
knob.style.transform = 'translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px)';
list.style.transform = 'translateY(' + pullPx.toFixed(1) + 'px)';
}
var state = 'idle'; // idle -> dragging -> spinning -> idle
var raw = 0, ratio = 0, spinAngle = 0, spinStart = 0, spinRaf = null;
function fromRatio() { return ratio * SWEEP; }
function spinFrame(ts) {
if (!spinStart) { spinStart = ts; }
var el = ts - spinStart;
spinAngle = (el * SPIN_SPEED) % 360;
place(spinAngle, 24);
if (el < 900) {
spinRaf = requestAnimationFrame(spinFrame);
} else {
state = 'idle'; ratio = 0; raw = 0; spinStart = 0;
hint.textContent = '당겨서 새로고침';
place(0, 0);
}
}

<main class="stage">
<div class="prrb is-demo" id="prrb">
<div class="prrb__phone" id="prrbPhone">
<div class="prrb__zone">
<span class="prrb__ring"></span>
<span class="prrb__knob" id="prrbKnob"></span>
</div>
<div class="prrb__list" id="prrbList">
<div class="prrb__row"></div>
<div class="prrb__row"></div>
<div class="prrb__row"></div>
</div>
</div>
<span class="prrb__hint" id="prrbHint">당겨서 새로고침</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(비율→각도 place())은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-yellow);
.prrb {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.prrb__phone {
position: relative;
width: 220px;
height: 168px;
overflow: hidden;
border-radius: $r-card;
background: $stage-paper;
touch-action: none;
cursor: grab;
}
.prrb__zone {
position: relative;
height: 56px;
display: grid;
place-items: center;
}
.prrb__ring {
position: absolute;
width: 36px;
height: 36px;
border-radius: $r-pill;
border: 2px solid rgba($subject-ink, .16);
}
.prrb__knob {
position: relative;
width: 8px;
height: 8px;
border-radius: $r-pill;
background: $subject-blue-deep;
}
.prrb__list {
position: relative;
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-3 $sp-4;
}
.prrb__row {
height: 20px;
border-radius: $r-sm;
background: $subject-cream;
}
.prrb__hint {
color: rgba($subject-ink, .72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.prrb__phone { width: 176px; height: 132px; }
.prrb__zone { height: 44px; }
.prrb__ring { width: 28px; height: 28px; }
.prrb__list { padding: $sp-2 $sp-3; gap: $sp-1; }
.prrb__row { height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.prrb__knob, .prrb__list { transition: none !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);
}
.prrb {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.prrb__phone {
position: relative;
width: 220px;
height: 168px;
overflow: hidden;
border-radius: 16px;
background: #ffffff;
touch-action: none;
cursor: grab;
}
.prrb__zone {
position: relative;
height: 56px;
display: grid;
place-items: center;
}
.prrb__ring {
position: absolute;
width: 36px;
height: 36px;
border-radius: 999px;
border: 2px solid rgba(23, 20, 26, 0.16);
}
.prrb__knob {
position: relative;
width: 8px;
height: 8px;
border-radius: 999px;
background: #0a4fe8;
}
.prrb__list {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
}
.prrb__row {
height: 20px;
border-radius: 8px;
background: #fff7e6;
}
.prrb__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.prrb__phone {
width: 176px;
height: 132px;
}
.prrb__zone {
height: 44px;
}
.prrb__ring {
width: 28px;
height: 28px;
}
.prrb__list {
padding: 8px 12px;
gap: 4px;
}
.prrb__row {
height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.prrb__knob, .prrb__list {
transition: none !important;
}
}(function () {
var phone = document.getElementById('prrbPhone');
var knob = document.getElementById('prrbKnob');
var list = document.getElementById('prrbList');
var hint = document.getElementById('prrbHint');
var root = document.getElementById('prrb');
// 계산 겹: 당긴 거리(raw)를 기준 거리(REF)로 나눈 비율(ratio)만큼 knob 을 궤도(SWEEP도)로 돌린다.
// ratio 가 1 이 되면 거리 연동을 놓고 각속도(SPIN_SPEED)로 계속 도는 로딩 상태로 넘어간다.
var REF = 120, SWEEP = 270, RADIUS = 18, SPIN_SPEED = 0.62; // deg / ms
function place(angleDeg, pullPx) {
var rad = (angleDeg - 90) * Math.PI / 180;
var x = Math.cos(rad) * RADIUS, y = Math.sin(rad) * RADIUS;
knob.style.transform = 'translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px)';
list.style.transform = 'translateY(' + pullPx.toFixed(1) + 'px)';
}
var state = 'idle'; // idle -> dragging -> spinning -> idle
var raw = 0, ratio = 0, spinAngle = 0, spinStart = 0, spinRaf = null;
function fromRatio() { return ratio * SWEEP; }
function spinFrame(ts) {
if (!spinStart) { spinStart = ts; }
var el = ts - spinStart;
spinAngle = (el * SPIN_SPEED) % 360;
place(spinAngle, 24);
if (el < 900) {
spinRaf = requestAnimationFrame(spinFrame);
} else {
state = 'idle'; ratio = 0; raw = 0; spinStart = 0;
hint.textContent = '당겨서 새로고침';
place(0, 0);
}
}
function startSpin() {
state = 'spinning';
hint.textContent = '불러오는 중';
spinStart = 0;
spinRaf = requestAnimationFrame(spinFrame);
}
function apply(r) {
raw = r;
ratio = Math.max(0, Math.min(1, raw / REF));
place(fromRatio(), ratio * 24);
hint.textContent = ratio > 0.05 ? ('회전 ' + Math.round(fromRatio()) + '도 · 비율 ' + Math.round(ratio * 100) + '%') : '당겨서 새로고침';
}
var dragging = false, startY = 0, startRaw = 0;
function onDown(y) {
if (state === 'spinning') { return; }
dragging = true; state = 'dragging'; startY = y; startRaw = raw;
}
function onMove(y) {
if (!dragging) { return; }
apply(Math.max(0, Math.min(REF * 1.3, startRaw + (y - startY))));
}
function onUp() {
if (!dragging) { return; }
dragging = false;
if (ratio >= 1) { startSpin(); } else { state = 'idle'; apply(0); }
}
function wireReal() {
phone.addEventListener('pointerdown', function (e) { root.classList.remove('is-demo'); stopAutoplay(); onDown(e.clientY); });
window.addEventListener('pointermove', function (e) { onMove(e.clientY); });
window.addEventListener('pointerup', onUp);
}
phone.style.touchAction = 'none';
wireReal();
place(0, 0);
// 자동 루프: raw 를 단계적으로 REF 까지 올려 같은 apply()·startSpin() 경로를 탄다.
var autoRaw = 0, autoTimer = null;
function autoTick() {
if (state === 'spinning') { return; }
autoRaw += 30;
if (autoRaw >= REF) { apply(REF); startSpin(); autoRaw = 0; return; }
apply(autoRaw);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } if (spinRaf) { cancelAnimationFrame(spinRaf); } }
if (!reduce) { autoTimer = setInterval(autoTick, 170); }
})();import { useEffect, useRef } from "react";
import styles from "./PullRefreshRotationBind.module.scss";
export type PullRefreshRotationBindProps = {
color?: string;
};
export default function PullRefreshRotationBind({
color = "#0a4fe8",
}: PullRefreshRotationBindProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const phone = root.querySelector<HTMLDivElement>(`.${styles.phone}`);
const knob = root.querySelector<HTMLSpanElement>(`.${styles.knob}`);
const list = root.querySelector<HTMLDivElement>(`.${styles.list}`);
const hint = root.querySelector<HTMLSpanElement>(`.${styles.hint}`);
if (!phone || !knob || !list || !hint) return;
// 계산 겹: 당긴 거리(raw)를 기준 거리(REF)로 나눈 비율(ratio)만큼 knob 을 궤도(SWEEP도)로 돌린다.
// ratio 가 1 이 되면 거리 연동을 놓고 각속도(SPIN_SPEED)로 계속 도는 로딩 상태로 넘어간다.
const REF = 120, SWEEP = 270, RADIUS = 18, SPIN_SPEED = 0.62;
function place(angleDeg: number, pullPx: number) {
const rad = (angleDeg - 90) * Math.PI / 180;
const x = Math.cos(rad) * RADIUS, y = Math.sin(rad) * RADIUS;
knob!.style.transform = "translate(" + x.toFixed(1) + "px," + y.toFixed(1) + "px)";
list!.style.transform = "translateY(" + pullPx.toFixed(1) + "px)";
}
let state: "idle" | "dragging" | "spinning" = "idle";
let raw = 0, ratio = 0, spinAngle = 0, spinStart = 0, spinRaf: number | null = null;
function fromRatio() { return ratio * SWEEP; }
function spinFrame(ts: number) {
if (!spinStart) { spinStart = ts; }
const el = ts - spinStart;
spinAngle = (el * SPIN_SPEED) % 360;
place(spinAngle, 24);
if (el < 900) {
spinRaf = requestAnimationFrame(spinFrame);
} else {
state = "idle"; ratio = 0; raw = 0; spinStart = 0;
hint!.textContent = "당겨서 새로고침";
place(0, 0);
}
}
function startSpin() {
state = "spinning";
hint!.textContent = "불러오는 중";
spinStart = 0;
spinRaf = requestAnimationFrame(spinFrame);
}
function apply(r: number) {
raw = r;
ratio = Math.max(0, Math.min(1, raw / REF));
place(fromRatio(), ratio * 24);
hint!.textContent = ratio > 0.05 ? ("회전 " + Math.round(fromRatio()) + "도 · 비율 " + Math.round(ratio * 100) + "%") : "당겨서 새로고침";
}
let dragging = false, startY = 0, startRaw = 0;
function onDown(y: number) {
if (state === "spinning") return;
dragging = true; state = "dragging"; startY = y; startRaw = raw;
}
function onMove(y: number) {
if (!dragging) return;
apply(Math.max(0, Math.min(REF * 1.3, startRaw + (y - startY))));
}
function onUp() {
if (!dragging) return;
dragging = false;
if (ratio >= 1) { startSpin(); } else { state = "idle"; apply(0); }
}
function onPointerDown(e: PointerEvent) { stopAutoplay(); onDown(e.clientY); }
function onPointerMove(e: PointerEvent) { onMove(e.clientY); }
phone.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onUp);
place(0, 0);
let autoRaw = 0, autoTimer: number | null = null;
function autoTick() {
if (state === "spinning") return;
autoRaw += 30;
if (autoRaw >= REF) { apply(REF); startSpin(); autoRaw = 0; return; }
apply(autoRaw);
}
function stopAutoplay() {
if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; }
if (spinRaf !== null) { cancelAnimationFrame(spinRaf); }
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 170); }
return () => {
stopAutoplay();
phone.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onUp);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.prrb}>
<div className={styles.phone}>
<div className={styles.zone}>
<span className={styles.ring} />
<span className={styles.knob} style={{ background: color }} />
</div>
<div className={styles.list}>
<div className={styles.row} />
<div className={styles.row} />
<div className={styles.row} />
</div>
</div>
<span className={styles.hint}>당겨서 새로고침</span>
</div>
</div>
);
}02 · 회전 연동 — 당긴 거리 값의 비율만큼 회전 값이 함께 도는 표시 — 측정 누적면적 14.129% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
03기준 확정 — 당긴 거리 값이 기준을 넘으면 확정 상태로 바뀌는 트리거
당긴 거리 값이 정해진 기준 값을 넘은 상태에서 손을 뗀 상태가 되면 확정된 상태로 바뀌어 새로고침이 시작되고, 기준에 못 미친 채로 손을 뗀 상태가 되면 거리 값이 0으로 되돌아갑니다. 메일함 새로고침, SNS 타임라인 새로고침에 맞는 형태입니다.
ON·OFF 히스테리시스로 확정 후보를 정하는 setCandidate()
// 계산 겹: 확정 여부(committed candidate)는 raw 하나의 부등호가 아니라 두 문턱의 히스테리시스로 정한다.
// ON(100px) 을 넘으면 true 로, 다시 OFF(70px) 아래로 내려가야만 false 로 — 경계 근처에서 깜빡이지 않는다.
var ON = 100, OFF = 70, MAX = 160;
var candidate = false, state = 'idle'; // idle -> dragging -> committing -> idle
function setCandidate(raw) {
if (!candidate && raw >= ON) { candidate = true; }
else if (candidate && raw < OFF) { candidate = false; }
}
function apply(raw) {
setCandidate(raw);
var pull = Math.min(raw, MAX) * 0.3;
disc.style.transform = 'translateY(' + pull.toFixed(1) + 'px) scale(' + (0.7 + 0.3 * Math.min(1, raw / ON)).toFixed(2) + ')';
disc.classList.toggle('prtc__disc--ready', candidate);
list.style.transform = 'translateY(' + pull.toFixed(1) + 'px)';
hint.textContent = candidate ? '놓으면 새로고침' : (raw > 4 ? '더 당기세요 (' + Math.round(raw) + '/' + ON + 'px)' : '아래로 당기세요');
}

<main class="stage">
<div class="prtc is-demo" id="prtc">
<div class="prtc__phone" id="prtcPhone">
<div class="prtc__zone">
<span class="prtc__disc" id="prtcDisc"></span>
</div>
<div class="prtc__list" id="prtcList">
<div class="prtc__row"></div>
<div class="prtc__row"></div>
<div class="prtc__row"></div>
</div>
</div>
<span class="prtc__hint" id="prtcHint">아래로 당기세요</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(히스테리시스 setCandidate())은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-paper);
.prtc {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.prtc__phone {
position: relative;
width: 220px;
height: 168px;
overflow: hidden;
border-radius: $r-card;
background: $subject-cream;
touch-action: none;
cursor: grab;
}
.prtc__zone {
position: relative;
height: 56px;
display: grid;
place-items: center;
}
.prtc__disc {
width: 28px;
height: 28px;
border-radius: $r-pill;
background: rgba($subject-ink, .22);
transition: background $dur-quick $ease-out;
&--ready { background: $subject-blue-deep; }
&--spin { animation: prtcSpin .6s linear infinite; }
}
@keyframes prtcSpin {
from { transform: rotate(0deg) scale(1); }
to { transform: rotate(360deg) scale(1); }
}
.prtc__list {
position: relative;
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-3 $sp-4;
}
.prtc__row {
height: 20px;
border-radius: $r-sm;
background: $stage-paper;
}
.prtc__hint {
color: rgba($subject-ink, .72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.prtc__phone { width: 176px; height: 132px; }
.prtc__zone { height: 44px; }
.prtc__disc { width: 22px; height: 22px; }
.prtc__list { padding: $sp-2 $sp-3; gap: $sp-1; }
.prtc__row { height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.prtc__disc, .prtc__list { transition: none !important; animation: none !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);
}
.prtc {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.prtc__phone {
position: relative;
width: 220px;
height: 168px;
overflow: hidden;
border-radius: 16px;
background: #fff7e6;
touch-action: none;
cursor: grab;
}
.prtc__zone {
position: relative;
height: 56px;
display: grid;
place-items: center;
}
.prtc__disc {
width: 28px;
height: 28px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.22);
transition: background 220ms ease-out;
}
.prtc__disc--ready {
background: #0a4fe8;
}
.prtc__disc--spin {
animation: prtcSpin 0.6s linear infinite;
}
@keyframes prtcSpin {
from {
transform: rotate(0deg) scale(1);
}
to {
transform: rotate(360deg) scale(1);
}
}
.prtc__list {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
}
.prtc__row {
height: 20px;
border-radius: 8px;
background: #ffffff;
}
.prtc__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.prtc__phone {
width: 176px;
height: 132px;
}
.prtc__zone {
height: 44px;
}
.prtc__disc {
width: 22px;
height: 22px;
}
.prtc__list {
padding: 8px 12px;
gap: 4px;
}
.prtc__row {
height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.prtc__disc, .prtc__list {
transition: none !important;
animation: none !important;
}
}(function () {
var phone = document.getElementById('prtcPhone');
var disc = document.getElementById('prtcDisc');
var list = document.getElementById('prtcList');
var hint = document.getElementById('prtcHint');
var root = document.getElementById('prtc');
// 계산 겹: 확정 여부(committed candidate)는 raw 하나의 부등호가 아니라 두 문턱의 히스테리시스로 정한다.
// ON(100px) 을 넘으면 true 로, 다시 OFF(70px) 아래로 내려가야만 false 로 — 경계 근처에서 깜빡이지 않는다.
var ON = 100, OFF = 70, MAX = 160;
var candidate = false, state = 'idle'; // idle -> dragging -> committing -> idle
function setCandidate(raw) {
if (!candidate && raw >= ON) { candidate = true; }
else if (candidate && raw < OFF) { candidate = false; }
}
function apply(raw) {
setCandidate(raw);
var pull = Math.min(raw, MAX) * 0.3;
disc.style.transform = 'translateY(' + pull.toFixed(1) + 'px) scale(' + (0.7 + 0.3 * Math.min(1, raw / ON)).toFixed(2) + ')';
disc.classList.toggle('prtc__disc--ready', candidate);
list.style.transform = 'translateY(' + pull.toFixed(1) + 'px)';
hint.textContent = candidate ? '놓으면 새로고침' : (raw > 4 ? '더 당기세요 (' + Math.round(raw) + '/' + ON + 'px)' : '아래로 당기세요');
}
function commit() {
state = 'committing';
hint.textContent = '새로고침 중';
disc.classList.add('prtc__disc--spin');
setTimeout(function () {
disc.classList.remove('prtc__disc--spin', 'prtc__disc--ready');
candidate = false; state = 'idle';
hint.textContent = '아래로 당기세요';
apply(0);
}, 620);
}
var dragging = false, startY = 0, startRaw = 0, raw = 0, target = 0, raf = null;
function settle() {
raw += (target - raw) * 0.3;
if (Math.abs(target - raw) < 0.3) { raw = target; }
apply(raw);
if (raw !== target) { raf = requestAnimationFrame(settle); } else { raf = null; }
}
function goTo(v) { target = v; if (raf === null) { raf = requestAnimationFrame(settle); } }
function onDown(y) {
if (state !== 'idle') { return; }
dragging = true; state = 'dragging'; startY = y; startRaw = raw;
if (raf) { cancelAnimationFrame(raf); raf = null; }
}
function onMove(y) {
if (!dragging) { return; }
raw = Math.max(0, Math.min(MAX, startRaw + (y - startY)));
apply(raw);
}
function onUp() {
if (!dragging) { return; }
dragging = false;
if (candidate) { commit(); } else { state = 'idle'; goTo(0); }
}
function wireReal() {
phone.addEventListener('pointerdown', function (e) { root.classList.remove('is-demo'); stopAutoplay(); onDown(e.clientY); });
window.addEventListener('pointermove', function (e) { onMove(e.clientY); });
window.addEventListener('pointerup', onUp);
}
phone.style.touchAction = 'none';
wireReal();
apply(0);
// 자동 루프: raw 를 단계적으로 MAX 까지 올려 같은 setCandidate()·commit() 경로를 탄다.
var autoRaw = 0, autoTimer = null;
function autoTick() {
if (state !== 'idle' && state !== 'dragging') { return; }
state = 'dragging';
autoRaw += 28;
if (autoRaw >= MAX) {
raw = MAX; apply(raw); autoRaw = 0;
if (candidate) { commit(); } else { state = 'idle'; goTo(0); }
return;
}
raw = autoRaw; apply(raw);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 170); }
})();import { useEffect, useRef } from "react";
import styles from "./PullRefreshThresholdCommit.module.scss";
export type PullRefreshThresholdCommitProps = {
color?: string;
};
export default function PullRefreshThresholdCommit({
color = "#0a4fe8",
}: PullRefreshThresholdCommitProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const phone = root.querySelector<HTMLDivElement>(`.${styles.phone}`);
const disc = root.querySelector<HTMLSpanElement>(`.${styles.disc}`);
const list = root.querySelector<HTMLDivElement>(`.${styles.list}`);
const hint = root.querySelector<HTMLSpanElement>(`.${styles.hint}`);
if (!phone || !disc || !list || !hint) return;
// 계산 겹: 확정 여부(committed candidate)는 raw 하나의 부등호가 아니라 두 문턱의 히스테리시스로 정한다.
// ON(100px) 을 넘으면 true 로, 다시 OFF(70px) 아래로 내려가야만 false 로 — 경계 근처에서 깜빡이지 않는다.
const ON = 100, OFF = 70, MAX = 160;
let candidate = false;
let state: "idle" | "dragging" | "committing" = "idle";
const readyClass = styles["disc--ready"];
const spinClass = styles["disc--spin"];
function setCandidate(raw: number) {
if (!candidate && raw >= ON) { candidate = true; }
else if (candidate && raw < OFF) { candidate = false; }
}
function apply(raw: number) {
setCandidate(raw);
const pull = Math.min(raw, MAX) * 0.3;
disc!.style.transform = "translateY(" + pull.toFixed(1) + "px) scale(" + (0.7 + 0.3 * Math.min(1, raw / ON)).toFixed(2) + ")";
disc!.classList.toggle(readyClass, candidate);
list!.style.transform = "translateY(" + pull.toFixed(1) + "px)";
hint!.textContent = candidate ? "놓으면 새로고침" : (raw > 4 ? "더 당기세요 (" + Math.round(raw) + "/" + ON + "px)" : "아래로 당기세요");
}
let commitTimer: number | null = null;
function commit() {
state = "committing";
hint!.textContent = "새로고침 중";
disc!.classList.add(spinClass);
commitTimer = window.setTimeout(() => {
disc!.classList.remove(spinClass, readyClass);
candidate = false; state = "idle";
hint!.textContent = "아래로 당기세요";
apply(0);
}, 620);
}
let dragging = false, startY = 0, startRaw = 0, raw = 0, target = 0, raf: number | null = null;
function settle() {
raw += (target - raw) * 0.3;
if (Math.abs(target - raw) < 0.3) { raw = target; }
apply(raw);
raf = raw !== target ? requestAnimationFrame(settle) : null;
}
function goTo(v: number) { target = v; if (raf === null) { raf = requestAnimationFrame(settle); } }
function onDown(y: number) {
if (state !== "idle") return;
dragging = true; state = "dragging"; startY = y; startRaw = raw;
if (raf !== null) { cancelAnimationFrame(raf); raf = null; }
}
function onMove(y: number) {
if (!dragging) return;
raw = Math.max(0, Math.min(MAX, startRaw + (y - startY)));
apply(raw);
}
function onUp() {
if (!dragging) return;
dragging = false;
if (candidate) { commit(); } else { state = "idle"; goTo(0); }
}
function onPointerDown(e: PointerEvent) { stopAutoplay(); onDown(e.clientY); }
function onPointerMove(e: PointerEvent) { onMove(e.clientY); }
phone.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onUp);
apply(0);
let autoRaw = 0, autoTimer: number | null = null;
function autoTick() {
if (state !== "idle" && state !== "dragging") return;
state = "dragging";
autoRaw += 28;
if (autoRaw >= MAX) {
raw = MAX; apply(raw); autoRaw = 0;
if (candidate) { commit(); } else { state = "idle"; goTo(0); }
return;
}
raw = autoRaw; apply(raw);
}
function stopAutoplay() {
if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; }
if (commitTimer !== null) { window.clearTimeout(commitTimer); }
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 170); }
return () => {
stopAutoplay();
if (raf !== null) { cancelAnimationFrame(raf); }
phone.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onUp);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.prtc}>
<div className={styles.phone}>
<div className={styles.zone}>
<span className={styles.disc} style={{ "--accent": color } as any} />
</div>
<div className={styles.list}>
<div className={styles.row} />
<div className={styles.row} />
</div>
</div>
<span className={styles.hint}>아래로 당기세요</span>
</div>
</div>
);
}03 · 기준 확정 — 당긴 거리 값이 기준을 넘으면 확정 상태로 바뀌는 트리거 — 측정 누적면적 13.559% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
04고무줄 한계 — 최대 거리 값을 넘어서면 늘어나는 폭이 급격히 줄어드는 당김
당긴 거리 값이 정해진 최대 값에 가까워질수록 같은 힘에도 늘어나는 폭 값이 급격히 줄어들고, 손을 뗀 상태가 되면 거리 값이 원래 위치로 튕겨 돌아옵니다. 짧은 리스트 새로고침, 과도한 당김 방지 영역에 맞는 형태입니다.
LINEAR·MAX_D 로 늘어나는 폭을 계산하는 displayedOf()
// 계산 겹: LINEAR(70px) 까지는 raw 에 0.9 배로 거의 그대로 따라가다가, 그 뒤로는 MAX_D(120px) 에
// 점근하는 지수 감쇠(1-e^-x/K)로 늘어나는 폭이 급격히 줄어든다 — 같은 힘이라도 한계 근처에선 거의 안 늘어난다.
var LINEAR = 70, MAX_D = 120, K = 42, RAW_MAX = 260;
var TRACK_H = 92, SCALE = TRACK_H / 150;
function displayedOf(raw) {
if (raw <= LINEAR) { return raw * 0.9; }
var base = LINEAR * 0.9;
var excess = raw - LINEAR;
return base + (MAX_D - base) * (1 - Math.exp(-excess / K));
}
function apply(raw) {
var displayed = displayedOf(raw);
marker.style.transform = 'translateY(' + (-(displayed * SCALE)).toFixed(1) + 'px)';
list.style.transform = 'translateY(' + (displayed * 0.55).toFixed(1) + 'px)';
var pctOfCap = Math.round((displayed / MAX_D) * 100);
hint.textContent = raw > 4 ? ('밀림 ' + Math.round(displayed) + 'px · 한계의 ' + pctOfCap + '%') : '아래로 당기세요';
}

<main class="stage">
<div class="prrbo is-demo" id="prrbo">
<div class="prrbo__panel">
<div class="prrbo__track" id="prrboTrack">
<span class="prrbo__cap"></span>
<span class="prrbo__marker" id="prrboMarker"></span>
</div>
<div class="prrbo__phone" id="prrboPhone">
<div class="prrbo__list" id="prrboList">
<div class="prrbo__row"></div>
<div class="prrbo__row"></div>
</div>
</div>
</div>
<span class="prrbo__hint" id="prrboHint">아래로 당기세요</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(지수 감쇠 displayedOf())은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-orange);
.prrbo {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.prrbo__panel {
display: flex;
align-items: flex-end;
gap: $sp-4;
padding: $sp-4;
border-radius: $r-card;
background: $stage-paper;
}
.prrbo__track {
position: relative;
width: 20px;
height: 92px;
border-radius: $r-xs;
background: $subject-cream;
}
.prrbo__cap {
position: absolute;
left: -4px;
right: -4px;
bottom: 77px;
height: 2px;
background: $subject-orange-deep;
}
.prrbo__marker {
position: absolute;
left: 2px;
right: 2px;
bottom: 2px;
height: 12px;
border-radius: $r-xs;
background: $subject-blue-deep;
}
.prrbo__phone {
position: relative;
width: 156px;
height: 108px;
overflow: hidden;
border-radius: $r-sm;
background: $subject-cream;
touch-action: none;
cursor: grab;
}
.prrbo__list {
position: relative;
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-3;
}
.prrbo__row {
height: 18px;
border-radius: $r-xs;
background: $stage-paper;
}
.prrbo__hint {
color: $subject-ink;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.prrbo__panel { padding: $sp-3; gap: $sp-2; }
.prrbo__track { height: 72px; }
.prrbo__cap { bottom: 60px; }
.prrbo__phone { width: 128px; height: 88px; }
}
@media (prefers-reduced-motion: reduce) {
.prrbo__marker, .prrbo__list { transition: none !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);
}
.prrbo {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.prrbo__panel {
display: flex;
align-items: flex-end;
gap: 16px;
padding: 16px;
border-radius: 16px;
background: #ffffff;
}
.prrbo__track {
position: relative;
width: 20px;
height: 92px;
border-radius: 4px;
background: #fff7e6;
}
.prrbo__cap {
position: absolute;
left: -4px;
right: -4px;
bottom: 77px;
height: 2px;
background: #c93000;
}
.prrbo__marker {
position: absolute;
left: 2px;
right: 2px;
bottom: 2px;
height: 12px;
border-radius: 4px;
background: #0a4fe8;
}
.prrbo__phone {
position: relative;
width: 156px;
height: 108px;
overflow: hidden;
border-radius: 8px;
background: #fff7e6;
touch-action: none;
cursor: grab;
}
.prrbo__list {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
}
.prrbo__row {
height: 18px;
border-radius: 4px;
background: #ffffff;
}
.prrbo__hint {
color: #17141a;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.prrbo__panel {
padding: 12px;
gap: 8px;
}
.prrbo__track {
height: 72px;
}
.prrbo__cap {
bottom: 60px;
}
.prrbo__phone {
width: 128px;
height: 88px;
}
}
@media (prefers-reduced-motion: reduce) {
.prrbo__marker, .prrbo__list {
transition: none !important;
}
}(function () {
var phone = document.getElementById('prrboPhone');
var marker = document.getElementById('prrboMarker');
var list = document.getElementById('prrboList');
var hint = document.getElementById('prrboHint');
var root = document.getElementById('prrbo');
// 계산 겹: LINEAR(70px) 까지는 raw 에 0.9 배로 거의 그대로 따라가다가, 그 뒤로는 MAX_D(120px) 에
// 점근하는 지수 감쇠(1-e^-x/K)로 늘어나는 폭이 급격히 줄어든다 — 같은 힘이라도 한계 근처에선 거의 안 늘어난다.
var LINEAR = 70, MAX_D = 120, K = 42, RAW_MAX = 260;
var TRACK_H = 92, SCALE = TRACK_H / 150;
function displayedOf(raw) {
if (raw <= LINEAR) { return raw * 0.9; }
var base = LINEAR * 0.9;
var excess = raw - LINEAR;
return base + (MAX_D - base) * (1 - Math.exp(-excess / K));
}
function apply(raw) {
var displayed = displayedOf(raw);
marker.style.transform = 'translateY(' + (-(displayed * SCALE)).toFixed(1) + 'px)';
list.style.transform = 'translateY(' + (displayed * 0.55).toFixed(1) + 'px)';
var pctOfCap = Math.round((displayed / MAX_D) * 100);
hint.textContent = raw > 4 ? ('밀림 ' + Math.round(displayed) + 'px · 한계의 ' + pctOfCap + '%') : '아래로 당기세요';
}
var raw = 0, target = 0, raf = null;
function settle() {
raw += (target - raw) * 0.3;
if (Math.abs(target - raw) < 0.3) { raw = target; }
apply(raw);
if (raw !== target) { raf = requestAnimationFrame(settle); } else { raf = null; }
}
function goTo(v) { target = v; if (raf === null) { raf = requestAnimationFrame(settle); } }
var dragging = false, startY = 0, startRaw = 0;
function onDown(y) { dragging = true; startY = y; startRaw = raw; if (raf) { cancelAnimationFrame(raf); raf = null; } }
function onMove(y) {
if (!dragging) { return; }
raw = Math.max(0, Math.min(RAW_MAX, startRaw + (y - startY)));
apply(raw);
}
function onUp() { if (!dragging) { return; } dragging = false; goTo(0); }
function wireReal() {
phone.addEventListener('pointerdown', function (e) { root.classList.remove('is-demo'); stopAutoplay(); onDown(e.clientY); });
window.addEventListener('pointermove', function (e) { onMove(e.clientY); });
window.addEventListener('pointerup', onUp);
}
phone.style.touchAction = 'none';
wireReal();
apply(0);
// 자동 루프: raw 를 RAW_MAX 까지 크게 밀어 넣어 한계선에 점근만 하고 못 넘는 모습을 보인다.
var autoRaw = 0, autoTimer = null, autoDir = 1;
function autoTick() {
autoRaw += autoDir * 55;
if (autoRaw >= RAW_MAX) { autoRaw = RAW_MAX; autoDir = -1; goTo(0); return; }
if (autoRaw <= 0 && autoDir < 0) { autoRaw = 0; autoDir = 1; }
if (autoDir > 0) { raw = autoRaw; apply(raw); }
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 170); }
})();import { useEffect, useRef } from "react";
import styles from "./PullRefreshRubberBandOverscroll.module.scss";
export type PullRefreshRubberBandOverscrollProps = {
color?: string;
};
export default function PullRefreshRubberBandOverscroll({
color = "#0a4fe8",
}: PullRefreshRubberBandOverscrollProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const phone = root.querySelector<HTMLDivElement>(`.${styles.phone}`);
const marker = root.querySelector<HTMLSpanElement>(`.${styles.marker}`);
const list = root.querySelector<HTMLDivElement>(`.${styles.list}`);
const hint = root.querySelector<HTMLSpanElement>(`.${styles.hint}`);
if (!phone || !marker || !list || !hint) return;
// 계산 겹: LINEAR(70px) 까지는 raw 에 0.9 배로 거의 그대로 따라가다가, 그 뒤로는 MAX_D(120px) 에
// 점근하는 지수 감쇠(1-e^-x/K)로 늘어나는 폭이 급격히 줄어든다 — 같은 힘이라도 한계 근처에선 거의 안 늘어난다.
const LINEAR = 70, MAX_D = 120, K = 42, RAW_MAX = 260;
const TRACK_H = 92, SCALE = TRACK_H / 150;
function displayedOf(raw: number) {
if (raw <= LINEAR) return raw * 0.9;
const base = LINEAR * 0.9;
const excess = raw - LINEAR;
return base + (MAX_D - base) * (1 - Math.exp(-excess / K));
}
function apply(raw: number) {
const displayed = displayedOf(raw);
marker!.style.transform = "translateY(" + (-(displayed * SCALE)).toFixed(1) + "px)";
list!.style.transform = "translateY(" + (displayed * 0.55).toFixed(1) + "px)";
const pctOfCap = Math.round((displayed / MAX_D) * 100);
hint!.textContent = raw > 4 ? ("밀림 " + Math.round(displayed) + "px · 한계의 " + pctOfCap + "%") : "아래로 당기세요";
}
let raw = 0, target = 0, raf: number | null = null;
function settle() {
raw += (target - raw) * 0.3;
if (Math.abs(target - raw) < 0.3) raw = target;
apply(raw);
raf = raw !== target ? requestAnimationFrame(settle) : null;
}
function goTo(v: number) { target = v; if (raf === null) { raf = requestAnimationFrame(settle); } }
let dragging = false, startY = 0, startRaw = 0;
function onDown(y: number) {
dragging = true; startY = y; startRaw = raw;
if (raf !== null) { cancelAnimationFrame(raf); raf = null; }
}
function onMove(y: number) {
if (!dragging) return;
raw = Math.max(0, Math.min(RAW_MAX, startRaw + (y - startY)));
apply(raw);
}
function onUp() { if (!dragging) return; dragging = false; goTo(0); }
function onPointerDown(e: PointerEvent) { stopAutoplay(); onDown(e.clientY); }
function onPointerMove(e: PointerEvent) { onMove(e.clientY); }
phone.style.touchAction = "none";
phone.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onUp);
apply(0);
// 자동 루프: raw 를 RAW_MAX 까지 크게 밀어 넣어 한계선에 점근만 하고 못 넘는 모습을 보인다.
let autoRaw = 0, autoTimer: number | null = null, autoDir = 1;
function autoTick() {
autoRaw += autoDir * 55;
if (autoRaw >= RAW_MAX) { autoRaw = RAW_MAX; autoDir = -1; goTo(0); return; }
if (autoRaw <= 0 && autoDir < 0) { autoRaw = 0; autoDir = 1; }
if (autoDir > 0) { raw = autoRaw; apply(raw); }
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 170); }
return () => {
stopAutoplay();
if (raf !== null) cancelAnimationFrame(raf);
phone.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onUp);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.prrbo}>
<div className={styles.panel}>
<div className={styles.track}>
<span className={styles.cap} style={{ "--accent": color } as any} />
<span className={styles.marker} />
</div>
<div className={styles.phone}>
<div className={styles.list}>
<div className={styles.row} />
<div className={styles.row} />
</div>
</div>
</div>
<span className={styles.hint}>아래로 당기세요</span>
</div>
</div>
);
}04 · 고무줄 한계 — 최대 거리 값을 넘어서면 늘어나는 폭이 급격히 줄어드는 당김 — 측정 누적면적 9.799% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
05아이콘 진행 링 — 당긴 거리 값만큼 원형 진행 값이 채워지다 완료되면 체크 표시로 바뀌는 표시
당긴 거리 값이 커질수록 원형 링의 진행 값이 둘레를 따라 채워지고, 진행 값이 가득 찬 상태가 되면 링 안쪽에 체크 표시의 획 값이 그려지며 새로고침 완료를 나타냅니다. 할 일 목록 새로고침, 채팅방 최신 메시지 불러오기에 맞는 형태입니다.
R·checkLen 으로 링 진행과 체크 획을 그리는 ringOf()
var R = 22, C = 2 * Math.PI * R;
bar.style.strokeDasharray = C.toFixed(2);
var checkLen = check.getTotalLength();
check.style.strokeDasharray = checkLen.toFixed(2);
check.style.strokeDashoffset = checkLen.toFixed(2);
function ringOf(progress) { return C * (1 - progress); }
function apply(raw) {
var progress = Math.max(0, Math.min(1, raw / REF));
bar.style.strokeDashoffset = ringOf(progress).toFixed(2);
var pct = Math.round(progress * 100);
hint.textContent = progress >= 1 ? '놓으면 새로고침' : (raw > 4 ? ('당김 진행 ' + pct + '%') : '아래로 당기세요');
}
<main class="stage">
<div class="prim is-demo" id="prim">
<div class="prim__phone" id="primPhone">
<div class="prim__ringwrap">
<svg class="prim__ring" viewBox="0 0 56 56" width="56" height="56">
<circle class="prim__ring-track" cx="28" cy="28" r="22"></circle>
<circle class="prim__ring-bar" id="primBar" cx="28" cy="28" r="22"></circle>
<polyline class="prim__check" id="primCheck" points="17,29 24,36 40,19"></polyline>
</svg>
</div>
<div class="prim__list">
<div class="prim__row"></div>
<div class="prim__row"></div>
</div>
</div>
<span class="prim__hint" id="primHint">아래로 당기세요</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(진행률→strokeDashoffset 환산)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-ink);
.prim {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.prim__phone {
position: relative;
width: 220px;
height: 168px;
overflow: hidden;
border-radius: $r-card;
background: rgba($subject-cream, .07);
touch-action: none;
cursor: grab;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $sp-3;
}
.prim__ringwrap {
position: relative;
width: 56px;
height: 56px;
}
.prim__ring {
width: 56px;
height: 56px;
}
.prim__ring-track {
fill: none;
stroke: rgba($subject-cream, .18);
stroke-width: 4;
}
.prim__ring-bar {
fill: none;
stroke: $subject-blue-light;
stroke-width: 4;
stroke-linecap: round;
transform: rotate(-90deg);
transform-origin: 28px 28px;
}
.prim__check {
fill: none;
stroke: $subject-mint;
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
}
.prim__list {
position: relative;
display: flex;
flex-direction: column;
gap: $sp-2;
width: 100%;
padding: 0 $sp-4;
}
.prim__row {
height: 16px;
border-radius: $r-xs;
background: rgba($subject-cream, .10);
}
.prim__hint {
color: $subject-cream;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.prim__phone { width: 176px; height: 140px; }
.prim__ringwrap, .prim__ring { width: 46px; height: 46px; }
}
@media (prefers-reduced-motion: reduce) {
.prim__ring-bar, .prim__check { transition: none !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);
}
.prim {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.prim__phone {
position: relative;
width: 220px;
height: 168px;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 247, 230, 0.07);
touch-action: none;
cursor: grab;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
}
.prim__ringwrap {
position: relative;
width: 56px;
height: 56px;
}
.prim__ring {
width: 56px;
height: 56px;
}
.prim__ring-track {
fill: none;
stroke: rgba(255, 247, 230, 0.18);
stroke-width: 4;
}
.prim__ring-bar {
fill: none;
stroke: #5b8cf8;
stroke-width: 4;
stroke-linecap: round;
transform: rotate(-90deg);
transform-origin: 28px 28px;
}
.prim__check {
fill: none;
stroke: #4cd4a6;
stroke-width: 4;
stroke-linecap: round;
stroke-linejoin: round;
}
.prim__list {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
padding: 0 16px;
}
.prim__row {
height: 16px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.1);
}
.prim__hint {
color: #fff7e6;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.prim__phone {
width: 176px;
height: 140px;
}
.prim__ringwrap, .prim__ring {
width: 46px;
height: 46px;
}
}
@media (prefers-reduced-motion: reduce) {
.prim__ring-bar, .prim__check {
transition: none !important;
}
}(function () {
var phone = document.getElementById('primPhone');
var bar = document.getElementById('primBar');
var check = document.getElementById('primCheck');
var hint = document.getElementById('primHint');
var root = document.getElementById('prim');
// 계산 겹: 당김 거리(raw)를 REF 로 나눈 progress(0~1)를 원 둘레(C)에 곱해
// strokeDashoffset 을 직접 놓는다 — 프레임마다 새 각도를 계산하는 것이지 CSS 트랜지션이 아니다.
var REF = 130, MAX = 150;
var R = 22, C = 2 * Math.PI * R;
bar.style.strokeDasharray = C.toFixed(2);
var checkLen = check.getTotalLength();
check.style.strokeDasharray = checkLen.toFixed(2);
check.style.strokeDashoffset = checkLen.toFixed(2);
function ringOf(progress) { return C * (1 - progress); }
function apply(raw) {
var progress = Math.max(0, Math.min(1, raw / REF));
bar.style.strokeDashoffset = ringOf(progress).toFixed(2);
var pct = Math.round(progress * 100);
hint.textContent = progress >= 1 ? '놓으면 새로고침' : (raw > 4 ? ('당김 진행 ' + pct + '%') : '아래로 당기세요');
}
var committing = false, commitTimer = null;
function commit() {
committing = true;
hint.textContent = '새로고침 완료';
var start = null, dur = 320;
function step(ts) {
if (start === null) { start = ts; }
var t = Math.min(1, (ts - start) / dur);
check.style.strokeDashoffset = (checkLen * (1 - t)).toFixed(2);
if (t < 1) { requestAnimationFrame(step); }
}
requestAnimationFrame(step);
commitTimer = setTimeout(function () {
check.style.strokeDashoffset = checkLen.toFixed(2);
committing = false;
bar.style.strokeDashoffset = C.toFixed(2);
hint.textContent = '아래로 당기세요';
apply(0);
}, 760);
}
var raw = 0, target = 0, raf = null;
function settle() {
raw += (target - raw) * 0.28;
if (Math.abs(target - raw) < 0.3) { raw = target; }
apply(raw);
if (raw !== target) { raf = requestAnimationFrame(settle); } else { raf = null; }
}
function goTo(v) { target = v; if (raf === null) { raf = requestAnimationFrame(settle); } }
var dragging = false, startY = 0, startRaw = 0;
function onDown(y) { if (committing) { return; } dragging = true; startY = y; startRaw = raw; if (raf) { cancelAnimationFrame(raf); raf = null; } }
function onMove(y) {
if (!dragging) { return; }
raw = Math.max(0, Math.min(MAX, startRaw + (y - startY)));
apply(raw);
}
function onUp() {
if (!dragging) { return; }
dragging = false;
if (raw >= REF) { commit(); } else { goTo(0); }
}
function wireReal() {
phone.addEventListener('pointerdown', function (e) { root.classList.remove('is-demo'); stopAutoplay(); onDown(e.clientY); });
window.addEventListener('pointermove', function (e) { onMove(e.clientY); });
window.addEventListener('pointerup', onUp);
}
phone.style.touchAction = 'none';
wireReal();
apply(0);
var autoRaw = 0, autoTimer = null;
function autoTick() {
if (committing) { return; }
autoRaw += 22;
if (autoRaw >= REF) { raw = REF; apply(raw); autoRaw = 0; commit(); return; }
raw = autoRaw; apply(raw);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } if (commitTimer) { clearTimeout(commitTimer); } }
if (!reduce) { autoTimer = setInterval(autoTick, 190); }
})();import { useEffect, useRef } from "react";
import styles from "./PullRefreshIconProgressMorph.module.scss";
export type PullRefreshIconProgressMorphProps = {
color?: string;
};
export default function PullRefreshIconProgressMorph({
color = "#5b8cf8",
}: PullRefreshIconProgressMorphProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const phone = root.querySelector<HTMLDivElement>(`.${styles.phone}`);
const bar = root.querySelector<SVGCircleElement>(`.${styles["ring-bar"]}`);
const check = root.querySelector<SVGPolylineElement>(`.${styles.check}`);
const hint = root.querySelector<HTMLSpanElement>(`.${styles.hint}`);
if (!phone || !bar || !check || !hint) return;
// 계산 겹: 당김 거리(raw)를 REF 로 나눈 progress(0~1)를 원 둘레(C)에 곱해
// strokeDashoffset 을 직접 놓는다 — 프레임마다 새 각도를 계산하는 것이지 CSS 트랜지션이 아니다.
const REF = 130, MAX = 150;
const R = 22, C = 2 * Math.PI * R;
bar.style.strokeDasharray = C.toFixed(2);
const checkLen = check.getTotalLength();
check.style.strokeDasharray = checkLen.toFixed(2);
check.style.strokeDashoffset = checkLen.toFixed(2);
function ringOf(progress: number) { return C * (1 - progress); }
function apply(raw: number) {
const progress = Math.max(0, Math.min(1, raw / REF));
bar!.style.strokeDashoffset = ringOf(progress).toFixed(2);
const pct = Math.round(progress * 100);
hint!.textContent = progress >= 1 ? "놓으면 새로고침" : (raw > 4 ? ("당김 진행 " + pct + "%") : "아래로 당기세요");
}
let committing = false, commitTimer: number | null = null;
function commit() {
committing = true;
hint!.textContent = "새로고침 완료";
let start: number | null = null;
const dur = 320;
function step(ts: number) {
if (start === null) start = ts;
const t = Math.min(1, (ts - start) / dur);
check!.style.strokeDashoffset = (checkLen * (1 - t)).toFixed(2);
if (t < 1) { requestAnimationFrame(step); }
}
requestAnimationFrame(step);
commitTimer = window.setTimeout(() => {
check!.style.strokeDashoffset = checkLen.toFixed(2);
committing = false;
bar!.style.strokeDashoffset = C.toFixed(2);
hint!.textContent = "아래로 당기세요";
apply(0);
}, 760);
}
let raw = 0, target = 0, raf: number | null = null;
function settle() {
raw += (target - raw) * 0.28;
if (Math.abs(target - raw) < 0.3) raw = target;
apply(raw);
raf = raw !== target ? requestAnimationFrame(settle) : null;
}
function goTo(v: number) { target = v; if (raf === null) { raf = requestAnimationFrame(settle); } }
let dragging = false, startY = 0, startRaw = 0;
function onDown(y: number) {
if (committing) return;
dragging = true; startY = y; startRaw = raw;
if (raf !== null) { cancelAnimationFrame(raf); raf = null; }
}
function onMove(y: number) {
if (!dragging) return;
raw = Math.max(0, Math.min(MAX, startRaw + (y - startY)));
apply(raw);
}
function onUp() {
if (!dragging) return;
dragging = false;
if (raw >= REF) { commit(); } else { goTo(0); }
}
function onPointerDown(e: PointerEvent) { stopAutoplay(); onDown(e.clientY); }
function onPointerMove(e: PointerEvent) { onMove(e.clientY); }
phone.style.touchAction = "none";
phone.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onUp);
apply(0);
let autoRaw = 0, autoTimer: number | null = null;
function autoTick() {
if (committing) return;
autoRaw += 22;
if (autoRaw >= REF) { raw = REF; apply(raw); autoRaw = 0; commit(); return; }
raw = autoRaw; apply(raw);
}
function stopAutoplay() {
if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; }
if (commitTimer !== null) { window.clearTimeout(commitTimer); }
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 190); }
return () => {
stopAutoplay();
if (raf !== null) cancelAnimationFrame(raf);
phone.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onUp);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.prim}>
<div className={styles.phone}>
<div className={styles.ringwrap}>
<svg className={styles.ring} viewBox="0 0 56 56" width="56" height="56">
<circle className={styles["ring-track"]} cx="28" cy="28" r="22" />
<circle className={styles["ring-bar"]} cx="28" cy="28" r="22" style={{ "--accent": color } as any} />
<polyline className={styles.check} points="17,29 24,36 40,19" />
</svg>
</div>
<div className={styles.list}>
<div className={styles.row} />
<div className={styles.row} />
</div>
</div>
<span className={styles.hint}>아래로 당기세요</span>
</div>
</div>
);
}05 · 아이콘 진행 링 — 당긴 거리 값만큼 원형 진행 값이 채워지다 완료되면 체크 표시로 바뀌는 표시 — 측정 누적면적 1.133% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
06래칫 눈금 — 당긴 거리 값이 눈금 값을 지날 때마다 위치가 짧게 튀는 표시
당긴 거리 값이 일정한 간격의 눈금 값을 지날 때마다 표시의 위치 값이 짧게 튀었다가 가라앉고, 지나온 눈금의 개수 값이 늘어날수록 그 튐이 반복됩니다. 재시도 확인이 필요한 새로고침, 강한 피드백이 필요한 리스트에 맞는 형태입니다.
TICK·AMP 로 눈금을 지날 때마다 튐을 얹는 triggerBurst()
function triggerBurst(now) { burstActive = true; burstStart = now; }
function frame(now) {
var clamped = Math.min(raw, MAX_TICK);
var tickIndex = Math.floor(clamped / TICK);
if (tickIndex > lastTick) { triggerBurst(now); }
lastTick = tickIndex;
var basePos = (clamped / MAX_TICK) * TRACK_H;
var burst = 0;
if (burstActive) {
var t = (now - burstStart) / 1000;
burst = AMP * Math.exp(-t / TAU) * Math.sin(t * W);
if (t > 0.6) { burstActive = false; }
}
marker.style.transform = 'translateY(' + (-(basePos) + burst).toFixed(1) + 'px)';
list.style.transform = 'translateY(' + (clamped * 0.35).toFixed(1) + 'px)';
hint.textContent = raw > 4 ? ('칸 ' + Math.min(tickIndex, N) + '/' + N) : '아래로 당기세요';
requestAnimationFrame(frame);
}

<main class="stage">
<div class="prtk is-demo" id="prtk">
<div class="prtk__panel">
<div class="prtk__track" id="prtkTrack">
<span class="prtk__tick" style="bottom:22px"></span>
<span class="prtk__tick" style="bottom:44px"></span>
<span class="prtk__tick" style="bottom:66px"></span>
<span class="prtk__tick" style="bottom:88px"></span>
<span class="prtk__marker" id="prtkMarker"></span>
</div>
<div class="prtk__phone" id="prtkPhone">
<div class="prtk__list" id="prtkList">
<div class="prtk__row"></div>
<div class="prtk__row"></div>
</div>
</div>
</div>
<span class="prtk__hint" id="prtkHint">아래로 당기세요</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(감쇠 진동 burst)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-yellow);
.prtk {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.prtk__panel {
display: flex;
align-items: flex-end;
gap: $sp-4;
padding: $sp-4;
border-radius: $r-card;
background: $stage-paper;
}
.prtk__track {
position: relative;
width: 20px;
height: 110px;
border-radius: $r-xs;
background: $subject-cream;
}
.prtk__tick {
position: absolute;
left: -3px;
right: -3px;
height: 2px;
background: rgba($subject-ink, .3);
}
.prtk__marker {
position: absolute;
left: 2px;
right: 2px;
bottom: 2px;
height: 12px;
border-radius: $r-xs;
background: $subject-blue-deep;
}
.prtk__phone {
position: relative;
width: 156px;
height: 108px;
overflow: hidden;
border-radius: $r-sm;
background: $subject-cream;
touch-action: none;
cursor: grab;
}
.prtk__list {
position: relative;
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-3;
}
.prtk__row {
height: 18px;
border-radius: $r-xs;
background: $stage-paper;
}
.prtk__hint {
color: $subject-ink;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.prtk__panel { padding: $sp-3; gap: $sp-2; }
.prtk__track { height: 88px; }
.prtk__phone { width: 128px; height: 88px; }
}
@media (prefers-reduced-motion: reduce) {
.prtk__marker, .prtk__list { transition: none !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);
}
.prtk {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.prtk__panel {
display: flex;
align-items: flex-end;
gap: 16px;
padding: 16px;
border-radius: 16px;
background: #ffffff;
}
.prtk__track {
position: relative;
width: 20px;
height: 110px;
border-radius: 4px;
background: #fff7e6;
}
.prtk__tick {
position: absolute;
left: -3px;
right: -3px;
height: 2px;
background: rgba(23, 20, 26, 0.3);
}
.prtk__marker {
position: absolute;
left: 2px;
right: 2px;
bottom: 2px;
height: 12px;
border-radius: 4px;
background: #0a4fe8;
}
.prtk__phone {
position: relative;
width: 156px;
height: 108px;
overflow: hidden;
border-radius: 8px;
background: #fff7e6;
touch-action: none;
cursor: grab;
}
.prtk__list {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
}
.prtk__row {
height: 18px;
border-radius: 4px;
background: #ffffff;
}
.prtk__hint {
color: #17141a;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.prtk__panel {
padding: 12px;
gap: 8px;
}
.prtk__track {
height: 88px;
}
.prtk__phone {
width: 128px;
height: 88px;
}
}
@media (prefers-reduced-motion: reduce) {
.prtk__marker, .prtk__list {
transition: none !important;
}
}(function () {
var phone = document.getElementById('prtkPhone');
var marker = document.getElementById('prtkMarker');
var list = document.getElementById('prtkList');
var hint = document.getElementById('prtkHint');
var root = document.getElementById('prtk');
// 계산 겹: 칸 경계(TICK 배수)를 새로 지날 때마다 감쇠 진동 AMP*e^(-t/TAU)*sin(t*W) 을
// 기준 위치에 얹는다 — 릴리즈 때 한 번이 아니라 손가락이 칸을 지날 때마다 걸리는 래칫 튐이다.
var TICK = 22, N = 5, TRACK_H = 110, MAX_TICK = TICK * N, RAW_MAX = 150;
var AMP = 10, TAU = 0.15, W = 26;
var raw = 0, lastTick = 0, burstActive = false, burstStart = 0;
var dragging = false, startY = 0, startRaw = 0, released = true;
function triggerBurst(now) { burstActive = true; burstStart = now; }
function frame(now) {
var clamped = Math.min(raw, MAX_TICK);
var tickIndex = Math.floor(clamped / TICK);
if (tickIndex > lastTick) { triggerBurst(now); }
lastTick = tickIndex;
var basePos = (clamped / MAX_TICK) * TRACK_H;
var burst = 0;
if (burstActive) {
var t = (now - burstStart) / 1000;
burst = AMP * Math.exp(-t / TAU) * Math.sin(t * W);
if (t > 0.6) { burstActive = false; }
}
marker.style.transform = 'translateY(' + (-(basePos) + burst).toFixed(1) + 'px)';
list.style.transform = 'translateY(' + (clamped * 0.35).toFixed(1) + 'px)';
hint.textContent = raw > 4 ? ('칸 ' + Math.min(tickIndex, N) + '/' + N) : '아래로 당기세요';
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
var committing = false, commitTimer = null;
function commit() {
committing = true;
hint.textContent = '새로고침 중';
commitTimer = setTimeout(function () {
committing = false;
raw = 0; lastTick = 0;
hint.textContent = '아래로 당기세요';
}, 620);
}
function onDown(y) { if (committing) { return; } dragging = true; released = false; startY = y; startRaw = raw; }
function onMove(y) {
if (!dragging) { return; }
raw = Math.max(0, Math.min(RAW_MAX, startRaw + (y - startY)));
}
function onUp() {
if (!dragging) { return; }
dragging = false; released = true;
if (raw >= MAX_TICK) { commit(); } else { raw = 0; lastTick = 0; }
}
function wireReal() {
phone.addEventListener('pointerdown', function (e) { root.classList.remove('is-demo'); stopAutoplay(); onDown(e.clientY); });
window.addEventListener('pointermove', function (e) { onMove(e.clientY); });
window.addEventListener('pointerup', onUp);
}
phone.style.touchAction = 'none';
wireReal();
var autoRaw = 0, autoTimer = null;
function autoTick() {
if (committing) { return; }
autoRaw += 16;
if (autoRaw >= MAX_TICK) { raw = MAX_TICK; autoRaw = 0; commit(); return; }
raw = autoRaw;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } if (commitTimer) { clearTimeout(commitTimer); } }
if (!reduce) { autoTimer = setInterval(autoTick, 160); }
})();import { useEffect, useRef } from "react";
import styles from "./PullRefreshRatchetTickSnap.module.scss";
export type PullRefreshRatchetTickSnapProps = {
color?: string;
};
export default function PullRefreshRatchetTickSnap({
color = "#0a4fe8",
}: PullRefreshRatchetTickSnapProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const phone = root.querySelector<HTMLDivElement>(`.${styles.phone}`);
const marker = root.querySelector<HTMLSpanElement>(`.${styles.marker}`);
const list = root.querySelector<HTMLDivElement>(`.${styles.list}`);
const hint = root.querySelector<HTMLSpanElement>(`.${styles.hint}`);
if (!phone || !marker || !list || !hint) return;
// 계산 겹: 칸 경계(TICK 배수)를 새로 지날 때마다 감쇠 진동 AMP*e^(-t/TAU)*sin(t*W) 을
// 기준 위치에 얹는다 — 릴리즈 때 한 번이 아니라 손가락이 칸을 지날 때마다 걸리는 래칫 튐이다.
const TICK = 22, N = 5, TRACK_H = 110, MAX_TICK = TICK * N, RAW_MAX = 150;
const AMP = 10, TAU = 0.15, W = 26;
let raw = 0, lastTick = 0, burstActive = false, burstStart = 0;
let dragging = false, startY = 0, startRaw = 0;
let rafId: number | null = null;
function triggerBurst(now: number) { burstActive = true; burstStart = now; }
function frame(now: number) {
const clamped = Math.min(raw, MAX_TICK);
const tickIndex = Math.floor(clamped / TICK);
if (tickIndex > lastTick) triggerBurst(now);
lastTick = tickIndex;
const basePos = (clamped / MAX_TICK) * TRACK_H;
let burst = 0;
if (burstActive) {
const t = (now - burstStart) / 1000;
burst = AMP * Math.exp(-t / TAU) * Math.sin(t * W);
if (t > 0.6) burstActive = false;
}
marker!.style.transform = "translateY(" + (-(basePos) + burst).toFixed(1) + "px)";
list!.style.transform = "translateY(" + (clamped * 0.35).toFixed(1) + "px)";
hint!.textContent = raw > 4 ? ("칸 " + Math.min(tickIndex, N) + "/" + N) : "아래로 당기세요";
rafId = requestAnimationFrame(frame);
}
rafId = requestAnimationFrame(frame);
let committing = false, commitTimer: number | null = null;
function commit() {
committing = true;
hint!.textContent = "새로고침 중";
commitTimer = window.setTimeout(() => {
committing = false;
raw = 0; lastTick = 0;
hint!.textContent = "아래로 당기세요";
}, 620);
}
function onDown(y: number) {
if (committing) return;
dragging = true; startY = y; startRaw = raw;
}
function onMove(y: number) {
if (!dragging) return;
raw = Math.max(0, Math.min(RAW_MAX, startRaw + (y - startY)));
}
function onUp() {
if (!dragging) return;
dragging = false;
if (raw >= MAX_TICK) { commit(); } else { raw = 0; lastTick = 0; }
}
function onPointerDown(e: PointerEvent) { stopAutoplay(); onDown(e.clientY); }
function onPointerMove(e: PointerEvent) { onMove(e.clientY); }
phone.style.touchAction = "none";
phone.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onUp);
let autoRaw = 0, autoTimer: number | null = null;
function autoTick() {
if (committing) return;
autoRaw += 16;
if (autoRaw >= MAX_TICK) { raw = MAX_TICK; autoRaw = 0; commit(); return; }
raw = autoRaw;
}
function stopAutoplay() {
if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; }
if (commitTimer !== null) { window.clearTimeout(commitTimer); }
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 160); }
return () => {
stopAutoplay();
if (rafId !== null) cancelAnimationFrame(rafId);
phone.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onUp);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.prtk}>
<div className={styles.panel}>
<div className={styles.track}>
<span className={styles.tick} style={{ bottom: "22px" }} />
<span className={styles.tick} style={{ bottom: "44px" }} />
<span className={styles.tick} style={{ bottom: "66px" }} />
<span className={styles.tick} style={{ bottom: "88px" }} />
<span className={styles.marker} style={{ "--accent": color } as any} />
</div>
<div className={styles.phone}>
<div className={styles.list}>
<div className={styles.row} />
<div className={styles.row} />
</div>
</div>
</div>
<span className={styles.hint}>아래로 당기세요</span>
</div>
</div>
);
}06 · 래칫 눈금 — 당긴 거리 값이 눈금 값을 지날 때마다 위치가 짧게 튀는 표시 — 측정 누적면적 8.078% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
07스프링 안착 — 손을 뗀 뒤 위치 값이 목표를 살짝 지나쳤다가 제자리로 돌아오는 표시
손을 뗀 상태가 되면 표시의 위치 값이 고정 위치를 살짝 지나친 값까지 갔다가, 시간이 지나면서 그 값이 잦아들어 고정 위치 값으로 안착한 상태가 됩니다. 상단 고정 새로고침 표시, 카드형 피드 새로고침에 맞는 형태입니다.
K·C 스프링 상수로 오버슈트를 계산하는 accel
var last = null;
function frame(now) {
if (last === null) { last = now; }
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
if (integrating) {
var accel = -K * (pos - target) - C * vel;
vel += accel * dt;
pos += vel * dt;
if (Math.abs(pos - target) < 0.5 && Math.abs(vel) < 5) {
pos = target; vel = 0; integrating = false;
if (pendingCommit) { pendingCommit = false; commit(); }
}
}
render();
requestAnimationFrame(frame);
}

<main class="stage">
<div class="prsp is-demo" id="prsp">
<div class="prsp__panel">
<div class="prsp__track" id="prspTrack">
<span class="prsp__rest"></span>
<span class="prsp__marker" id="prspMarker"></span>
</div>
<div class="prsp__phone" id="prspPhone">
<div class="prsp__list" id="prspList">
<div class="prsp__row"></div>
<div class="prsp__row"></div>
</div>
</div>
</div>
<span class="prsp__hint" id="prspHint">아래로 당기세요</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(스프링 적분 pos/vel)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-ink);
.prsp {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.prsp__panel {
display: flex;
align-items: flex-end;
gap: $sp-4;
padding: $sp-4;
border-radius: $r-card;
background: rgba($subject-cream, .07);
}
.prsp__track {
position: relative;
width: 20px;
height: 110px;
border-radius: $r-xs;
background: rgba($subject-cream, .10);
}
.prsp__rest {
position: absolute;
left: -4px;
right: -4px;
bottom: 50px;
height: 2px;
background: $subject-mint;
}
.prsp__marker {
position: absolute;
left: 2px;
right: 2px;
bottom: 44px;
height: 12px;
border-radius: $r-xs;
background: $subject-blue-light;
}
.prsp__phone {
position: relative;
width: 156px;
height: 108px;
overflow: hidden;
border-radius: $r-sm;
background: rgba($subject-cream, .07);
touch-action: none;
cursor: grab;
}
.prsp__list {
position: relative;
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-3;
}
.prsp__row {
height: 18px;
border-radius: $r-xs;
background: rgba($subject-cream, .12);
}
.prsp__hint {
color: $subject-cream;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.prsp__panel { padding: $sp-3; gap: $sp-2; }
.prsp__track { height: 88px; }
.prsp__rest { bottom: 40px; }
.prsp__marker { bottom: 34px; }
.prsp__phone { width: 128px; height: 88px; }
}
@media (prefers-reduced-motion: reduce) {
.prsp__marker, .prsp__list { transition: none !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);
}
.prsp {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.prsp__panel {
display: flex;
align-items: flex-end;
gap: 16px;
padding: 16px;
border-radius: 16px;
background: rgba(255, 247, 230, 0.07);
}
.prsp__track {
position: relative;
width: 20px;
height: 110px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.1);
}
.prsp__rest {
position: absolute;
left: -4px;
right: -4px;
bottom: 50px;
height: 2px;
background: #4cd4a6;
}
.prsp__marker {
position: absolute;
left: 2px;
right: 2px;
bottom: 44px;
height: 12px;
border-radius: 4px;
background: #5b8cf8;
}
.prsp__phone {
position: relative;
width: 156px;
height: 108px;
overflow: hidden;
border-radius: 8px;
background: rgba(255, 247, 230, 0.07);
touch-action: none;
cursor: grab;
}
.prsp__list {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
}
.prsp__row {
height: 18px;
border-radius: 4px;
background: rgba(255, 247, 230, 0.12);
}
.prsp__hint {
color: #fff7e6;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.prsp__panel {
padding: 12px;
gap: 8px;
}
.prsp__track {
height: 88px;
}
.prsp__rest {
bottom: 40px;
}
.prsp__marker {
bottom: 34px;
}
.prsp__phone {
width: 128px;
height: 88px;
}
}
@media (prefers-reduced-motion: reduce) {
.prsp__marker, .prsp__list {
transition: none !important;
}
}(function () {
var phone = document.getElementById('prspPhone');
var marker = document.getElementById('prspMarker');
var list = document.getElementById('prspList');
var hint = document.getElementById('prspHint');
var root = document.getElementById('prsp');
// 계산 겹: 놓는 순간부터는 값을 보간(lerp)하는 게 아니라 위치·속도를 매 프레임 적분한다
// (스프링 힘 -K*(pos-target) + 감쇠 -C*vel). 임계감쇠보다 C 가 작아 목표를 지나쳐 반동한 뒤 잦아든다.
var SCALE = 0.35, REF = 110, MAX = 150;
var K = 170, C = 9;
var pos = 0, vel = 0, target = 0, integrating = false, dragging = false;
var startY = 0, startPos = 0;
var committing = false, pendingCommit = false, commitTimer = null;
function render() {
var y = -(pos * SCALE);
marker.style.transform = 'translateY(' + y.toFixed(1) + 'px)';
list.style.transform = 'translateY(' + (Math.max(0, pos) * 0.3).toFixed(1) + 'px)';
if (committing) { hint.textContent = '새로고침 중'; }
else if (pos > 4) { hint.textContent = '밀림 ' + Math.round(pos) + 'px'; }
else if (pos < -2) { hint.textContent = '되돌림 ' + Math.round(-pos) + 'px'; }
else { hint.textContent = '아래로 당기세요'; }
}
function commit() {
committing = true;
hint.textContent = '새로고침 중';
commitTimer = setTimeout(function () {
committing = false;
hint.textContent = '아래로 당기세요';
}, 560);
}
var last = null;
function frame(now) {
if (last === null) { last = now; }
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
if (integrating) {
var accel = -K * (pos - target) - C * vel;
vel += accel * dt;
pos += vel * dt;
if (Math.abs(pos - target) < 0.5 && Math.abs(vel) < 5) {
pos = target; vel = 0; integrating = false;
if (pendingCommit) { pendingCommit = false; commit(); }
}
}
render();
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
function onDown(y) {
if (committing) { return; }
dragging = true; integrating = false; vel = 0; startY = y; startPos = pos;
}
function onMove(y) {
if (!dragging) { return; }
pos = Math.max(0, Math.min(MAX, startPos + (y - startY)));
}
function onUp() {
if (!dragging) { return; }
dragging = false;
pendingCommit = pos >= REF;
target = 0; integrating = true;
}
function wireReal() {
phone.addEventListener('pointerdown', function (e) { root.classList.remove('is-demo'); stopAutoplay(); onDown(e.clientY); });
window.addEventListener('pointermove', function (e) { onMove(e.clientY); });
window.addEventListener('pointerup', onUp);
}
phone.style.touchAction = 'none';
wireReal();
var autoRaw = 0, autoTimer = null;
function autoTick() {
if (committing || integrating || dragging) { return; }
autoRaw += 14;
if (autoRaw >= REF) {
pos = autoRaw; pendingCommit = pos >= REF; target = 0; integrating = true; autoRaw = 0; return;
}
pos = autoRaw;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } if (commitTimer) { clearTimeout(commitTimer); } }
if (!reduce) { autoTimer = setInterval(autoTick, 160); }
})();import { useEffect, useRef } from "react";
import styles from "./PullRefreshReleaseSpringSettle.module.scss";
export type PullRefreshReleaseSpringSettleProps = {
color?: string;
};
export default function PullRefreshReleaseSpringSettle({
color = "#5b8cf8",
}: PullRefreshReleaseSpringSettleProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const phone = root.querySelector<HTMLDivElement>(`.${styles.phone}`);
const marker = root.querySelector<HTMLSpanElement>(`.${styles.marker}`);
const list = root.querySelector<HTMLDivElement>(`.${styles.list}`);
const hint = root.querySelector<HTMLSpanElement>(`.${styles.hint}`);
if (!phone || !marker || !list || !hint) return;
// 계산 겹: 놓는 순간부터는 값을 보간(lerp)하는 게 아니라 위치·속도를 매 프레임 적분한다
// (스프링 힘 -K*(pos-target) + 감쇠 -C*vel). 임계감쇠보다 C 가 작아 목표를 지나쳐 반동한 뒤 잦아든다.
const SCALE = 0.35, REF = 110, MAX = 150;
const K = 170, C = 9;
let pos = 0, vel = 0, target = 0, integrating = false, dragging = false;
let startY = 0, startPos = 0;
let committing = false, pendingCommit = false, commitTimer: number | null = null;
function render() {
const y = -(pos * SCALE);
marker!.style.transform = "translateY(" + y.toFixed(1) + "px)";
list!.style.transform = "translateY(" + (Math.max(0, pos) * 0.3).toFixed(1) + "px)";
if (committing) { hint!.textContent = "새로고침 중"; }
else if (pos > 4) { hint!.textContent = "밀림 " + Math.round(pos) + "px"; }
else if (pos < -2) { hint!.textContent = "되돌림 " + Math.round(-pos) + "px"; }
else { hint!.textContent = "아래로 당기세요"; }
}
function commit() {
committing = true;
hint!.textContent = "새로고침 중";
commitTimer = window.setTimeout(() => {
committing = false;
hint!.textContent = "아래로 당기세요";
}, 560);
}
let last: number | null = null;
let rafId: number | null = null;
function frame(now: number) {
if (last === null) last = now;
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
if (integrating) {
const accel = -K * (pos - target) - C * vel;
vel += accel * dt;
pos += vel * dt;
if (Math.abs(pos - target) < 0.5 && Math.abs(vel) < 5) {
pos = target; vel = 0; integrating = false;
if (pendingCommit) { pendingCommit = false; commit(); }
}
}
render();
rafId = requestAnimationFrame(frame);
}
rafId = requestAnimationFrame(frame);
function onDown(y: number) {
if (committing) return;
dragging = true; integrating = false; vel = 0; startY = y; startPos = pos;
}
function onMove(y: number) {
if (!dragging) return;
pos = Math.max(0, Math.min(MAX, startPos + (y - startY)));
}
function onUp() {
if (!dragging) return;
dragging = false;
pendingCommit = pos >= REF;
target = 0; integrating = true;
}
function onPointerDown(e: PointerEvent) { stopAutoplay(); onDown(e.clientY); }
function onPointerMove(e: PointerEvent) { onMove(e.clientY); }
phone.style.touchAction = "none";
phone.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onUp);
let autoRaw = 0, autoTimer: number | null = null;
function autoTick() {
if (committing || integrating || dragging) return;
autoRaw += 14;
if (autoRaw >= REF) {
pos = autoRaw; pendingCommit = pos >= REF; target = 0; integrating = true; autoRaw = 0; return;
}
pos = autoRaw;
}
function stopAutoplay() {
if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; }
if (commitTimer !== null) { window.clearTimeout(commitTimer); }
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 160); }
return () => {
stopAutoplay();
if (rafId !== null) cancelAnimationFrame(rafId);
phone.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onUp);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.prsp}>
<div className={styles.panel}>
<div className={styles.track}>
<span className={styles.rest} />
<span className={styles.marker} style={{ "--accent": color } as any} />
</div>
<div className={styles.phone}>
<div className={styles.list}>
<div className={styles.row} />
<div className={styles.row} />
</div>
</div>
</div>
<span className={styles.hint}>아래로 당기세요</span>
</div>
</div>
);
}07 · 스프링 안착 — 손을 뗀 뒤 위치 값이 목표를 살짝 지나쳤다가 제자리로 돌아오는 표시 — 측정 누적면적 7.859% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
08행 순차 등장 — 새로고침이 끝나면 목록의 각 행이 순서 값을 따라 나타나는 효과
새로고침이 끝난 상태가 되면 목록의 각 행이 자신의 순서 값만큼 지연된 뒤 위치 값과 투명도 값이 원래대로 돌아오고, 순서 값이 큰 행일수록 나중에 나타납니다. 새 글 목록 새로고침, 알림 목록 새로고침에 맞는 형태입니다.
STAGGER_MS·LATENCY_MS 로 행 지연을 계산하는 refreshCycle()
function refreshCycle() {
disc.classList.add('prsg__disc--spin');
hint.textContent = '새로고침 중';
timers.push(setTimeout(function () {
for (var i = 0; i < rows.length; i++) { rows[i].classList.remove('prsg__row--in'); }
// 강제 리플로우 — 클래스를 뗀 상태를 브라우저가 실제로 반영하게 한다.
void list.offsetHeight;
for (var j = 0; j < rows.length; j++) {
(function (idx) {
timers.push(setTimeout(function () { rows[idx].classList.add('prsg__row--in'); }, idx * STAGGER_MS));
})(j);
}
var totalDelay = (rows.length - 1) * STAGGER_MS + 260;
timers.push(setTimeout(function () {
disc.classList.remove('prsg__disc--spin', 'prsg__disc--ready');
hint.textContent = '아래로 당기세요';
raw = 0; apply(0);
}, totalDelay));
}, LATENCY_MS));
}

<main class="stage">
<div class="prsg is-demo" id="prsg">
<div class="prsg__phone" id="prsgPhone">
<div class="prsg__zone">
<span class="prsg__disc" id="prsgDisc"></span>
</div>
<div class="prsg__list" id="prsgList">
<div class="prsg__row"></div>
<div class="prsg__row"></div>
<div class="prsg__row"></div>
<div class="prsg__row"></div>
<div class="prsg__row"></div>
</div>
</div>
<span class="prsg__hint" id="prsgHint">아래로 당기세요</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(행마다 순번 * STAGGER_MS 지연)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-orange);
.prsg {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.prsg__phone {
position: relative;
width: 220px;
height: 168px;
overflow: hidden;
border-radius: $r-card;
background: $stage-paper;
touch-action: none;
cursor: grab;
}
.prsg__zone {
position: relative;
height: 34px;
display: grid;
place-items: center;
}
.prsg__disc {
width: 20px;
height: 20px;
border-radius: $r-pill;
background: rgba($subject-ink, .22);
transition: background $dur-quick $ease-out;
&--ready { background: $subject-blue-deep; }
&--spin { animation: prsgSpin .6s linear infinite; }
}
@keyframes prsgSpin {
from { transform: rotate(0deg) scale(1); }
to { transform: rotate(360deg) scale(1); }
}
.prsg__list {
position: relative;
display: flex;
flex-direction: column;
gap: $sp-2;
padding: 0 $sp-4 $sp-3;
}
.prsg__row {
height: 15px;
border-radius: $r-xs;
background: $subject-cream;
opacity: 0;
transform: translateY(6px);
transition: opacity $dur-base $ease-out, transform $dur-base $ease-out;
&--in { opacity: 1; transform: translateY(0); }
}
.prsg__hint {
color: $subject-ink;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.prsg__phone { width: 176px; height: 132px; }
.prsg__zone { height: 28px; }
.prsg__list { gap: $sp-1; padding: 0 $sp-3 $sp-2; }
.prsg__row { height: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.prsg__disc, .prsg__row { transition: none !important; animation: none !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);
}
.prsg {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.prsg__phone {
position: relative;
width: 220px;
height: 168px;
overflow: hidden;
border-radius: 16px;
background: #ffffff;
touch-action: none;
cursor: grab;
}
.prsg__zone {
position: relative;
height: 34px;
display: grid;
place-items: center;
}
.prsg__disc {
width: 20px;
height: 20px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.22);
transition: background 220ms ease-out;
}
.prsg__disc--ready {
background: #0a4fe8;
}
.prsg__disc--spin {
animation: prsgSpin 0.6s linear infinite;
}
@keyframes prsgSpin {
from {
transform: rotate(0deg) scale(1);
}
to {
transform: rotate(360deg) scale(1);
}
}
.prsg__list {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 0 16px 12px;
}
.prsg__row {
height: 15px;
border-radius: 4px;
background: #fff7e6;
opacity: 0;
transform: translateY(6px);
transition: opacity 300ms ease-out, transform 300ms ease-out;
}
.prsg__row--in {
opacity: 1;
transform: translateY(0);
}
.prsg__hint {
color: #17141a;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.prsg__phone {
width: 176px;
height: 132px;
}
.prsg__zone {
height: 28px;
}
.prsg__list {
gap: 4px;
padding: 0 12px 8px;
}
.prsg__row {
height: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.prsg__disc, .prsg__row {
transition: none !important;
animation: none !important;
}
}(function () {
var phone = document.getElementById('prsgPhone');
var disc = document.getElementById('prsgDisc');
var list = document.getElementById('prsgList');
var hint = document.getElementById('prsgHint');
var root = document.getElementById('prsg');
var rows = list.querySelectorAll('.prsg__row');
// 계산 겹: 새로고침이 끝난 뒤 각 행의 등장 지연은 STAGGER_MS * 행 순번 — 한 번의 CSS
// animation-delay 로 걸지 않고 행마다 setTimeout 을 따로 걸어 순번 계산을 그대로 노출한다.
var REF = 90, MAX = 130, STAGGER_MS = 90, LATENCY_MS = 420;
var raw = 0, target = 0, raf = null;
function apply(raw) {
var pull = Math.min(raw, MAX) * 0.28;
disc.style.transform = 'translateY(' + pull.toFixed(1) + 'px) scale(' + (0.7 + 0.3 * Math.min(1, raw / REF)).toFixed(2) + ')';
disc.classList.toggle('prsg__disc--ready', raw >= REF);
hint.textContent = raw >= REF ? '놓으면 새로고침' : (raw > 4 ? '더 당기세요 (' + Math.round(raw) + '/' + REF + 'px)' : '아래로 당기세요');
}
function settle() {
raw += (target - raw) * 0.3;
if (Math.abs(target - raw) < 0.3) { raw = target; }
apply(raw);
if (raw !== target) { raf = requestAnimationFrame(settle); } else { raf = null; }
}
function goTo(v) { target = v; if (raf === null) { raf = requestAnimationFrame(settle); } }
var timers = [];
function clearTimers() { timers.forEach(clearTimeout); timers = []; }
function refreshCycle() {
disc.classList.add('prsg__disc--spin');
hint.textContent = '새로고침 중';
timers.push(setTimeout(function () {
for (var i = 0; i < rows.length; i++) { rows[i].classList.remove('prsg__row--in'); }
// 강제 리플로우 — 클래스를 뗀 상태를 브라우저가 실제로 반영하게 한다.
void list.offsetHeight;
for (var j = 0; j < rows.length; j++) {
(function (idx) {
timers.push(setTimeout(function () { rows[idx].classList.add('prsg__row--in'); }, idx * STAGGER_MS));
})(j);
}
var totalDelay = (rows.length - 1) * STAGGER_MS + 260;
timers.push(setTimeout(function () {
disc.classList.remove('prsg__disc--spin', 'prsg__disc--ready');
hint.textContent = '아래로 당기세요';
raw = 0; apply(0);
}, totalDelay));
}, LATENCY_MS));
}
var dragging = false, startY = 0, startRaw = 0, committing = false;
function onDown(y) { if (committing) { return; } dragging = true; startY = y; startRaw = raw; if (raf) { cancelAnimationFrame(raf); raf = null; } }
function onMove(y) {
if (!dragging) { return; }
raw = Math.max(0, Math.min(MAX, startRaw + (y - startY)));
apply(raw);
}
function onUp() {
if (!dragging) { return; }
dragging = false;
if (raw >= REF) {
committing = true;
refreshCycle();
timers.push(setTimeout(function () { committing = false; }, LATENCY_MS + (rows.length - 1) * STAGGER_MS + 260));
} else {
goTo(0);
}
}
function wireReal() {
phone.addEventListener('pointerdown', function (e) { root.classList.remove('is-demo'); stopAutoplay(); onDown(e.clientY); });
window.addEventListener('pointermove', function (e) { onMove(e.clientY); });
window.addEventListener('pointerup', onUp);
}
phone.style.touchAction = 'none';
wireReal();
for (var k = 0; k < rows.length; k++) { rows[k].classList.add('prsg__row--in'); }
apply(0);
var autoRaw = 0, autoTimer = null;
function autoTick() {
if (committing) { return; }
autoRaw += 18;
if (autoRaw >= REF) {
raw = REF; apply(raw); autoRaw = 0;
committing = true;
refreshCycle();
timers.push(setTimeout(function () { committing = false; }, LATENCY_MS + (rows.length - 1) * STAGGER_MS + 260));
return;
}
raw = autoRaw; apply(raw);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } clearTimers(); }
if (!reduce) { autoTimer = setInterval(autoTick, 190); }
})();import { useEffect, useRef } from "react";
import styles from "./PullRefreshRowStaggerReveal.module.scss";
export type PullRefreshRowStaggerRevealProps = {
color?: string;
};
export default function PullRefreshRowStaggerReveal({
color = "#0a4fe8",
}: PullRefreshRowStaggerRevealProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const phone = root.querySelector<HTMLDivElement>(`.${styles.phone}`);
const disc = root.querySelector<HTMLSpanElement>(`.${styles.disc}`);
const list = root.querySelector<HTMLDivElement>(`.${styles.list}`);
const hint = root.querySelector<HTMLSpanElement>(`.${styles.hint}`);
if (!phone || !disc || !list || !hint) return;
const rows = Array.from(root.querySelectorAll<HTMLDivElement>(`.${styles.row}`));
// 계산 겹: 새로고침이 끝난 뒤 각 행의 등장 지연은 STAGGER_MS * 행 순번 — 한 번의 CSS
// animation-delay 로 걸지 않고 행마다 setTimeout 을 따로 걸어 순번 계산을 그대로 노출한다.
const REF = 90, MAX = 130, STAGGER_MS = 90, LATENCY_MS = 420;
const readyClass = styles["disc--ready"];
const spinClass = styles["disc--spin"];
const inClass = styles["row--in"];
let raw = 0, target = 0, raf: number | null = null;
function apply(rawV: number) {
const pull = Math.min(rawV, MAX) * 0.28;
disc!.style.transform = "translateY(" + pull.toFixed(1) + "px) scale(" + (0.7 + 0.3 * Math.min(1, rawV / REF)).toFixed(2) + ")";
disc!.classList.toggle(readyClass, rawV >= REF);
hint!.textContent = rawV >= REF ? "놓으면 새로고침" : (rawV > 4 ? "더 당기세요 (" + Math.round(rawV) + "/" + REF + "px)" : "아래로 당기세요");
}
function settle() {
raw += (target - raw) * 0.3;
if (Math.abs(target - raw) < 0.3) raw = target;
apply(raw);
raf = raw !== target ? requestAnimationFrame(settle) : null;
}
function goTo(v: number) { target = v; if (raf === null) { raf = requestAnimationFrame(settle); } }
let timers: number[] = [];
function clearTimers() { timers.forEach((t) => window.clearTimeout(t)); timers = []; }
function refreshCycle() {
disc!.classList.add(spinClass);
hint!.textContent = "새로고침 중";
timers.push(window.setTimeout(() => {
rows.forEach((row) => row.classList.remove(inClass));
void list!.offsetHeight;
rows.forEach((row, idx) => {
timers.push(window.setTimeout(() => row.classList.add(inClass), idx * STAGGER_MS));
});
const totalDelay = (rows.length - 1) * STAGGER_MS + 260;
timers.push(window.setTimeout(() => {
disc!.classList.remove(spinClass, readyClass);
hint!.textContent = "아래로 당기세요";
raw = 0; apply(0);
}, totalDelay));
}, LATENCY_MS));
}
let dragging = false, startY = 0, startRaw = 0, committing = false;
function onDown(y: number) {
if (committing) return;
dragging = true; startY = y; startRaw = raw;
if (raf !== null) { cancelAnimationFrame(raf); raf = null; }
}
function onMove(y: number) {
if (!dragging) return;
raw = Math.max(0, Math.min(MAX, startRaw + (y - startY)));
apply(raw);
}
function onUp() {
if (!dragging) return;
dragging = false;
if (raw >= REF) {
committing = true;
refreshCycle();
timers.push(window.setTimeout(() => { committing = false; }, LATENCY_MS + (rows.length - 1) * STAGGER_MS + 260));
} else {
goTo(0);
}
}
function onPointerDown(e: PointerEvent) { stopAutoplay(); onDown(e.clientY); }
function onPointerMove(e: PointerEvent) { onMove(e.clientY); }
phone.style.touchAction = "none";
phone.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onUp);
rows.forEach((row) => row.classList.add(inClass));
apply(0);
let autoRaw = 0, autoTimer: number | null = null;
function autoTick() {
if (committing) return;
autoRaw += 18;
if (autoRaw >= REF) {
raw = REF; apply(raw); autoRaw = 0;
committing = true;
refreshCycle();
timers.push(window.setTimeout(() => { committing = false; }, LATENCY_MS + (rows.length - 1) * STAGGER_MS + 260));
return;
}
raw = autoRaw; apply(raw);
}
function stopAutoplay() {
if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; }
clearTimers();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 190); }
return () => {
stopAutoplay();
if (raf !== null) cancelAnimationFrame(raf);
phone.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onUp);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.prsg}>
<div className={styles.phone}>
<div className={styles.zone}>
<span className={styles.disc} style={{ "--accent": color } as any} />
</div>
<div className={styles.list}>
<div className={styles.row} />
<div className={styles.row} />
<div className={styles.row} />
<div className={styles.row} />
<div className={styles.row} />
</div>
</div>
<span className={styles.hint}>아래로 당기세요</span>
</div>
</div>
);
}08 · 행 순차 등장 — 새로고침이 끝나면 목록의 각 행이 순서 값을 따라 나타나는 효과 — 측정 누적면적 8.638% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
09실패 흔들림 — 새로고침이 실패한 상태가 되면 표시가 좌우로 짧게 흔들리는 효과
새로고침이 실패한 상태가 되면 표시의 좌우 위치 값이 짧은 시간 동안 여러 번 오갔다가 0으로 돌아오고, 다시 당긴 상태가 되면 처음 절차가 다시 시작됩니다. 네트워크 불안정 상태 안내, 재시도가 필요한 새로고침에 맞는 형태입니다.
attemptCount 홀짝과 FREQ 흔들림을 계산하는 attempt()
function attempt() {
attemptCount += 1;
var fail = attemptCount % 2 === 1;
committing = true;
if (fail) {
hint.textContent = '오프라인 - 다시 시도';
disc.classList.add('prof__disc--fail');
shaking = true; shakeStart = performance.now();
commitTimer = setTimeout(function () {
disc.classList.remove('prof__disc--fail', 'prof__disc--ready');
committing = false;
raw = 0; apply(0);
hint.textContent = '아래로 당기세요';
}, 640);
} else {
hint.textContent = '새로고침 완료';
disc.classList.add('prof__disc--ok');
commitTimer = setTimeout(function () {
disc.classList.remove('prof__disc--ok', 'prof__disc--ready');
committing = false;
raw = 0; apply(0);
hint.textContent = '아래로 당기세요';
}, 640);
}
}
09 는 Math.random을 쓰지 않고 attemptCount % 2로 실패·성공을 결정적으로 번갈아 정하는 구조라, 무작위로 실패하는 게 아니라 1회차는 항상 실패하고 2회차는 항상 성공합니다.

<main class="stage">
<div class="prof is-demo" id="prof">
<div class="prof__phone" id="profPhone">
<div class="prof__zone">
<span class="prof__disc" id="profDisc"></span>
</div>
<div class="prof__list">
<div class="prof__row"></div>
<div class="prof__row"></div>
</div>
</div>
<span class="prof__hint" id="profHint">아래로 당기세요</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(시도 홀짝 판정·감쇠 진동 흔들림)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-paper);
.prof {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.prof__phone {
position: relative;
width: 220px;
height: 168px;
overflow: hidden;
border-radius: $r-card;
background: $subject-cream;
touch-action: none;
cursor: grab;
}
.prof__zone {
position: relative;
height: 56px;
display: grid;
place-items: center;
}
.prof__disc {
width: 28px;
height: 28px;
border-radius: $r-pill;
background: rgba($subject-ink, .22);
transition: background $dur-quick $ease-out;
&--ready { background: $subject-blue-deep; }
&--fail { background: $error; }
&--ok { background: $subject-mint; }
}
.prof__list {
position: relative;
display: flex;
flex-direction: column;
gap: $sp-2;
padding: $sp-3 $sp-4;
}
.prof__row {
height: 20px;
border-radius: $r-sm;
background: $stage-paper;
}
.prof__hint {
color: $subject-ink;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.prof__phone { width: 176px; height: 132px; }
.prof__zone { height: 44px; }
.prof__disc { width: 22px; height: 22px; }
.prof__list { padding: $sp-2 $sp-3; gap: $sp-1; }
.prof__row { height: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.prof__disc, .prof__phone { transition: none !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);
}
.prof {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.prof__phone {
position: relative;
width: 220px;
height: 168px;
overflow: hidden;
border-radius: 16px;
background: #fff7e6;
touch-action: none;
cursor: grab;
}
.prof__zone {
position: relative;
height: 56px;
display: grid;
place-items: center;
}
.prof__disc {
width: 28px;
height: 28px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.22);
transition: background 220ms ease-out;
}
.prof__disc--ready {
background: #0a4fe8;
}
.prof__disc--fail {
background: #c2361a;
}
.prof__disc--ok {
background: #4cd4a6;
}
.prof__list {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 16px;
}
.prof__row {
height: 20px;
border-radius: 8px;
background: #ffffff;
}
.prof__hint {
color: #17141a;
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.prof__phone {
width: 176px;
height: 132px;
}
.prof__zone {
height: 44px;
}
.prof__disc {
width: 22px;
height: 22px;
}
.prof__list {
padding: 8px 12px;
gap: 4px;
}
.prof__row {
height: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.prof__disc, .prof__phone {
transition: none !important;
}
}(function () {
var phone = document.getElementById('profPhone');
var disc = document.getElementById('profDisc');
var hint = document.getElementById('profHint');
var root = document.getElementById('prof');
// 계산 겹: 성공/실패는 Math.random 이 아니라 시도 횟수(attemptCount)의 홀짝으로 정해지는
// 결정적 번갈이(1회차 실패·2회차 성공…)다. 실패 시 흔들림은 감쇠 진동 AMP*e^(-t/TAU)*sin(t*FREQ) 를
// phone 의 translateX 에 얹는 시간 기반 계산이지, 매번 새 애니메이션 클래스를 재생시키는 게 아니다.
var REF = 90, MAX = 130;
var AMP = 10, TAU = 0.12, FREQ = 46;
var raw = 0, target = 0, raf1 = null;
function apply(rawV) {
var pull = Math.min(rawV, MAX) * 0.28;
disc.style.transform = 'translateY(' + pull.toFixed(1) + 'px) scale(' + (0.7 + 0.3 * Math.min(1, rawV / REF)).toFixed(2) + ')';
disc.classList.toggle('prof__disc--ready', rawV >= REF);
}
function settle() {
raw += (target - raw) * 0.3;
if (Math.abs(target - raw) < 0.3) { raw = target; }
apply(raw);
if (raw !== target) { raf1 = requestAnimationFrame(settle); } else { raf1 = null; }
}
function goTo(v) { target = v; if (raf1 === null) { raf1 = requestAnimationFrame(settle); } }
var shaking = false, shakeStart = 0;
function frame(now) {
if (shaking) {
var t = (now - shakeStart) / 1000;
var off = AMP * Math.exp(-t / TAU) * Math.sin(t * FREQ);
phone.style.transform = 'translateX(' + off.toFixed(1) + 'px)';
if (t > 0.5) { shaking = false; phone.style.transform = 'translateX(0px)'; }
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
var attemptCount = 0, committing = false, commitTimer = null;
function attempt() {
attemptCount += 1;
var fail = attemptCount % 2 === 1;
committing = true;
if (fail) {
hint.textContent = '오프라인 - 다시 시도';
disc.classList.add('prof__disc--fail');
shaking = true; shakeStart = performance.now();
commitTimer = setTimeout(function () {
disc.classList.remove('prof__disc--fail', 'prof__disc--ready');
committing = false;
raw = 0; apply(0);
hint.textContent = '아래로 당기세요';
}, 640);
} else {
hint.textContent = '새로고침 완료';
disc.classList.add('prof__disc--ok');
commitTimer = setTimeout(function () {
disc.classList.remove('prof__disc--ok', 'prof__disc--ready');
committing = false;
raw = 0; apply(0);
hint.textContent = '아래로 당기세요';
}, 640);
}
}
var dragging = false, startY = 0, startRaw = 0;
function onDown(y) { if (committing) { return; } dragging = true; startY = y; startRaw = raw; if (raf1) { cancelAnimationFrame(raf1); raf1 = null; } }
function onMove(y) {
if (!dragging) { return; }
raw = Math.max(0, Math.min(MAX, startRaw + (y - startY)));
apply(raw);
}
function onUp() {
if (!dragging) { return; }
dragging = false;
if (raw >= REF) { attempt(); } else { goTo(0); }
}
function wireReal() {
phone.addEventListener('pointerdown', function (e) { root.classList.remove('is-demo'); stopAutoplay(); onDown(e.clientY); });
window.addEventListener('pointermove', function (e) { onMove(e.clientY); });
window.addEventListener('pointerup', onUp);
}
phone.style.touchAction = 'none';
wireReal();
apply(0);
var autoRaw = 0, autoTimer = null;
function autoTick() {
if (committing) { return; }
autoRaw += 18;
if (autoRaw >= REF) { raw = REF; apply(raw); autoRaw = 0; attempt(); return; }
raw = autoRaw; apply(raw);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } if (commitTimer) { clearTimeout(commitTimer); } }
if (!reduce) { autoTimer = setInterval(autoTick, 190); }
})();import { useEffect, useRef } from "react";
import styles from "./PullRefreshOfflineRetryShake.module.scss";
export type PullRefreshOfflineRetryShakeProps = {
color?: string;
};
export default function PullRefreshOfflineRetryShake({
color = "#0a4fe8",
}: PullRefreshOfflineRetryShakeProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const phone = root.querySelector<HTMLDivElement>(`.${styles.phone}`);
const disc = root.querySelector<HTMLSpanElement>(`.${styles.disc}`);
const hint = root.querySelector<HTMLSpanElement>(`.${styles.hint}`);
if (!phone || !disc || !hint) return;
// 계산 겹: 성공/실패는 Math.random 이 아니라 시도 횟수(attemptCount)의 홀짝으로 정해지는
// 결정적 번갈이(1회차 실패·2회차 성공…)다. 실패 시 흔들림은 감쇠 진동 AMP*e^(-t/TAU)*sin(t*FREQ) 를
// phone 의 translateX 에 얹는 시간 기반 계산이지, 매번 새 애니메이션 클래스를 재생시키는 게 아니다.
const REF = 90, MAX = 130;
const AMP = 10, TAU = 0.12, FREQ = 46;
const readyClass = styles["disc--ready"];
const failClass = styles["disc--fail"];
const okClass = styles["disc--ok"];
let raw = 0, target = 0, raf1: number | null = null;
function apply(rawV: number) {
const pull = Math.min(rawV, MAX) * 0.28;
disc!.style.transform = "translateY(" + pull.toFixed(1) + "px) scale(" + (0.7 + 0.3 * Math.min(1, rawV / REF)).toFixed(2) + ")";
disc!.classList.toggle(readyClass, rawV >= REF);
}
function settle() {
raw += (target - raw) * 0.3;
if (Math.abs(target - raw) < 0.3) raw = target;
apply(raw);
raf1 = raw !== target ? requestAnimationFrame(settle) : null;
}
function goTo(v: number) { target = v; if (raf1 === null) { raf1 = requestAnimationFrame(settle); } }
let shaking = false, shakeStart = 0, rafFrame: number;
function frame(now: number) {
if (shaking) {
const t = (now - shakeStart) / 1000;
const off = AMP * Math.exp(-t / TAU) * Math.sin(t * FREQ);
phone!.style.transform = "translateX(" + off.toFixed(1) + "px)";
if (t > 0.5) { shaking = false; phone!.style.transform = "translateX(0px)"; }
}
rafFrame = requestAnimationFrame(frame);
}
rafFrame = requestAnimationFrame(frame);
let attemptCount = 0, committing = false;
let timers: number[] = [];
function clearTimers() { timers.forEach((t) => window.clearTimeout(t)); timers = []; }
function attempt() {
attemptCount += 1;
const fail = attemptCount % 2 === 1;
committing = true;
if (fail) {
hint!.textContent = "오프라인 - 다시 시도";
disc!.classList.add(failClass);
shaking = true; shakeStart = performance.now();
timers.push(window.setTimeout(() => {
disc!.classList.remove(failClass, readyClass);
committing = false;
raw = 0; apply(0);
hint!.textContent = "아래로 당기세요";
}, 640));
} else {
hint!.textContent = "새로고침 완료";
disc!.classList.add(okClass);
timers.push(window.setTimeout(() => {
disc!.classList.remove(okClass, readyClass);
committing = false;
raw = 0; apply(0);
hint!.textContent = "아래로 당기세요";
}, 640));
}
}
let dragging = false, startY = 0, startRaw = 0;
function onDown(y: number) {
if (committing) return;
dragging = true; startY = y; startRaw = raw;
if (raf1 !== null) { cancelAnimationFrame(raf1); raf1 = null; }
}
function onMove(y: number) {
if (!dragging) return;
raw = Math.max(0, Math.min(MAX, startRaw + (y - startY)));
apply(raw);
}
function onUp() {
if (!dragging) return;
dragging = false;
if (raw >= REF) { attempt(); } else { goTo(0); }
}
function onPointerDown(e: PointerEvent) { stopAutoplay(); onDown(e.clientY); }
function onPointerMove(e: PointerEvent) { onMove(e.clientY); }
phone.style.touchAction = "none";
phone.addEventListener("pointerdown", onPointerDown);
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onUp);
apply(0);
let autoRaw = 0, autoTimer: number | null = null;
function autoTick() {
if (committing) return;
autoRaw += 18;
if (autoRaw >= REF) { raw = REF; apply(raw); autoRaw = 0; attempt(); return; }
raw = autoRaw; apply(raw);
}
function stopAutoplay() {
if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; }
clearTimers();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) { autoTimer = window.setInterval(autoTick, 190); }
return () => {
stopAutoplay();
if (raf1 !== null) cancelAnimationFrame(raf1);
cancelAnimationFrame(rafFrame);
phone.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("pointermove", onPointerMove);
window.removeEventListener("pointerup", onUp);
};
}, []);
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.prof}>
<div className={styles.phone}>
<div className={styles.zone}>
<span className={styles.disc} style={{ "--accent": color } as any} />
</div>
<div className={styles.list}>
<div className={styles.row} />
<div className={styles.row} />
</div>
</div>
<span className={styles.hint}>아래로 당기세요</span>
</div>
</div>
);
}09 · 실패 흔들림 — 새로고침이 실패한 상태가 되면 표시가 좌우로 짧게 흔들리는 효과 — 측정 누적면적 2.943% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
값이 패턴을 바꾸는 곳 — 거리·비율·기준 값의 경계
실제 당겨서 새로고침 예시에서 거리·비율·기준 값을 어디서 조정하면 되는지 보겠습니다 — 아홉 개 중 세 값만 골라도 손맛이 크게 갈립니다.
저항 거리 기준 — 01 D_MAX·K
01 의 D_MAX(56)는 밀림 거리 값의 상한이라, 30 대로 낮추면 아무리 세게 당겨도 조금만 밀리는 뻑뻑한 느낌이 되고, 90 대로 올리면 더 많이 밀려 헐거운 느낌이 됩니다. K(80)는 저항이 커지는 속도를 정해, 20 대로 낮추면 초반부터 저항이 강해 조금만 당겨도 밀림이 빨리 멈추고, 200 대로 올리면 한참을 당겨도 거의 선형에 가깝게 술술 밀립니다.
회전 비율 기준 — 02 REF·SWEEP
02 의 REF(120)는 비율(ratio)이 1 이 되는 기준 거리라, 60 으로 낮추면 짧게만 당겨도 로딩 상태로 넘어가 새로고침이 성급하게 트리거되고, 200 으로 올리면 훨씬 길게 당겨야 로딩 상태가 됩니다. SWEEP(270도)는 그 비율 값이 다 찼을 때 knob 이 도는 각도 폭이라, 90도로 줄이면 회전이 눈에 덜 띄고, 360도로 늘리면 완전히 한 바퀴를 도는 느낌이 됩니다.
확정 문턱 기준 — 03 ON·OFF
03 의 ON(100)은 확정 후보(candidate)가 되는 거리 기준이라, 60 으로 낮추면 살짝만 당겨도 후보 상태가 되어 손을 떼면 바로 새로고침이 시작되고, 150 으로 올리면 훨씬 더 당겨야만 후보가 됩니다. OFF(70)는 후보가 된 뒤 다시 취소되는 기준이라, ON 과 가깝게 두면(예: 95) 경계 근처에서 손이 미세하게 떨릴 때 candidate 가 자주 뒤집혀 깜빡이는 느낌이 나고, 지금처럼 30px 간격을 벌려 두면 그 깜빡임이 사라집니다.
어디서 깨지는가 — 빠른 당김·짧은 당김·중간에 놓기
빠른 당김 — 06
06 은 frame()이 매 프레임 raw의 현재 값에서 tickIndex를 한 번만 계산하고 if (tickIndex > lastTick)로 새로 지난 눈금이 있는지만 확인하므로, 손가락을 아주 빠르게 움직여 pointermove 한 번에 raw가 눈금 두세 칸을 한꺼번에 건너뛰어도 triggerBurst()는 딱 한 번만 불립니다 — 눈금을 하나씩 다 지난 것처럼 여러 번 튀는 게 아니라, 빠르게 당기면 오히려 튐 횟수가 실제로 지난 칸 수보다 적게 느껴집니다.
짧은 당김 — 03
03 은 setCandidate()가 raw >= ON(100)일 때만 candidate 를 true 로 바꾸므로, OFF(70)보다는 멀리 당겼지만 ON 에는 못 미친 짧은 당김(예: 85px)에서 손을 떼면 candidate 가 한 번도 true 가 된 적이 없어 commit() 없이 그대로 0 으로 되돌아갑니다 — OFF 문턱을 넘었으니 절반은 확정된 것처럼 보여도, 히스테리시스는 애초에 candidate 가 한 번 true 가 된 적이 있을 때만 의미가 있는 값입니다.
중간에 놓기 — 02
02 는 onUp()이 ratio >= 1이면 startSpin()으로 넘어가지만, 그 전에(비율 값이 1 이 되기 전에) 손을 놓으면 state = 'idle'; apply(0);가 그 자리에서 곧바로 실행됩니다 — 01·03·04·08·09 처럼 lerp 로 몇 프레임에 걸쳐 서서히 0 으로 돌아가는 게 아니라, bffdpzhj 안 데모에서 이 즉시 복귀가 딱딱하게 느껴지면 apply(0) 대신 같은 파일의 goTo 류 감쇠 함수를 하나 얹어야 다른 여덟 개와 같은 결의 복귀가 됩니다.
변형 세 가지와 접근성
직접 당겨서 새로고침 끄기 버튼까지 포함한 UI 를 만들 때는 값 하나만 슬쩍 바꿔도 손맛의 인상이 크게 달라집니다.
| 이름 | 바뀐 값 | 느낌 |
|---|---|---|
| 확정 기준 거리 | 03 ON 100px → 150px | 기준을 100px 로 두면 살짝만 당겨도 확정 후보 상태가 되어 손을 떼면 바로 새로고침이 시작되고, 150px 로 늘리면 훨씬 더 당겨야만 확정 후보가 돼 오작동이 줄어드는 대신 손짓이 커져야 한다 |
| 스프링 강성 | 07 K 170 → 80 | 강성을 170 으로 두면 목표로 빠르고 팽팽하게 당겨져 짧게 튕기고, 80 으로 낮추면 더 느슨하게 당겨져 목표에 닿기까지 시간이 걸리고 오버슈트 폭도 커진다 |
| 행 지연 간격 | 08 STAGGER_MS 90ms → 180ms | 간격을 90ms 로 두면 행들이 거의 동시에 우르르 나타나는 느낌이고, 180ms 로 늘리면 한 행씩 뚜렷하게 시간차를 두고 나타나 순서가 눈에 잘 들어온다 |
아홉 개 모두 prefers-reduced-motion: reduce가 참인 상태가 되면 자동 재생 setInterval 루프만 걸지 않을 뿐, pointerdown·pointermove·pointerup 리스너는 그대로 등록돼 있어 실제 손으로 당기는 조작과 그 결과 값 계산은 전혀 막히지 않습니다. 다만 아홉 개 전부 tabindex나 keydown 대체 입력이 없어 마우스·터치 드래그가 유일한 입력 경로이고, 상태가 바뀔 때 스크린 리더에 알리는 aria-live 장치도 없습니다 — 실제 서비스에 쓸 때는 Pointer Events 문서를 참고해 새로고침 버튼 같은 키보드 대체 경로를 별도로 두어야 하고, 브라우저 자체의 당겨서 새로고침 끄기 설정과 겹치지 않는지도 함께 확인해야 합니다.
FAQ
아홉 개 중 실제 서비스에 쓰기 가장 무난한 패턴은 뭔가요?
거리 값이 기준을 넘었는지만 확정하는 03(기준 확정)이 히스테리시스 덕분에 경계에서 값이 깜빡이지 않아 가장 예측 가능하고, 04(고무줄 한계)는 아무리 세게 당겨도 값이 무한히 늘지 않아 레이아웃이 안전합니다. 06(래칫 눈금)처럼 눈금마다 튀는 표시는 강한 피드백이 필요한 화면에는 맞지만 일반적인 새로고침에는 과할 수 있습니다.
풀 투 리프레시를 만들 때 서버 네트워크 상태를 어떻게 반영하나요?
이 편의 아홉 개는 실제 fetch 나 navigator.onLine 을 읽지 않고 값을 시간·거리 기반으로 시뮬레이션할 뿐이라, 09(실패 흔들림)의 실패·성공도 attemptCount의 홀짝으로 정해지는 결정적 값입니다. 실제 서비스에서는 이 계산 겹을 그대로 두고 commit()·attempt() 안의 성공·실패 분기만 실제 fetch 응답으로 바꿔 끼우면 됩니다.
모바일 웹 당겨서 새로고침은 CSS만으로도 만들 수 있나요?
아홉 개 모두 당긴 거리 값을 실시간으로 읽어 저항 곡선·비율·감쇠 진동 같은 계산을 매 프레임 다시 해야 해서, CSS 트랜지션만으로는 같은 동작이 나오지 않습니다. 05 의 원형 진행 링처럼 stroke-dashoffset 하나만 움직이는 것처럼 보여도 그 값이 매 프레임 손가락 위치에서 다시 계산되는 자바스크립트 계산 겹이 필요합니다.
더 읽을거리
- 무한 스크롤 로딩 UI 패턴 9종 — 스피너부터 가상 윈도우까지 — 349 는 목록 끝에 닿았을 때 다음 묶음을 이어 붙이는 계산을 다루는 데 비해, 이 편은 위로 당겼다 놓는 거리·기준·복귀 값을 다룹니다.
- 로딩페이지 디자인 9종 — 복붙하는 예쁜 CSS 로딩 화면 — 219 는 화면 전체가 뜨기 전 한 번 보여주는 로딩 표시를 다루는 데 비해, 이 편은 사용자가 직접 당긴 거리 값에 반응해 반복적으로 걸리는 새로고침 계산을 다룹니다.
- 스와이프 삭제 리스트 UI 9가지 — 페이지네이션까지 — 207 은 옆으로 미는 스와이프와 페이지 번호로 넘어가는 계산을 다루는 데 비해, 이 편은 위아래 한 방향으로 당겼다 놓는 거리·임계·복귀 계산을 다룹니다.









