07Edge autoscroll — an rAF loop mapping edge distance into scroll speed
Once the pointer enters the inner 40px band near a container's top or bottom edge during a drag, an requestAnimationFrame loop normalizes the remaining distance to that edge into 0-1, multiplies it by a max speed, and feeds the result to scrollBy every frame. Scrolling speeds up the closer it gets to the edge, and the loop stops the instant it leaves the band.
Best forLong lists, admin tables
requestAnimationFramescrollBygetBoundingClientRect

<main class="stage">
<div class="as">
<p class="as__hint">Remaining distance to the edge is normalized and multiplied into per-frame scroll speed.</p>
<div class="as__view" id="asView">
<ul class="as__list" id="asList">
<li class="as__row"><span class="as__txt">Packing</span></li>
<li class="as__row"><span class="as__txt">Shipping</span></li>
<li class="as__row"><span class="as__txt">Delivered</span></li>
<li class="as__row"><span class="as__txt">Ticketing</span></li>
<li class="as__row"><span class="as__txt">Confirmed</span></li>
<li class="as__row"><span class="as__txt">Points</span></li>
<li class="as__row"><span class="as__txt">Coupons</span></li>
<li class="as__row"><span class="as__txt">Review ask</span></li>
<li class="as__row"><span class="as__txt">Refunds</span></li>
</ul>
</div>
<p class="as__meta"><b class="as__state" id="asState">speed 0px/s</b><span class="as__unit">480px per second</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 220ms;
$easing: cubic-bezier(.2,.8,.2,1);
$color: $subject-ink;
@include stage($stage-orange);
.as {
width: 100%;
max-width: 340px;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.as__hint {
margin: 0;
padding: 0 $sp-1;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: $subject-ink;
}
.as__view {
height: 160px;
overflow-y: auto;
padding: $sp-2;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-press;
}
.as__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: $sp-1;
}
.as__row {
padding: $sp-2;
border-radius: $r-sm;
background: $subject-blue-deep;
color: $subject-cream;
font-weight: 500;
font-size: 12px;
line-height: 1.3;
touch-action: none;
cursor: grab;
will-change: transform;
}
.as__row--drag {
cursor: grabbing;
box-shadow: $shadow-raised;
}
.as__meta {
margin: 0;
padding: 0 $sp-1;
display: flex;
align-items: baseline;
gap: $sp-1;
}
.as__state {
font-weight: 700;
font-size: 12px;
color: $subject-ink;
}
.as__unit {
font-weight: 400;
font-size: 11px;
color: $subject-ink;
}
@media (max-width: 340px) {
.as { max-width: 260px; gap: $sp-1; }
.as__hint { font-size: 10px; }
.as__view { height: 76px; padding: $sp-1; }
.as__row { font-size: 11px; padding: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.as__row {
transition: none !important;
transform: 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);
}
.as {
width: 100%;
max-width: 340px;
display: flex;
flex-direction: column;
gap: 8px;
}
.as__hint {
margin: 0;
padding: 0 4px;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: #17141a;
}
.as__view {
height: 160px;
overflow-y: auto;
padding: 8px;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.as__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
}
.as__row {
padding: 8px;
border-radius: 8px;
background: #0a4fe8;
color: #fff7e6;
font-weight: 500;
font-size: 12px;
line-height: 1.3;
touch-action: none;
cursor: grab;
will-change: transform;
}
.as__row--drag {
cursor: grabbing;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.as__meta {
margin: 0;
padding: 0 4px;
display: flex;
align-items: baseline;
gap: 4px;
}
.as__state {
font-weight: 700;
font-size: 12px;
color: #17141a;
}
.as__unit {
font-weight: 400;
font-size: 11px;
color: #17141a;
}
@media (max-width: 340px) {
.as {
max-width: 260px;
gap: 4px;
}
.as__hint {
font-size: 10px;
}
.as__view {
height: 76px;
padding: 4px;
}
.as__row {
font-size: 11px;
padding: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.as__row {
transition: none !important;
transform: none !important;
}
}// 가장자리 자동 scroll(07) — 든 행을 옮기는 동안 포인터 y 를 scroll 상자(view) rect 와 비교해
// 위/아래 40px 밴드 안이면 가장자리까지 남은 거리를 0..1 로 정규화하고, 그 값에 최대 속도(480px per second)
// 를 곱해 매 rAF 프레임 scrollBy 로 스크롤한다. 밴드 밖이면 speed 0 — 정지.
(function () {
var view = document.getElementById('asView');
var list = document.getElementById('asList');
var stateEl = document.getElementById('asState');
if (!view || !list || !stateEl) return;
var EDGE_PX = 40;
var MAX_SPEED = 480; // px per second — 문구 "480px per second" 과 같은 값
var drag = null; // { row, offsetInRow }
var lastT = 0;
var raf = null;
function rowsExcept(row) {
return Array.prototype.slice.call(list.children).filter(function (r) { return r !== row; });
}
function flipInsert(row, target) {
var siblings = rowsExcept(row);
var first = siblings.map(function (s) { return s.getBoundingClientRect(); });
list.insertBefore(row, target);
siblings.forEach(function (s, i) {
var r = s.getBoundingClientRect();
var dy = first[i].top - r.top;
if (dy !== 0) {
s.style.transition = 'none';
s.style.transform = 'translateY(' + dy + 'px)';
requestAnimationFrame(function () {
s.style.transition = 'transform 220ms cubic-bezier(.2,.8,.2,1)';
s.style.transform = 'translateY(0)';
});
}
});
}
function reflow(row, pointerY) {
var siblings = rowsExcept(row);
var target = null;
for (var i = 0; i < siblings.length; i++) {
var r = siblings[i].getBoundingClientRect();
if (pointerY < r.top + r.height / 2) { target = siblings[i]; break; }
}
if (target === row.nextElementSibling) return;
if (target === null && row === list.lastElementChild) return;
flipInsert(row, target);
}
// 가장자리 거리 → 0..1 정규화 → 프레임당 스크롤량
function edgeFactor(pointerY, viewRect) {
var topDist = pointerY - viewRect.top;
var botDist = viewRect.bottom - pointerY;
if (topDist >= 0 && topDist < EDGE_PX) return -(1 - topDist / EDGE_PX);
if (botDist >= 0 && botDist < EDGE_PX) return (1 - botDist / EDGE_PX);
return 0;
}
function tick(now) {
if (!drag) { raf = null; stateEl.textContent = 'speed 0px/s'; return; }
var dt = lastT ? (now - lastT) / 1000 : 0;
lastT = now;
var viewRect = view.getBoundingClientRect();
var f = edgeFactor(drag.pointerY, viewRect);
var speed = f * MAX_SPEED;
if (speed !== 0 && dt > 0) view.scrollBy(0, speed * dt);
stateEl.textContent = 'speed ' + Math.round(Math.abs(speed)) + 'px/s';
raf = requestAnimationFrame(tick);
}
function moveDrag(clientY) {
if (!drag) return;
drag.pointerY = clientY;
var listRect = list.getBoundingClientRect();
var ty = clientY - drag.offsetInRow - listRect.top - drag.row.offsetTop;
drag.row.style.transform = 'translateY(' + ty.toFixed(1) + 'px)';
reflow(drag.row, clientY);
}
function beginDrag(row, clientY) {
var r = row.getBoundingClientRect();
row.classList.add('as__row--drag');
drag = { row: row, offsetInRow: clientY - r.top, pointerY: clientY };
lastT = 0;
if (!raf) raf = requestAnimationFrame(tick);
}
function endDrag() {
if (!drag) return;
var row = drag.row;
drag = null;
row.classList.remove('as__row--drag');
row.style.transition = 'transform 220ms cubic-bezier(.2,.8,.2,1)';
row.style.transform = 'translateY(0)';
setTimeout(function () { row.style.transition = ''; }, 240);
}
function onDown(e) {
var row = e.target.closest('.as__row');
if (!row) return;
stop();
row.setPointerCapture(e.pointerId);
beginDrag(row, e.clientY);
}
function onMove(e) { moveDrag(e.clientY); }
function onUp() { endDrag(); }
list.addEventListener('pointerdown', onDown);
list.addEventListener('pointermove', onMove);
list.addEventListener('pointerup', onUp);
list.addEventListener('pointercancel', onUp);
// 자동 시연 — 같은 함수를 합성 좌표로 호출해 위/아래 가장자리를 번갈아 지나가게 한다
var playing = true;
function stop() { playing = false; }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function loopOnce() {
var vr = view.getBoundingClientRect();
var row = list.children[3];
beginDrag(row, vr.top + vr.height / 2);
moveDrag(vr.bottom - 6);
setTimeout(function () { if (drag) moveDrag(vr.top + 6); }, 520);
setTimeout(function () { endDrag(); }, 980);
}
if (reduce) {
view.scrollTop = 40;
} else {
loopOnce();
setInterval(function () { if (playing) loopOnce(); }, 1700);
}
})();// DragAutoscroll — vanilla 판(run/336/demo/07-drag-autoscroll/index.html)
// 의 React 포트. 든 행을 옮기는 동안 포인터 y 를 스크롤 상자(view) rect 와 비교해
// 위/아래 40px 밴드 안이면 가장자리까지 남은 거리를 0..1 로 정규화하고, 그 값에 최대
// 속도(초당 480px)를 곱해 매 rAF 프레임 scrollBy 로 스크롤한다. 밴드 밖이면 속도 0 — 정지.
import { useEffect, useRef } from "react";
import styles from "./DragAutoscroll.module.scss";
export type DragAutoscrollProps = {
duration?: string;
easing?: string;
color?: string;
};
const HINT = "가장자리까지 남은 거리를 정규화해 매 프레임 스크롤 속도에 곱합니다";
const ROW_TXT = ["배송 준비", "배송 중", "고객 인수", "티켓 발급", "문의 확정", "포인트 적립", "쿠폰 지급", "리뷰 요청", "환불 처리"];
type Box<T> = { current: T };
type DragState = { row: HTMLElement; offsetInRow: number; pointerY: number };
export default function DragAutoscroll({
duration = "220ms",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#17141a"
}: DragAutoscrollProps) {
const viewRef = useRef<HTMLDivElement | null>(null);
const listRef = useRef<HTMLUListElement | null>(null);
const stateRef = useRef<HTMLElement | null>(null);
const rafRef = useRef(0) as Box<number>;
useEffect(() => {
const view = viewRef.current;
const list = listRef.current;
const stateEl = stateRef.current;
if (!view || !list || !stateEl) return;
const EDGE_PX = 40;
const MAX_SPEED = 480; // px per second — 문구 "초당 480px" 과 같은 값
let drag: DragState | null = null;
let lastT = 0;
let raf = 0;
function rowsExcept(row: HTMLElement): HTMLElement[] {
return Array.prototype.slice.call(list!.children).filter((r: HTMLElement) => r !== row);
}
function flipInsert(row: HTMLElement, target: HTMLElement | null): void {
const siblings = rowsExcept(row);
const first = siblings.map((s) => s.getBoundingClientRect());
list!.insertBefore(row, target);
siblings.forEach((s, i) => {
const r = s.getBoundingClientRect();
const dy = first[i].top - r.top;
if (dy !== 0) {
s.style.transition = "none";
s.style.transform = "translateY(" + dy + "px)";
requestAnimationFrame(() => {
s.style.transition = "transform " + duration + " " + easing;
s.style.transform = "translateY(0)";
});
}
});
}
function reflow(row: HTMLElement, pointerY: number): void {
const siblings = rowsExcept(row);
let target: HTMLElement | null = null;
for (let i = 0; i < siblings.length; i++) {
const r = siblings[i].getBoundingClientRect();
if (pointerY < r.top + r.height / 2) {
target = siblings[i];
break;
}
}
if (target === row.nextElementSibling) return;
if (target === null && row === list!.lastElementChild) return;
flipInsert(row, target);
}
function edgeFactor(pointerY: number, viewRect: DOMRect): number {
const topDist = pointerY - viewRect.top;
const botDist = viewRect.bottom - pointerY;
if (topDist >= 0 && topDist < EDGE_PX) return -(1 - topDist / EDGE_PX);
if (botDist >= 0 && botDist < EDGE_PX) return 1 - botDist / EDGE_PX;
return 0;
}
function tick(now: number): void {
if (!drag) {
raf = 0;
stateEl!.textContent = "speed 0px/s";
return;
}
const dt = lastT ? (now - lastT) / 1000 : 0;
lastT = now;
const viewRect = view!.getBoundingClientRect();
const f = edgeFactor(drag.pointerY, viewRect);
const speed = f * MAX_SPEED;
if (speed !== 0 && dt > 0) view!.scrollBy(0, speed * dt);
stateEl!.textContent = "speed " + Math.round(Math.abs(speed)) + "px/s";
raf = requestAnimationFrame(tick);
rafRef.current = raf;
}
function moveDrag(clientY: number): void {
if (!drag) return;
drag.pointerY = clientY;
const listRect = list!.getBoundingClientRect();
const ty = clientY - drag.offsetInRow - listRect.top - drag.row.offsetTop;
drag.row.style.transform = "translateY(" + ty.toFixed(1) + "px)";
reflow(drag.row, clientY);
}
function beginDrag(row: HTMLElement, clientY: number): void {
const r = row.getBoundingClientRect();
row.classList.add(styles.rowDrag);
drag = { row, offsetInRow: clientY - r.top, pointerY: clientY };
lastT = 0;
if (!raf) {
raf = requestAnimationFrame(tick);
rafRef.current = raf;
}
}
function endDrag(): void {
if (!drag) return;
const row = drag.row;
drag = null;
row.classList.remove(styles.rowDrag);
row.style.transition = "transform 220ms cubic-bezier(.2,.8,.2,1)";
row.style.transform = "translateY(0)";
setTimeout(() => {
row.style.transition = "";
}, 240);
}
function onDown(e: PointerEvent): void {
const row = (e.target as HTMLElement).closest("." + styles.row) as HTMLElement | null;
if (!row) return;
stop();
row.setPointerCapture(e.pointerId);
beginDrag(row, e.clientY);
}
function onMove(e: PointerEvent): void {
moveDrag(e.clientY);
}
function onUp(): void {
endDrag();
}
list.addEventListener("pointerdown", onDown as EventListener);
list.addEventListener("pointermove", onMove as EventListener);
list.addEventListener("pointerup", onUp as EventListener);
list.addEventListener("pointercancel", onUp as EventListener);
let playing = true;
function stop(): void {
playing = false;
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const timeouts: number[] = [];
let intervalId = 0;
function loopOnce(): void {
const vr = view!.getBoundingClientRect();
const row = list!.children[3] as HTMLElement;
beginDrag(row, vr.top + vr.height / 2);
moveDrag(vr.bottom - 6);
timeouts.push(
window.setTimeout(() => {
if (drag) moveDrag(vr.top + 6);
}, 520)
);
timeouts.push(window.setTimeout(endDrag, 980));
}
if (reduce) {
view.scrollTop = 40;
} else {
loopOnce();
intervalId = window.setInterval(() => {
if (playing) loopOnce();
}, 1700);
}
return () => {
cancelAnimationFrame(rafRef.current);
timeouts.forEach((t) => clearTimeout(t));
if (intervalId) clearInterval(intervalId);
list.removeEventListener("pointerdown", onDown as EventListener);
list.removeEventListener("pointermove", onMove as EventListener);
list.removeEventListener("pointerup", onUp as EventListener);
list.removeEventListener("pointercancel", onUp as EventListener);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.as}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.view} ref={viewRef}>
<ul className={styles.list} ref={listRef}>
{ROW_TXT.map((txt, i) => (
<li key={i} className={styles.row}>
<span>{txt}</span>
</li>
))}
</ul>
</div>
<p className={styles.meta}>
<b className={styles.state} ref={stateRef} style={{ color }}>speed 0px/s</b>
<span className={styles.unit}>초당 480px</span>
</p>
</div>
</main>
);
}07 Edge autoscroll — an rAF loop mapping edge distance into scroll speed — Measured area 41.662% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · Drag to Reorder UI: 9 Lists, One Drop Index
The post this demo lives in
Drag to Reorder UI: 9 Lists, One Drop IndexDrag to reorder UI: Computing the drop index from pointer position reorders lists, kanban boards and grids -- with long- See all demos in this post
If you like this effect - similar demos
drag06 Momentum-Fling Drag Where Release Speed Pushes the Remaining DistanceDrag and Drop Animation CSS: 9 Physics Patterns
drag09 Reorder after upload — finishing by hand-arranging the completed chipsDrag and Drop File Upload UI: 9 Computed Dropzones
drag04 A Sidebar Edge That Clicks Into Three Detent WidthsCSS Resize Handle: 9 Demos That Follow Your Grip