01Elastic pull — a refresh trigger whose resistance grows as the dragged distance value increases
As the dragged distance value grows, the actual displaced distance value increases by progressively smaller amounts, and once released that distance value returns to zero.
Best forA mobile web feed refresh, the pull area at the top of a list
D_MAXKresist

<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">Pull down to try</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)를 Displaced 거리(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 ? ('Pull ' + Math.round(raw) + 'px · Displaced ' + Math.round(displayed) + 'px') : 'Pull down to try';
}
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 Elastic pull — a refresh trigger whose resistance grows as the dragged distance value increases — Measured area 15.385% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · Pull to Refresh CSS: 9 Loading Patterns
The post this demo lives in
Pull to Refresh CSS: 9 Loading PatternsOne dragged distance value produces a progressively smaller displacement, one rotation value turns in proportion to that See all demos in this post
If you like this effect - similar demos
Loaders03 Load trigger threshold — a list recomputing how early it starts loading from the scroll speed valueInfinite Scroll Loading: 9 Essential UI Patterns
Loaders02 Loader Whose Wireframe Vertices Settle Into Place With the Progress ValueCSS 3D Cube Animation Loaders, 9 Patterns
Loaders01 QR Code Whose Modules Fill In With the Progress ValueQR Code Loading Animation — 9 CSS Expiry Patterns