09ラバープル — スクロール方向が変わった瞬間、値がゴムのように引かれてから戻る方式
方向が変わった瞬間、値が逆に引かれ、元の値へ弾むように戻ります。
使いどころ上部ナビゲーションバー、モバイルアプリ風ヘッダー
MIN_VELKICKSPRING_C

<main class="stage">
<div class="rpn is-demo" id="rpn">
<div class="rpn__frame">
<div class="rpn__nav" id="rpnNav"><span>区間案内</span></div>
<div class="rpn__viewport" id="rpnViewport" role="application" aria-label="方向転換ラバーナビ実験パネル" tabindex="0">
<div class="rpn__track" id="rpnTrack">
<div class="rpn__row">セクション1</div>
<div class="rpn__row">セクション2</div>
<div class="rpn__row">セクション3</div>
<div class="rpn__row">セクション4</div>
<div class="rpn__row">セクション5</div>
<div class="rpn__row">セクション6</div>
</div>
</div>
</div>
<p class="rpn__hint">スクロール方向を切り替えるたびに、上のナビバーがゴムのように弾みます</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 ラバープル — スクロール方向が変わった瞬間、値がゴムのように引かれてから戻る方式 — 計測 累積面積 1.796% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · スクロール 慣性 css 9選 — 値が自ら収まっていく9つの計算パターン
このデモが属する記事
スクロール 慣性 css 9選 — 値が自ら収まっていく9つの計算パターン9つのうち1つはスクロールが止まった後も移動の値が直前の速度を引き継いで少しずつ小さくなり、1つは端を超えて伸びた値が離すと元の値へ弾むように戻り、もう1つは方向が変わった瞬間に値が逆に引かれてから元の値へ戻ります。「スクロール 慣性 cs この記事のデモをすべて見る


