07가장자리 자동 스크롤 — 거리를 속도로 매핑하는 rAF 루프
드래그 중 포인터가 컨테이너 위·아래 가장자리 안쪽 40px 영역에 들어오면, 가장자리까지의 남은 거리를 0~1 로 정규화해 최대 속도에 곱한 값을 매 프레임 scrollBy 에 넘기는 requestAnimationFrame 루프를 돌립니다. 가장자리에 가까울수록 스크롤이 빨라지고 벗어나면 루프를 즉시 멈춥니다.
쓰임긴 리스트, 관리자 테이블
requestAnimationFramescrollBygetBoundingClientRect

<main class="stage">
<div class="as">
<p class="as__hint">가장자리까지 남은 거리를 정규화해 매 프레임 스크롤 속도에 곱합니다</p>
<div class="as__view" id="asView">
<ul class="as__list" id="asList">
<li class="as__row"><span class="as__txt">배송 준비</span></li>
<li class="as__row"><span class="as__txt">배송 중</span></li>
<li class="as__row"><span class="as__txt">고객 인수</span></li>
<li class="as__row"><span class="as__txt">티켓 발급</span></li>
<li class="as__row"><span class="as__txt">문의 확정</span></li>
<li class="as__row"><span class="as__txt">포인트 적립</span></li>
<li class="as__row"><span class="as__txt">쿠폰 지급</span></li>
<li class="as__row"><span class="as__txt">리뷰 요청</span></li>
<li class="as__row"><span class="as__txt">환불 처리</span></li>
</ul>
</div>
<p class="as__meta"><b class="as__state" id="asState">speed 0px/s</b><span class="as__unit">초당 480px</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;
}
}// 가장자리 자동 스크롤(07) — 든 행을 옮기는 동안 포인터 y 를 스크롤 상자(view) rect 와 비교해
// 위/아래 40px 밴드 안이면 가장자리까지 남은 거리를 0..1 로 정규화하고, 그 값에 최대 속도(초당 480px)
// 를 곱해 매 rAF 프레임 scrollBy 로 스크롤한다. 밴드 밖이면 속도 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" 과 같은 값
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 가장자리 자동 스크롤 — 거리를 속도로 매핑하는 rAF 루프 — 측정 누적면적 41.662% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 드래그 앤 드롭 UI 리스트 — 목표 인덱스 계산을 얹은 9종
이 데모가 속한 글
드래그 앤 드롭 UI 리스트 — 목표 인덱스 계산을 얹은 9종포인터가 놓인 위치에서 목표 인덱스를 계산해 리스트·칸반·그리드가 자리를 바꾸고, 롱프레스·Alt 화살표·가장자리 자동 스크롤까지 — 드래그로 순서 바꾸는 리스트 9종, 코드는 비밀번호 zip으로 드립니다. 드래그 이 글의 데모 전부 보기


