引っ張って更新 スマホ 9選 — 引いた距離で変わる更新のかたち
9つのうち1つは引いた距離の値が大きくなるほど実際に動く幅の値がだんだん狭くなり、1つはその距離の割合の値だけ回転の値も一緒に回り、もう1つは基準を超えた状態で指を離してはじめて確定した状態に変わります。引っ張って更新 スマホの9パターンです。
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 弾性プル — 引いた距離の値が増えるほど抵抗が強まる更新トリガー
- 02 回転連動 — 引いた距離の割合の値だけ回転の値も一緒に回る表示
- 03 基準確定 — 引いた距離の値が基準を超えると確定状態に変わるトリガー
- 04 ゴムバンド限界 — 最大距離の値を超えると伸びる幅が急に減るプル
- 05 アイコン進行リング — 引いた距離の値だけ円形の進行の値が満ちていき、完了するとチェック表示に変わる表示
- 06 ラチェット目盛り — 引いた距離の値が目盛りの値を通過するたびに位置が短く跳ねる表示
- 07 スプリング着地 — 指を離した後、位置の値が目標を少し越えてから元に戻る表示
- 08 行順次表示 — 更新が終わると一覧の各行が順番の値に沿って現れる効果
- 09 失敗振動 — 更新が失敗した状態になると表示が左右に短く揺れる効果
9つとも実際のサーバーには一度も届きません。それぞれがsetTimeoutだけでネットワークの往復をその場で再現しており、09の成功・失敗の切り替えもattemptCountという回数のカウンターによるもので、navigator.onLineのような実際の接続確認ではありません。代わりに9つとも同じ3種類の実際のポインターイベントに応答します。pointerdownで引っ張りが始まり、pointermoveが1フレームごとに生の距離の値を伝え、pointerupがそのまま確定させるか元に戻すかを決めます。wireReal()を通した実際の入力なしに自動再生だけで動く項目は9つの中に1つもありません。プルトゥリフレッシュという名前が指しているのは、まさにこの9つがpointerdownから拾い続けるその生の距離の値そのものです。
9つの解剖 — どの値がいつ変わるか
9つは、指を離した後にその値がどこへ向かうかで大きく分かれます。この違いが、9つを同じスピナーの使い回しではなく別々のパターンにしています。
01と04がいつまでも更新を起動しない理由
01と04はcommitも更新の開始も一度も呼びません。この2つは生の距離の値を表示の値へどう変換するかだけを扱っています。01のresist()はD_MAX * raw / (raw + K)という式で、rawが増えるほど伸びる幅の値が少しずつ小さい増分になります。04のdisplayedOf()はLINEAR(70px)まではほぼ生の距離の値どおりに動き、それを超えると1 - Math.exp(-excess / K)という指数関数の項に切り替わり、MAX_D(120)に近づきながらも決して届きません。どちらも指を離した後は同じdiff * 0.28・diff * 0.3のイージングで0へ戻るだけで、9つの中でこの2つだけが基準判定そのものを持っていません。
02と06だけ戻りのイージングを飛ばして指が離れた瞬間に0に戻る理由
02と06は、確定に届かなかった引っ張りに対してsettle()・goTo()のような戻りのループを一切使いません。02のonUp()はratioが1未満のまま指が離れた状態になるとapply(0)をその場で呼び、06のonUp()もraw = 0; lastTick = 0;をそのまま実行します。どちらも表示の値は次のフレームを待たずに0へ切り替わります。その代わりに確定した側の演出に手が込んでいます。02はspinFrame()という、引いた距離の値ではなく経過時間の値そのものから回転を作る処理に切り替わり、06はrawが新しいTICKの倍数を通過するたびにAMP * Math.exp(-t / TAU) * Math.sin(t * W)という減衰する跳ねを位置の値に重ねます。
03・05・07・08・09が同じ基準判定に5通りの方法でたどり着く理由
03・05・07・08・09は、指を離した距離の値を基準の値と比較して確定へ進む点は共通していますが、その比較の作り方は5つとも違います。03のsetCandidate()は基準を1つではなく2つ使い、ON(100)とOFF(70)の間にすき間を置くことで、100付近で値が揺れても候補の状態が細かく切り替わらないようにしています。05は自分の進行の割合の値をそのまま使い、raw >= REF(130)で指を離した状態になったcommit()が、引っ張りとは別に固定の320ミリ秒でチェック表示の線を描きます。07はイージングの代わりにaccel = -K * (pos - target) - C * velというばねの積分をフレームごとに計算し直すため、位置の値が一度目標を越えてから戻る動きが実際に生まれます。08と09はどちらも見慣れたdiff * 0.3のループで戻りますが、面白い計算は更新が始まった後にあります。08は各行の再表示をindex * STAGGER_MSだけ遅らせ、09は成否をattemptCount % 2という決まった手順で決めていて、ランダムな要素は一切入っていません。
パターンごとのコード
01弾性プル — 引いた距離の値が増えるほど抵抗が強まる更新トリガー
指で引いた距離の値が大きくなるほど、実際にずれる距離の値は徐々に小さい増分になり、指を離した状態になるとその距離の値は再び0に戻ります。モバイルウェブフィードの更新や、リスト最上部の引っ張り領域に向いています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレームのうち01は14フレームで動き、累積面積は15.39%、平均強度は30.6です。ゴースト・ドット・リストの3つが同じresist(raw)から得たdisplayedの値を同時に読んでいるため、引っ張りが伸びるほど3つとも一緒に増分が小さくなっていく様子が見えます。
// 計算層: 引いた生の距離(raw)を表示用の距離(displayed)へ変換する抵抗曲線。
// displayed = D_MAX * raw / (raw + K) — rawが増えるほど伸びが小さい増分になる
// 飽和曲線(ミカエリス・メンテン式)。
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') : '下に引っぱってみましょう';
}
02回転連動 — 引いた距離の割合の値だけ回転の値も一緒に回る表示
引いた距離の値を基準距離で割った割合の値だけ表示の回転の値も一緒に回り、割合の値が1になった状態になると回転が続く状態に変わります。ニュースフィード上部の表示や、受信箱の更新に使われる形です。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレームのうち02は20フレームで動き、累積面積は14.13%、平均強度は125.9で9つの中で最も高い値です。ほぼ毎フレームplace()がノブを270度の弧に沿って大きく動かしているため、小さな位置ずれではなく大きな回転そのものが強度を押し上げています。
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);
}
}
03基準確定 — 引いた距離の値が基準を超えると確定状態に変わるトリガー
引いた距離の値が決まった基準の値を超えた状態で指を離した状態になると確定した状態に変わって更新が始まり、基準に届かないまま指を離した状態になると距離の値は0に戻ります。メールボックスの更新やSNSタイムラインの更新に向いた形です。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレームのうち03は21フレームで動き、累積面積は13.56%、平均強度は35.0です。円盤の大きさと位置はどちらもMath.min(1, raw / ON)を毎フレーム読み続けているため、確定候補のフラグが切り替わる前から見た目の変化が止まりません。
// 計算層: 引っ張りが確定候補になるかどうかは、rawを1回比較するだけでは決めず、
// 2つの基準の間でヒステリシスを取って決める。
// ON(100px)を超えるとcandidateが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)' : '下に引っぱってください');
}
04ゴムバンド限界 — 最大距離の値を超えると伸びる幅が急に減るプル
引いた距離の値が決まった最大値に近づくほど、同じ力でも伸びる幅の値は急に小さくなり、指を離した状態になると距離の値は元の位置へ跳ね戻ります。短いリストの更新や、過度な引っ張りを防ぐ領域に向いています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレームのうち04は15フレームで動き、累積面積は9.80%、平均強度は50.2です。マーカーとリストはどちらもdisplayedOf(raw)を読んでいますが、リストの動きの方が上限に近づく前から目に見えて鈍り始め、マーカーだけが上限へじわじわ近づき続けます。
// 計算層: rawはLINEAR(70px)まではほぼ1対1で追従し、それを超えると
// 指数減衰曲線(1-e^-x/K)でMAX_D(120px)へ近づくため、上限に近いほど
// 同じ力を加えても伸びる幅が急に小さくなる。
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 + '%') : '下に引っぱってください';
}
05アイコン進行リング — 引いた距離の値だけ円形の進行の値が満ちていき、完了するとチェック表示に変わる表示
引いた距離の値が大きくなるほど円形リングの進行の値が円周に沿って満ちていき、進行の値が満杯の状態になるとリングの内側にチェック表示の線の値が描かれて更新完了を示します。タスクリストの更新やチャットルームの最新メッセージ読み込みに向いた形です。
<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレームのうち05は13フレームで動き、累積面積は1.13%と9つの中で最も低い一方、平均強度は133.0と最も高い値です。書き換わるのはリングの細い線とチェック表示だけですが、その少ない画素ほど変化が急なので平均強度が押し上げられます。
// 計算層: 引いた距離(raw)をREFで割った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 + '%') : '下に引っぱってください');
}
06ラチェット目盛り — 引いた距離の値が目盛りの値を通過するたびに位置が短く跳ねる表示
引いた距離の値が一定間隔の目盛りの値を通過するたびに表示の位置の値が短く跳ねてから落ち着き、通過した目盛りの数の値が増えるほどその跳ねが繰り返されます。確実な確認が必要な更新や、強いフィードバックが必要なリストに向いています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレームのうち06は22フレームで動き、9つの中で最も高い動作フレーム数です。累積面積は8.08%、平均強度は74.2です。frame()はドラッグ中でなくても毎アニメーションフレーム走り続けるため、目盛りを通過する瞬間以外でもマーカーがほとんど止まりません。
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);
}
07スプリング着地 — 指を離した後、位置の値が目標を少し越えてから元に戻る表示
指を離した状態になると表示の位置の値は固定位置を少し越えた値まで動き、時間が経つにつれその値が収まって固定位置の値に落ち着いた状態になります。上部固定の更新表示や、カード型フィードの更新に向いた形です。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレームのうち07は17フレームで動き、累積面積は7.86%、平均強度は76.8です。この越えて戻る動きそのものが、他のパターンならとっくに止まっている段階でも数フレーム余分にマーカーを動かし続けています。
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);
}
9つの中で他のパターンは毎フレーム作り直すdiff * factorだけで戻りますが、07だけはaccel = -K * (pos - target) - C * velという速度そのものを積分しています。chrome 引っ張って更新の挙動を自分のサイトで再現したい場合も、ここに並ぶ9つと同じpointerdown・pointermove・pointerupの3つのイベントで十分です。
08行順次表示 — 更新が終わると一覧の各行が順番の値に沿って現れる効果
更新が終わった状態になると一覧の各行は自分の順番の値の分だけ遅れて位置の値と不透明度の値が元に戻り、順番の値が大きい行ほど後から現れます。新着記事一覧の更新や、通知一覧の更新に使われる形です。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレームのうち08は15フレームで動き、累積面積は8.64%、平均強度は46.2です。測定データを見ると、9つの中で唯一自動再生だけで動くフレーム(10フレーム)を含んでいます。行の再表示は更新の一連の手順が終わった後にしか起きないためです。
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));
}
09失敗振動 — 更新が失敗した状態になると表示が左右に短く揺れる効果
更新が失敗した状態になると表示の左右位置の値が短い時間の間に何度か行き来してから0に戻り、再び引いた状態になると最初の手順がやり直されます。ネットワーク不安定状態の案内や、再試行が必要な更新に向いた形です。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が語ること
23フレームのうち09は17フレームで動き、累積面積は2.94%、平均強度は55.5です。揺れそのものは短く、スマホ枠自体の左右のずれに閉じているため、動きは鋭いのに累積面積は9つの中でも小さめの部類に収まっています。
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);
値が形を変える場所 — 距離・割合・基準の境界
3つの定数は、パターンの動き自体を変えずに感触だけを変えます。入れ替えても同じ引っ張りは同じ終着点に着きますが、そこまでの道筋が変わるだけです。
03のONが確定のしやすさだけを変え、円盤の見た目自体は変えない理由
setCandidate()はrawをONとだけ比較しますが、円盤の大きさと位置はMath.min(1, raw / ON)という同じ式でONの値そのものを読み続けています。100pxから150pxへ引き上げても円盤の育ち方は変わらず、変わるのは指を離す前にどれだけ引く必要があるかだけです。150pxにすると軽いスクロールの反動で誤って更新が始まる確率が減る代わりに、はっきりした引っ張りが必要になります。
07のKが着地の締まり具合だけを変え、動く距離自体は変えない理由
accel = -K * (pos - target) - C * velのKはばねの硬さだけを決め、指を離した位置そのものはKの値と関係ありません。170では数フレームで素早く目標に収まってわずかに跳ね返るだけですが、80まで下げると同じ距離を戻るのに時間がかかり、目標を越える幅もはっきり大きくなります。
08のSTAGGER_MSが表示の間隔だけを変え、現れる行の数自体は変えない理由
どの行もidx * STAGGER_MSという同じ式で自分の遅延を受け取り、変わるのは行と行の間隔だけです。90ミリ秒ではほぼ一斉に現れてひとまとまりに見えますが、180ミリ秒まで広げると1行ずつ順番に現れる様子がはっきり目で追えるようになります。
壊れる場所 — 素早い引っ張り・短い引っ張り・途中で離す
更新の最中にもう一度引っ張るとどうなるか
03・05・06・07・08・09はonDown()の先頭でcommitting(または同等のstate)を確認し、更新が進んでいる間は新しい引っ張りをそのまま無視します。01と04には確定状態そのものがないため、更新中の再度の引っ張りも、その時点のrawから同じ抵抗曲線・オーバースクロール曲線をそのまま続けるだけです。02はその中間で、state === 'spinning'の間だけ新しいドラッグを止めるため、その状態に切り替わる直前のわずかな間なら確定前のノブへの再度の引っ張りが割り込めます。
基準に届く前に短く引っ張って離すとどうなるか
01・03・04・05・08・09は、短い引っ張りでも同じdiff * factorのループで数フレームかけて0へ戻るため、軽く引いてすぐ離しても値がふわっと戻る様子が見えます。02と06はその逆で、apply(0)とraw = 0; lastTick = 0;が指を離した瞬間にそのまま実行されるため、確定に届かなかった引っ張りは戻る間もなくその場で消えます。
離す前に最大値を超えて引っ張るとどうなるか
9つともonMove()の中でrawをRAW_MAXや同等のMAXで必ずクランプしているため、表示の枠を大きく超えて引っ張り続けてもresist()・displayedOf()や各種の割合計算に際限のない値が渡ることはありません。03のヒステリシスはもう一段細かく、OFF(70)がON(100)より低い位置にあるため、1回のpointermoveで両方の基準を一気に飛び越える速い引っ張りでもそのフレームでcandidateはtrueに切り替わり、境界をゆっくり通過する場合と同じように確定します。
無限スクロールローディング UIパターン9選 — スピナーから仮想ウィンドウまで・【コピペOK】ローディング 画面 デザイン CSSだけ 9選・スワイプ削除リストUI 9選 — ページネーションまでとこの9つが違うのは、どの値を計算のもとにしているかです。無限スクロールの9つは、残りスクロール距離・追加された高さの値・表示中のインデックス範囲という、絶えず変わり続けるスクロール位置から値を導いていて、引っ張りのように指を離して判定する場面が一度もありません。ローディング画面の9つはポインターの値そのものに触れず、固定のCSSキーフレームの周期だけで動き続けます。スワイプ削除の9つはすでにプル・トゥ・リフレッシュの1項目を、スワイプ削除やページネーションと並ぶ広い9パターンの中の1つとして含んでいますが、こちらは9つすべてがこの1つの操作に特化していて、抵抗曲線とヒステリシス基準とばねの積分を互いに比べられます。この9つのソースファイルはすべて、パスワードbffdpzhjで解除した先にあり、擬似的なネットワーク遅延を実際のfetchに置き換えるだけで本物の更新処理に組み込めます。
3つのバリエーションとアクセシビリティ
9つのうち実際に配布用のバリエーションにする価値がある定数は3つだけです。他の6つに調整できる数値がないわけではなく、ON・K・STAGGER_MSの3つが、値をひとつ変えるだけで許容度・重さ・間隔の感触が最もはっきり変わる場所だからです。
| バリエーション | 変わるもの | トレードオフ |
|---|---|---|
| 03のONを100pxから150pxへ | 確定候補になるまでに必要な引っ張りの距離 | 100pxでは軽い引っ張りだけで確定候補になり指を離した瞬間に更新が始まるが、150pxではより長くはっきり引っ張る必要があり、素早い更新の代わりに誤操作が減る |
| 07のKを170から80へ | 指を離した後の着地の締まり具合 | 170ではばねが硬く数フレームで素早く収まるが、80まで下げると同じ引っ張りでもゆっくり戻ってはっきりした行き過ぎが見える |
| 08のSTAGGER_MSを90msから180msへ | 各行の再表示がどれだけ際立つか | 90msでは行がほぼ一塊で現れるが、180msまで広げると1行ずつ順番に現れる様子がはっきり見える |
9つともwindow.matchMedia('(prefers-reduced-motion: reduce)').matchesを自動再生プレビューの開始前にだけ確認します。このガードが止めるのはそのsetIntervalによるデモ動作だけで、wireReal()のpointerdown・pointermove・pointerupを通した実際の引っ張りは、この設定に関わらず抵抗曲線・回転・基準判定・ばねの着地をいつも最後まで再生します。9つともaria-labelやaria-hiddenはマークアップのどこにも置かれておらず、hintというテキスト要素の中身が「下に引っぱってください」「離すと更新」「更新中」と書き換わるだけで、aria-liveのようなライブリージョンは置かれていないため、スクリーンリーダーはこの状態変化を自動では読み上げません。実際のサービスでは更新ボタンなどキーボードの代替経路と読み上げ用の通知を別途用意する必要があります。
FAQ
これらのプル・トゥ・リフレッシュにアニメーションライブラリを使っているか
いいえ。9つともrequestAnimationFrame・setTimeout・CSSのtransformだけで動いており外部依存は一切ありません。HTML・SCSS・JSをそのまま貼り付けるだけで同じ動きがローカルでも再現できます。
9つのうち実際にサーバーへ通信しているものはあるか
ありません。どの引っ張りもsetTimeoutによるローカルな再現で、09の失敗状態もattemptCount % 2という決まった手順の切り替えであり、navigator.onLineのような実際の接続確認ではありません。fetchはこの9つのどこにも登場しません。
引っ張って更新 無効にする設定と9つのデモの関係
ブラウザ側の引っ張って更新 無効の設定は、ブラウザ自体が持つネイティブな引っ張り更新ジェスチャーを止めるものですが、この9つはpointerdown・pointermove・pointerupを自前で受け取るカスタム実装なので、その設定の有無に関わらずそのまま動きます。逆にOSの弱いモーション設定は自動再生プレビューのsetIntervalだけを止め、実際の引っ張りには影響しません。
Sources: MDN — Pointer events · MDN — transform-function: translate() · MDN — Window.requestAnimationFrame · MDN — prefers-reduced-motion









