04隣の中間点を越えると入れ替わるスワップドラッグ
ドラッグ中のカードの位置が隣のカードの中間点を越える瞬間を計算して検知し、その瞬間二つのカードがスプリングアニメーションで入れ替わる。
使いどころプレイリストの並び替え
SWAP_FLIP_STIFFNESSSWAP_FLIP_DAMPINGKEY_STEP_SLOTS

<main class="stage">
<div class="sw is-demo" id="sw">
<div class="sw__track" id="swTrack">
<button type="button" class="sw__chip" id="swChip0" data-id="0" aria-label="ドラッグして順番を変えるチップ。Enterで掴み、矢印キーで隣のマスと入れ替えます">A</button>
<div class="sw__chip sw__chip--static" data-id="1">B</div>
<div class="sw__chip sw__chip--static" data-id="2">C</div>
</div>
<p class="sw__caption" id="swCaption">中点を越えると隣と場所を入れ替えます</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.sw {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
max-width: 400px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-5;
}
.sw__track {
position: relative;
display: flex;
gap: $sp-3;
}
.sw__chip {
appearance: none;
border: 0;
width: 64px;
height: 64px;
border-radius: $r-control;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 18px;
color: $subject-cream;
background: $subject-blue;
box-shadow: $shadow-raised;
will-change: transform;
}
.sw__chip--static {
background: $subject-blue-deep;
cursor: default;
}
#swChip0 {
cursor: grab;
touch-action: none;
position: relative;
z-index: 2;
}
#swChip0.is-grabbed,
#swChip0:focus-visible {
outline: 2px solid $subject-blue-light;
outline-offset: 3px;
}
.sw.is-dragging #swChip0 {
cursor: grabbing;
z-index: 3;
}
.sw__caption {
margin: 0;
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.sw__chip { width: 48px; height: 48px; font-size: 15px; }
.sw__caption { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.sw__chip { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sw {
position: relative;
width: min(92vw, 160vh - 12.8vw);
max-width: 400px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 20px;
}
.sw__track {
position: relative;
display: flex;
gap: 12px;
}
.sw__chip {
appearance: none;
border: 0;
width: 64px;
height: 64px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 18px;
color: #fff7e6;
background: #2f6df6;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
will-change: transform;
}
.sw__chip--static {
background: #0a4fe8;
cursor: default;
}
#swChip0 {
cursor: grab;
touch-action: none;
position: relative;
z-index: 2;
}
#swChip0.is-grabbed,
#swChip0:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.sw.is-dragging #swChip0 {
cursor: grabbing;
z-index: 3;
}
.sw__caption {
margin: 0;
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
text-align: center;
}
@media (max-width: 340px) {
.sw__chip {
width: 48px;
height: 48px;
font-size: 15px;
}
.sw__caption {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.sw__chip {
transition: none;
}
}(function () {
// 기법: 드래그 중인 칩의 중심이 이웃 칩과의 중점(슬롯 경계)을 넘는 순간을 감지해
// order 배열의 자리를 바꾸고, 밀려난 칩은 FLIP(새 목표 위치와의 차이를 스프링으로
// 0 까지 줄이는) 방식으로 부드럽게 새 자리로 이동한다.
var root = document.getElementById('sw');
var track = document.getElementById('swTrack');
var chips = Array.prototype.slice.call(track.querySelectorAll('.sw__chip'));
var caption = document.getElementById('swCaption');
var dragChip = document.getElementById('swChip0');
var DRAGGED_ID = 0;
var SWAP_FLIP_STIFFNESS = 210;
var SWAP_FLIP_DAMPING = 22;
var KEY_STEP_SLOTS = 1;
var order = [0, 1, 2];
var STEP = 0;
var state = {};
chips.forEach(function (c) {
var id = parseInt(c.getAttribute('data-id'), 10);
state[id] = { x: 0, v: 0, target: 0 };
});
function measureStep() {
if (chips.length < 2) return 80;
var r0 = chips[0].getBoundingClientRect();
var r1 = chips[1].getBoundingClientRect();
return r1.left - r0.left;
}
function slotOf(id) { return order.indexOf(id); }
function retarget() {
chips.forEach(function (c) {
var id = parseInt(c.getAttribute('data-id'), 10);
state[id].target = (slotOf(id) - id) * STEP;
});
}
function moveInOrder(fromSlot, toSlot) {
var id = order[fromSlot];
order.splice(fromSlot, 1);
order.splice(toSlot, 0, id);
}
function applyChip(id, x) {
var el = id === DRAGGED_ID ? dragChip : chips.filter(function (c) { return parseInt(c.getAttribute('data-id'), 10) === id; })[0];
el.style.transform = 'translateX(' + x.toFixed(1) + 'px)';
}
var raf = 0, lastTs = null;
function settleTick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
var moving = false;
chips.forEach(function (c) {
var id = parseInt(c.getAttribute('data-id'), 10);
if (id === DRAGGED_ID && dragging) return;
var s = state[id];
var f = (s.target - s.x) * SWAP_FLIP_STIFFNESS - s.v * SWAP_FLIP_DAMPING;
s.v += f * dt;
s.x += s.v * dt;
applyChip(id, s.x);
if (Math.abs(s.v) > 1 || Math.abs(s.target - s.x) > 0.5) moving = true;
else { s.x = s.target; s.v = 0; applyChip(id, s.x); }
});
if (moving) { raf = requestAnimationFrame(settleTick); }
else { lastTs = null; raf = 0; }
}
function kickSpring() {
if (!raf) { lastTs = null; raf = requestAnimationFrame(settleTick); }
}
var dragging = false;
var pointerStartX = null;
var dragStartX = 0;
var startSlot = 0;
var grabbed = false;
function beginDrag(clientX) {
cancelAnimationFrame(raf); raf = 0; lastTs = null;
dragging = true;
pointerStartX = clientX;
dragStartX = state[DRAGGED_ID].x;
startSlot = slotOf(DRAGGED_ID);
root.classList.add('is-dragging');
}
function moveDrag(clientX) {
if (!STEP) STEP = measureStep();
var deltaX = clientX - pointerStartX;
var rawX = dragStartX + deltaX;
applyChip(DRAGGED_ID, rawX);
// 중점 판정: 드래그 칩의 화면 중심을 재서 어느 슬롯에 가장 가까운지 계산
var chipRect = dragChip.getBoundingClientRect();
var trackRect = track.getBoundingClientRect();
var centerX = chipRect.left + chipRect.width / 2 - trackRect.left;
var targetSlot = Math.max(0, Math.min(chips.length - 1, Math.round((centerX - STEP / 2) / STEP)));
while (targetSlot !== slotOf(DRAGGED_ID)) {
var from = slotOf(DRAGGED_ID);
var step = targetSlot > from ? 1 : -1;
var to = from + step;
moveInOrder(from, to);
retarget();
kickSpring();
caption.textContent = '場所が入れ替わりました';
}
state[DRAGGED_ID].x = rawX;
}
function endDrag() {
dragging = false;
root.classList.remove('is-dragging');
caption.textContent = '中点を越えると隣と場所を入れ替えます';
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
retarget();
if (reduce) {
chips.forEach(function (c) {
var id = parseInt(c.getAttribute('data-id'), 10);
state[id].x = state[id].target; state[id].v = 0;
applyChip(id, state[id].x);
});
} else {
kickSpring();
}
}
dragChip.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo');
dragChip.setPointerCapture(e.pointerId);
beginDrag(e.clientX);
e.preventDefault();
});
dragChip.addEventListener('pointermove', function (e) {
if (!dragging) return;
moveDrag(e.clientX);
e.preventDefault();
});
dragChip.addEventListener('pointerup', function (e) {
if (!dragging) return;
endDrag();
e.preventDefault();
});
dragChip.addEventListener('keydown', function (e) {
root.classList.remove('is-demo');
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
grabbed = !grabbed;
dragChip.classList.toggle('is-grabbed', grabbed);
caption.textContent = grabbed ? '掴みました。矢印キーで場所を入れ替えてください' : '中点を越えると隣と場所を入れ替えます';
return;
}
if (!grabbed) return;
var dir = 0;
if (e.key === 'ArrowLeft') dir = -1;
else if (e.key === 'ArrowRight') dir = 1;
else if (e.key === 'Escape') {
e.preventDefault();
grabbed = false;
dragChip.classList.remove('is-grabbed');
caption.textContent = '中点を越えると隣と場所を入れ替えます';
return;
} else { return; }
e.preventDefault();
var from = slotOf(DRAGGED_ID);
var to = Math.max(0, Math.min(chips.length - 1, from + dir * KEY_STEP_SLOTS));
if (to !== from) {
moveInOrder(from, to);
retarget();
kickSpring();
caption.textContent = '場所が入れ替わりました';
}
});
if (!STEP) STEP = 96;
retarget();
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
root.classList.remove('is-demo');
} else {
var demoStart = null;
function demoTick(ts) {
if (demoStart === null) { demoStart = ts; STEP = measureStep(); }
var t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 1000) {
var p = Math.min(1, (t - 200) / 800);
var ease = 1 - Math.pow(1 - p, 2);
var travel = STEP * 2 * ease;
if (t < 220) beginDrag(0);
moveDrag(travel);
} else if (t < 1300) {
// 결과 관찰
} else if (t < 1330) {
endDrag();
} else {
if (t > 1900) demoStart = ts - 60;
}
requestAnimationFrame(demoTick);
}
requestAnimationFrame(demoTick);
}
})();import { useEffect, useRef } from "react";
import styles from "./DragPhysSwapNeighbor.module.scss";
type Box<T> = { current: T };
export default function DragPhysSwapNeighbor() {
const rootRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const dragChipRef = useRef<HTMLButtonElement>(null);
const chipBRef = useRef<HTMLDivElement>(null);
const chipCRef = useRef<HTMLDivElement>(null);
const captionRef = useRef<HTMLParagraphElement>(null);
useEffect(() => {
const root = rootRef.current;
const track = trackRef.current;
const dragChip = dragChipRef.current;
const caption = captionRef.current;
if (!root || !track || !dragChip || !chipBRef.current || !chipCRef.current || !caption) return;
const chips: HTMLElement[] = [dragChip, chipBRef.current, chipCRef.current];
const DRAGGED_ID = 0;
const SWAP_FLIP_STIFFNESS = 210;
const SWAP_FLIP_DAMPING = 22;
const KEY_STEP_SLOTS = 1;
let order = [0, 1, 2];
let STEP = 0;
const state: Record<number, { x: number; v: number; target: number }> = {
0: { x: 0, v: 0, target: 0 },
1: { x: 0, v: 0, target: 0 },
2: { x: 0, v: 0, target: 0 },
};
function measureStep() {
const r0 = chips[0].getBoundingClientRect();
const r1 = chips[1].getBoundingClientRect();
return r1.left - r0.left;
}
function slotOf(id: number) { return order.indexOf(id); }
function retarget() {
chips.forEach(function (c, id) {
state[id].target = (slotOf(id) - id) * STEP;
});
}
function moveInOrder(fromSlot: number, toSlot: number) {
const id = order[fromSlot];
order.splice(fromSlot, 1);
order.splice(toSlot, 0, id);
}
function applyChip(id: number, x: number) {
chips[id].style.transform = "translateX(" + x.toFixed(1) + "px)";
}
let raf = 0, lastTs: number | null = null;
let dragging = false;
function settleTick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.032);
lastTs = ts;
let moving = false;
chips.forEach(function (c, id) {
if (id === DRAGGED_ID && dragging) return;
const s = state[id];
const f = (s.target - s.x) * SWAP_FLIP_STIFFNESS - s.v * SWAP_FLIP_DAMPING;
s.v += f * dt;
s.x += s.v * dt;
applyChip(id, s.x);
if (Math.abs(s.v) > 1 || Math.abs(s.target - s.x) > 0.5) moving = true;
else { s.x = s.target; s.v = 0; applyChip(id, s.x); }
});
if (moving) { raf = requestAnimationFrame(settleTick); } else { lastTs = null; raf = 0; }
}
function kickSpring() {
if (!raf) { lastTs = null; raf = requestAnimationFrame(settleTick); }
}
let pointerStartX: number | null = null;
let dragStartX = 0;
let startSlot = 0;
let grabbed = false;
function beginDrag(clientX: number) {
cancelAnimationFrame(raf); raf = 0; lastTs = null;
dragging = true;
pointerStartX = clientX;
dragStartX = state[DRAGGED_ID].x;
startSlot = slotOf(DRAGGED_ID);
root!.classList.add(styles.isDragging);
}
function moveDrag(clientX: number) {
if (!STEP) STEP = measureStep();
const deltaX = clientX - pointerStartX!;
const rawX = dragStartX + deltaX;
applyChip(DRAGGED_ID, rawX);
const chipRect = dragChip!.getBoundingClientRect();
const trackRect = track!.getBoundingClientRect();
const centerX = chipRect.left + chipRect.width / 2 - trackRect.left;
const targetSlot = Math.max(0, Math.min(chips.length - 1, Math.round((centerX - STEP / 2) / STEP)));
while (targetSlot !== slotOf(DRAGGED_ID)) {
const from = slotOf(DRAGGED_ID);
const step = targetSlot > from ? 1 : -1;
const to = from + step;
moveInOrder(from, to);
retarget();
kickSpring();
caption!.textContent = "자리가 바뀌었어요";
}
state[DRAGGED_ID].x = rawX;
}
function endDrag() {
dragging = false;
root!.classList.remove(styles.isDragging);
caption!.textContent = "중점을 넘으면 이웃과 자리를 바꿔요";
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
retarget();
if (reduce) {
chips.forEach(function (c, id) {
state[id].x = state[id].target; state[id].v = 0;
applyChip(id, state[id].x);
});
} else {
kickSpring();
}
}
function onPointerDown(e: PointerEvent) {
root!.classList.remove("is-demo");
dragChip!.setPointerCapture(e.pointerId);
beginDrag(e.clientX);
e.preventDefault();
}
function onPointerMove(e: PointerEvent) {
if (!dragging) return;
moveDrag(e.clientX);
e.preventDefault();
}
function onPointerUp(e: PointerEvent) {
if (!dragging) return;
endDrag();
e.preventDefault();
}
dragChip.addEventListener("pointerdown", onPointerDown);
dragChip.addEventListener("pointermove", onPointerMove);
dragChip.addEventListener("pointerup", onPointerUp);
function onKeyDown(e: KeyboardEvent) {
root!.classList.remove("is-demo");
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
grabbed = !grabbed;
dragChip!.classList.toggle(styles.isGrabbed, grabbed);
caption!.textContent = grabbed ? "잡았어요. 화살표로 자리를 바꾸세요" : "중점을 넘으면 이웃과 자리를 바꿔요";
return;
}
if (!grabbed) return;
let dir = 0;
if (e.key === "ArrowLeft") dir = -1;
else if (e.key === "ArrowRight") dir = 1;
else if (e.key === "Escape") {
e.preventDefault();
grabbed = false;
dragChip!.classList.remove(styles.isGrabbed);
caption!.textContent = "중점을 넘으면 이웃과 자리를 바꿔요";
return;
} else { return; }
e.preventDefault();
const from = slotOf(DRAGGED_ID);
const to = Math.max(0, Math.min(chips.length - 1, from + dir * KEY_STEP_SLOTS));
if (to !== from) {
moveInOrder(from, to);
retarget();
kickSpring();
caption!.textContent = "자리가 바뀌었어요";
}
}
dragChip.addEventListener("keydown", onKeyDown);
if (!STEP) STEP = 96;
retarget();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demoRaf = 0;
if (reduce) {
root.classList.remove("is-demo");
} else {
let demoStart: number | null = null;
function demoTick(ts: number) {
if (demoStart === null) { demoStart = ts; STEP = measureStep(); }
const t = ts - demoStart;
if (t < 200) {
// 대기
} else if (t < 1000) {
const p = Math.min(1, (t - 200) / 800);
const ease = 1 - Math.pow(1 - p, 2);
const travel = STEP * 2 * ease;
if (t < 220) beginDrag(0);
moveDrag(travel);
} else if (t < 1300) {
// 결과 관찰
} else if (t < 1330) {
endDrag();
} else {
if (t > 1900) demoStart = ts - 60;
}
demoRaf = requestAnimationFrame(demoTick);
}
demoRaf = requestAnimationFrame(demoTick);
}
return () => {
dragChip!.removeEventListener("pointerdown", onPointerDown);
dragChip!.removeEventListener("pointermove", onPointerMove);
dragChip!.removeEventListener("pointerup", onPointerUp);
dragChip!.removeEventListener("keydown", onKeyDown);
cancelAnimationFrame(raf);
cancelAnimationFrame(demoRaf);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.sw} is-demo`} ref={rootRef}>
<div className={styles.track} ref={trackRef}>
<button
type="button"
className={`${styles.chip} ${styles.chipDrag}`}
ref={dragChipRef}
aria-label="드래그해서 순서를 바꾸는 칩. Enter 로 잡고 화살표로 옆 칸과 자리를 바꿉니다"
>A</button>
<div className={`${styles.chip} ${styles.chipStatic}`} ref={chipBRef}>B</div>
<div className={`${styles.chip} ${styles.chipStatic}`} ref={chipCRef}>C</div>
</div>
<p className={styles.caption} ref={captionRef}>중점을 넘으면 이웃과 자리를 바꿔요</p>
</div>
</main>
);
}04 隣の中間点を越えると入れ替わるスワップドラッグ — 計測 累積面積 22.487% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · ドラッグ アンド ドロップ 実装 9パターン — 指を離した後まで続く計算
このデモが属する記事
ドラッグ アンド ドロップ 実装 9パターン — 指を離した後まで続く計算「ドラッグ アンド ドロップ 実装」の作り方は9通りあります。磁力グリッドが24px以内でアイコンを捕まえ、ゴムのように伸びた境界がその手をまた放し、指を離した瞬間の速度が残りの距離を押し進める。 この記事のデモをすべて見る


