07Spring settle — an indicator whose position value slightly overshoots the target after release before settling
Once released, the indicator's position value moves slightly past the fixed position value before decaying over time and settling at the fixed position value.
Best forA fixed top refresh indicator, a card-feed refresh
KCaccel

<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">Pull down</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 = 'Refreshing'; }
else if (pos > 4) { hint.textContent = 'Displaced ' + Math.round(pos) + 'px'; }
else if (pos < -2) { hint.textContent = 'Rebound ' + Math.round(-pos) + 'px'; }
else { hint.textContent = 'Pull down'; }
}
function commit() {
committing = true;
hint.textContent = 'Refreshing';
commitTimer = setTimeout(function () {
committing = false;
hint.textContent = 'Pull down';
}, 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 Spring settle — an indicator whose position value slightly overshoots the target after release before settling — Measured area 7.859% · moving frames 17/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
Loaders02 Loader Whose Wireframe Vertices Settle Into Place With the Progress ValueCSS 3D Cube Animation Loaders, 9 Patterns
Loaders07 Scroll anchor preserve — a list correcting its position by the height value added aboveInfinite Scroll Loading: 9 Essential UI Patterns
Loaders07 Completion-value flash — the completion value flips to one the instant a download finishes, then falls back to zero after a set timeDownload Progress Bar: 9 Loading UI Patterns