09Rubber pull — the moment scroll direction reverses, a value pulls like a rubber band and springs back
At the moment of reversal the value is pulled the opposite way, then springs back to its resting value.
Best forA top navigation bar, a mobile app-style header
MIN_VELKICKSPRING_C

<main class="stage">
<div class="rpn is-demo" id="rpn">
<div class="rpn__frame">
<div class="rpn__nav" id="rpnNav"><span>Section guide</span></div>
<div class="rpn__viewport" id="rpnViewport" role="application" aria-label="Direction-reversal rubber nav test panel" tabindex="0">
<div class="rpn__track" id="rpnTrack">
<div class="rpn__row">Section 1</div>
<div class="rpn__row">Section 2</div>
<div class="rpn__row">Section 3</div>
<div class="rpn__row">Section 4</div>
<div class="rpn__row">Section 5</div>
<div class="rpn__row">Section 6</div>
</div>
</div>
</div>
<p class="rpn__hint">Every time you reverse scroll direction, the nav bar snaps back like a rubber band.</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹(방향전환 감지 + 스프링 적분)은 JS 에 있다 — 여기는 정적 배치만.
@include stage($stage-yellow);
.stage { position: relative; }
.rpn {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.rpn__frame {
width: 220px;
border-radius: $r-card;
overflow: hidden;
background: rgba($subject-ink, .06);
border: 1px solid rgba($subject-ink, .14);
}
.rpn__nav {
height: 34px;
background: $subject-blue-deep;
color: $stage-paper;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
transform-origin: center top;
will-change: transform;
}
.rpn__viewport {
position: relative;
width: 100%;
height: 150px;
overflow: hidden;
touch-action: none;
overscroll-behavior: contain;
cursor: grab;
box-sizing: border-box;
}
.rpn__track {
position: absolute;
inset: 0;
will-change: transform;
}
.rpn__row {
height: 40px;
margin: 0 $sp-3;
border-radius: $r-control;
background: rgba($subject-ink, .07);
color: $subject-ink;
display: flex;
align-items: center;
padding-left: $sp-2;
font-size: 12px;
box-sizing: border-box;
}
.rpn__hint {
color: rgba($subject-ink, .72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.rpn { gap: $sp-2; }
.rpn__frame { width: 176px; }
.rpn__nav { height: 26px; font-size: 11px; }
.rpn__viewport { height: 80px; }
.rpn__row { height: 30px; }
.rpn__hint { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.rpn__nav { 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);
}
.stage {
position: relative;
}
.rpn {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.rpn__frame {
width: 220px;
border-radius: 16px;
overflow: hidden;
background: rgba(23, 20, 26, 0.06);
border: 1px solid rgba(23, 20, 26, 0.14);
}
.rpn__nav {
height: 34px;
background: #0a4fe8;
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
transform-origin: center top;
will-change: transform;
}
.rpn__viewport {
position: relative;
width: 100%;
height: 150px;
overflow: hidden;
touch-action: none;
overscroll-behavior: contain;
cursor: grab;
box-sizing: border-box;
}
.rpn__track {
position: absolute;
inset: 0;
will-change: transform;
}
.rpn__row {
height: 40px;
margin: 0 12px;
border-radius: 12px;
background: rgba(23, 20, 26, 0.07);
color: #17141a;
display: flex;
align-items: center;
padding-left: 8px;
font-size: 12px;
box-sizing: border-box;
}
.rpn__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 12px;
text-align: center;
}
@media (max-width: 340px) {
.rpn {
gap: 8px;
}
.rpn__frame {
width: 176px;
}
.rpn__nav {
height: 26px;
font-size: 11px;
}
.rpn__viewport {
height: 80px;
}
.rpn__row {
height: 30px;
}
.rpn__hint {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.rpn__nav {
transition: none !important;
}
}(function () {
var root = document.getElementById('rpn');
var viewport = document.getElementById('rpnViewport');
var track = document.getElementById('rpnTrack');
var nav = document.getElementById('rpnNav');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// 계산 겹 ①: 목록은 입력에 1:1 로 즉시 따라간다(scrollPos, 경계 clamp).
var rowH = 40, rowCount = 6;
var maxScroll = Math.max(0, rowH * rowCount - viewport.clientHeight);
var scrollPos = 0;
var lastVel = 0;
// 계산 겹 ②: 스크롤 속도(vel)의 부호가 바뀌는 순간(방향 전환)에만 네비 바에 충격을 주고,
// 그 뒤엔 스프링 적분(SPRING_K·SPRING_C)이 scale 을 1로 되돌리며 튕긴다 — 경계에 닿아서가 아니다.
var MIN_VEL = 1.2;
var KICK = 0.4;
var SPRING_K = 260;
var SPRING_C = 14;
var navScale = 1, navVel = 0;
var navRaf = null;
function paintTrack() { track.style.transform = 'translateY(-' + scrollPos.toFixed(1) + 'px)'; }
function paintNav() { nav.style.transform = 'scaleY(' + navScale.toFixed(3) + ')'; }
function navTick() {
var force = -SPRING_K * (navScale - 1) - SPRING_C * navVel;
navVel += force * 0.016;
navScale += navVel * 0.016;
if (reduce) { navScale = 1; navVel = 0; paintNav(); navRaf = null; return; }
if (Math.abs(navScale - 1) < 0.003 && Math.abs(navVel) < 0.02) {
navScale = 1; navVel = 0; paintNav(); navRaf = null; return;
}
paintNav();
navRaf = requestAnimationFrame(navTick);
}
function ensureNavLoop() { if (navRaf === null) { navRaf = requestAnimationFrame(navTick); } }
function kickNav(sign) {
if (reduce) { return; }
navVel += sign * KICK;
ensureNavLoop();
}
function applyVel(vel) {
if (lastVel !== 0 && vel !== 0 && (lastVel > 0) !== (vel > 0) && Math.abs(lastVel) > MIN_VEL && Math.abs(vel) > MIN_VEL) {
kickNav(vel > 0 ? 1 : -1);
}
if (Math.abs(vel) > 0.1) { lastVel = vel; }
}
function setScroll(v, vel) {
var next = Math.max(0, Math.min(maxScroll, v));
applyVel(vel);
scrollPos = next;
paintTrack();
}
paintTrack(); paintNav();
function wireReal() {
viewport.addEventListener('wheel', function (e) {
e.preventDefault();
root.classList.remove('is-demo'); stopAutoplay();
setScroll(scrollPos + e.deltaY, e.deltaY);
}, { passive: false });
var dragging = false, lastY = 0;
viewport.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo'); stopAutoplay();
dragging = true; lastY = e.clientY;
viewport.setPointerCapture(e.pointerId);
});
viewport.addEventListener('pointermove', function (e) {
if (!dragging) { return; }
var dy = -(e.clientY - lastY);
setScroll(scrollPos + dy, dy);
lastY = e.clientY;
});
viewport.addEventListener('pointerup', function () { dragging = false; });
viewport.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') { return; }
root.classList.remove('is-demo'); stopAutoplay();
var dy = (e.key === 'ArrowDown' ? 40 : -40);
setScroll(scrollPos + dy, dy);
});
}
wireReal();
// 자동 루프: 아래로 갔다가 위로 되짚어 방향 전환을 주기적으로 만든다.
var autoTimer = null, phase = 0;
function autoTick() {
phase += 1;
var dir = (phase % 2 === 0) ? -1 : 1;
setScroll(scrollPos + dir * 60, dir * 60);
}
function stopAutoplay() { if (autoTimer) { clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = setInterval(autoTick, 900); autoTick(); }
})();import { useEffect, useRef } from "react";
import styles from "./ScrollMomentumRubberPullNav.module.scss";
export type ScrollMomentumRubberPullNavProps = {
color?: string;
};
export default function ScrollMomentumRubberPullNav({
color = "#2f6df6",
}: ScrollMomentumRubberPullNavProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const viewport = root.querySelector<HTMLDivElement>(`.${styles.viewport}`);
const track = root.querySelector<HTMLDivElement>(`.${styles.track}`);
const nav = root.querySelector<HTMLDivElement>(`.${styles.nav}`);
if (!viewport || !track || !nav) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// 계산 겹 ①: 목록은 입력에 1:1 로 즉시 따라간다(scrollPos, 경계 clamp).
const rowH = 40, rowCount = 6;
const maxScroll = Math.max(0, rowH * rowCount - viewport.clientHeight);
let scrollPos = 0;
let lastVel = 0;
// 계산 겹 ②: 스크롤 속도(vel)의 부호가 바뀌는 순간(방향 전환)에만 네비 바에 충격을 주고,
// 그 뒤엔 스프링 적분(SPRING_K·SPRING_C)이 scale 을 1로 되돌리며 튕긴다 — 경계에 닿아서가 아니다.
const MIN_VEL = 1.2;
const KICK = 0.4;
const SPRING_K = 260;
const SPRING_C = 14;
let navScale = 1, navVel = 0;
let navRaf: number | null = null;
function paintTrack() { track!.style.transform = "translateY(-" + scrollPos.toFixed(1) + "px)"; }
function paintNav() { nav!.style.transform = "scaleY(" + navScale.toFixed(3) + ")"; }
function navTick() {
const force = -SPRING_K * (navScale - 1) - SPRING_C * navVel;
navVel += force * 0.016;
navScale += navVel * 0.016;
if (reduce) { navScale = 1; navVel = 0; paintNav(); navRaf = null; return; }
if (Math.abs(navScale - 1) < 0.003 && Math.abs(navVel) < 0.02) {
navScale = 1; navVel = 0; paintNav(); navRaf = null; return;
}
paintNav();
navRaf = requestAnimationFrame(navTick);
}
function ensureNavLoop() { if (navRaf === null) navRaf = requestAnimationFrame(navTick); }
function kickNav(sign: number) {
if (reduce) return;
navVel += sign * KICK;
ensureNavLoop();
}
function applyVel(vel: number) {
if (lastVel !== 0 && vel !== 0 && (lastVel > 0) !== (vel > 0) && Math.abs(lastVel) > MIN_VEL && Math.abs(vel) > MIN_VEL) {
kickNav(vel > 0 ? 1 : -1);
}
if (Math.abs(vel) > 0.1) lastVel = vel;
}
function setScroll(v: number, vel: number) {
const next = Math.max(0, Math.min(maxScroll, v));
applyVel(vel);
scrollPos = next;
paintTrack();
}
paintTrack(); paintNav();
function onWheel(e: WheelEvent) {
e.preventDefault();
stopAutoplay();
setScroll(scrollPos + e.deltaY, e.deltaY);
}
let dragging = false, lastY = 0;
function onPointerDown(e: PointerEvent) {
stopAutoplay();
dragging = true; lastY = e.clientY;
viewport!.setPointerCapture(e.pointerId);
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
const dy = -(e.clientY - lastY);
setScroll(scrollPos + dy, dy);
lastY = e.clientY;
}
function onPointerUp() { dragging = false; }
function onKeydown(e: KeyboardEvent) {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
stopAutoplay();
const dy = e.key === "ArrowDown" ? 40 : -40;
setScroll(scrollPos + dy, dy);
}
viewport.addEventListener("wheel", onWheel, { passive: false });
viewport.addEventListener("pointerdown", onPointerDown);
viewport.addEventListener("pointermove", onPointerMove);
viewport.addEventListener("pointerup", onPointerUp);
viewport.addEventListener("keydown", onKeydown);
let autoTimer: number | null = null, phase = 0;
function autoTick() {
phase += 1;
const dir = phase % 2 === 0 ? -1 : 1;
setScroll(scrollPos + dir * 60, dir * 60);
}
function stopAutoplay() { if (autoTimer !== null) { window.clearInterval(autoTimer); autoTimer = null; } }
if (!reduce) { autoTimer = window.setInterval(autoTick, 900); autoTick(); }
return () => {
stopAutoplay();
if (navRaf !== null) cancelAnimationFrame(navRaf);
viewport.removeEventListener("wheel", onWheel);
viewport.removeEventListener("pointerdown", onPointerDown);
viewport.removeEventListener("pointermove", onPointerMove);
viewport.removeEventListener("pointerup", onPointerUp);
viewport.removeEventListener("keydown", onKeydown);
};
}, []);
const rows = [1, 2, 3, 4, 5, 6];
return (
<div ref={rootRef} className={styles.stage}>
<div className={styles.rpn}>
<div className={styles.frame}>
<div className={styles.nav} style={{ background: color }}><span>구간 안내</span></div>
<div className={styles.viewport} role="application" aria-label="방향 전환 러버 네비 실험판" tabIndex={0}>
<div className={styles.track}>
{rows.map((n) => (
<div key={n} className={styles.row}>섹션 {n}</div>
))}
</div>
</div>
</div>
<p className={styles.hint}>스크롤 방향을 바꾸는 순간마다 위 네비 바가 고무줄처럼 튕깁니다</p>
</div>
</div>
);
}09 Rubber pull — the moment scroll direction reverses, a value pulls like a rubber band and springs back — Measured area 1.796% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · Scroll Inertia CSS — 9 Value Patterns
The post this demo lives in
Scroll Inertia CSS — 9 Value PatternsOne value keeps its last scroll speed and shrinks to zero after stopping, one stretches past the boundary and springs ba See all demos in this post
If you like this effect - similar demos
Scroll03 Direction reverse — the particle flow's direction value flips the instant the scroll-direction value switches from down to upMoving Particles Background CSS: 9 Scroll Scenes
Scroll01 Card That Grows and Sharpens With the Range ValueCSS Scroll Animation: 9 View-Timeline Examples
Scroll04 Thumb That Overshoots and Springs Back at the EndCustom Scrollbar CSS — 9 Patterns