09자리 표시자 — 들어올린 행의 높이를 옮겨 담아 부풀리는 계산
드래그를 시작한 행의 getBoundingClientRect 높이를 읽어 CSS 변수로 저장해두고, 그 값을 자리 표시자 칸의 --h 에 넘겨 height: var(--h) 로 정확히 같은 크기를 비워둡니다. 표시자가 목표 위치로 옮겨질 때마다 transition 으로 높이가 부드럽게 늘고 줄어들어 다른 행이 밀리는 폭을 자연스럽게 보여줍니다.
쓰임할 일 목록, 파일 리스트
transitionCSS custom propertiesgetBoundingClientRect

<main class="stage">
<div class="dd">
<p class="dd__hint">들어올린 행의 높이를 변수에 담아 두었다가 자리 표시자 칸에 채웁니다</p>
<ul class="dd__list" id="ddList">
<li class="dd__row" data-id="0"><span class="dd__txt">배송 준비</span></li>
<li class="dd__row" data-id="1"><span class="dd__txt">배송 중</span></li>
<li class="dd__row" data-id="2"><span class="dd__txt">고객 인수</span></li>
<li class="dd__row" data-id="3"><span class="dd__txt">티켓 발급</span></li>
</ul>
<p class="dd__meta"><b class="dd__state" id="ddState">slot 1</b></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 200ms;
$easing: cubic-bezier(.2,.8,.2,1);
$color: $subject-cream;
@include stage($stage-ink);
.dd {
width: 100%;
max-width: 340px;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.dd__hint {
margin: 0;
padding: 0 $sp-1;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: $subject-cream;
}
.dd__list {
position: relative;
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: $sp-1;
overflow-y: auto;
}
.dd__row {
padding: $sp-2;
border-radius: $r-sm;
background: $subject-cream;
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.3;
touch-action: none;
cursor: grab;
}
.dd__row--drag {
position: absolute;
left: 0;
top: var(--rowTop, 0px);
width: var(--rowW, auto);
transform: translateY(var(--ty, 0px));
cursor: grabbing;
box-shadow: $shadow-raised;
z-index: 5;
}
.dd__placeholder {
height: var(--h, 0px);
border-radius: $r-sm;
border: 2px dashed rgba($subject-cream, .48);
box-sizing: border-box;
}
.dd__meta {
margin: 0;
padding: 0 $sp-1;
}
.dd__state {
font-weight: 700;
font-size: 12px;
color: $subject-cream;
}
@media (max-width: 340px) {
.dd { max-width: 260px; gap: $sp-1; }
.dd__hint { font-size: 10px; }
.dd__row { font-size: 11px; padding: $sp-1; }
.dd__list { max-height: 88px; }
}
@media (prefers-reduced-motion: reduce) {
.dd__row {
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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dd {
width: 100%;
max-width: 340px;
display: flex;
flex-direction: column;
gap: 8px;
}
.dd__hint {
margin: 0;
padding: 0 4px;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: #fff7e6;
}
.dd__list {
position: relative;
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
}
.dd__row {
padding: 8px;
border-radius: 8px;
background: #fff7e6;
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.3;
touch-action: none;
cursor: grab;
}
.dd__row--drag {
position: absolute;
left: 0;
top: var(--rowTop, 0px);
width: var(--rowW, auto);
transform: translateY(var(--ty, 0px));
cursor: grabbing;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
z-index: 5;
}
.dd__placeholder {
height: var(--h, 0px);
border-radius: 8px;
border: 2px dashed rgba(255, 247, 230, 0.48);
box-sizing: border-box;
}
.dd__meta {
margin: 0;
padding: 0 4px;
}
.dd__state {
font-weight: 700;
font-size: 12px;
color: #fff7e6;
}
@media (max-width: 340px) {
.dd {
max-width: 260px;
gap: 4px;
}
.dd__hint {
font-size: 10px;
}
.dd__row {
font-size: 11px;
padding: 4px;
}
.dd__list {
max-height: 88px;
}
}
@media (prefers-reduced-motion: reduce) {
.dd__row {
transition: none !important;
}
}// 자리 표시자(09) — 든 행을 getBoundingClientRect 로 실측해 그 높이를 자리표시자의
// --h 커스텀 프로퍼티에 담는다. 든 행은 목록에서 빼내 position:absolute 로 포인터를
// 그대로 따라가고(transition 없음), 자리표시자만 목록의 정상 흐름 안에서 transform 이
// transition 으로 부드럽게 옮겨간다.
(function () {
var list = document.getElementById('ddList');
var stateEl = document.getElementById('ddState');
if (!list || !stateEl) return;
var placeholder = document.createElement('li');
placeholder.className = 'dd__placeholder';
var drag = null; // { row, offsetInRow, listRect }
function staticRows() {
return Array.prototype.slice.call(list.children).filter(function (r) {
return r !== placeholder && r !== (drag && drag.row);
});
}
function flipMovePlaceholder(target) {
var siblings = staticRows();
var first = siblings.map(function (s) { return s.getBoundingClientRect(); });
list.insertBefore(placeholder, 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(pointerY) {
var siblings = staticRows();
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 === placeholder.nextElementSibling) return;
if (target === null && placeholder === list.lastElementChild) return;
flipMovePlaceholder(target);
var idx = Array.prototype.indexOf.call(list.children, placeholder);
stateEl.textContent = 'slot ' + (idx + 1);
}
function beginDrag(row, clientY) {
var r = row.getBoundingClientRect();
var listRect = list.getBoundingClientRect();
placeholder.style.setProperty('--h', r.height + 'px');
list.insertBefore(placeholder, row);
row.classList.add('dd__row--drag');
row.style.setProperty('--rowTop', (r.top - listRect.top) + 'px');
row.style.setProperty('--rowW', r.width + 'px');
row.style.setProperty('--ty', '0px');
drag = { row: row, offsetInRow: clientY - r.top, listRect: listRect };
var idx = Array.prototype.indexOf.call(list.children, placeholder);
stateEl.textContent = 'slot ' + (idx + 1);
}
function moveDrag(clientY) {
if (!drag) return;
var ty = clientY - drag.offsetInRow - drag.listRect.top - parseFloat(drag.row.style.getPropertyValue('--rowTop'));
drag.row.style.setProperty('--ty', ty.toFixed(1) + 'px');
reflow(clientY);
}
function endDrag() {
if (!drag) return;
var row = drag.row;
drag = null;
list.insertBefore(row, placeholder);
placeholder.remove();
row.classList.remove('dd__row--drag');
row.style.transition = 'transform 200ms cubic-bezier(.2,.8,.2,1)';
row.style.setProperty('--ty', '0px');
setTimeout(function () {
row.style.transition = '';
row.style.removeProperty('--rowTop');
row.style.removeProperty('--rowW');
}, 210);
}
function onDown(e) {
var row = e.target.closest('.dd__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 row = list.children[0];
var r = row.getBoundingClientRect();
beginDrag(row, r.top + r.height / 2);
var r3 = list.children[3] ? list.children[3].getBoundingClientRect() : r;
setTimeout(function () { if (drag) moveDrag(r3.top + r3.height / 2 + 6); }, 220);
var r1 = list.children[1] ? list.children[1].getBoundingClientRect() : r;
setTimeout(function () { if (drag) moveDrag(r1.top + r1.height / 2 - 4); }, 520);
setTimeout(function () { endDrag(); }, 860);
}
if (reduce) {
// 정지 상태 그대로 — 연출 없음
} else {
loopOnce();
setInterval(function () { if (playing) loopOnce(); }, 1700);
}
})();// DragDropPlaceholder — vanilla 판(run/336/demo/09-drag-drop-placeholder/index.html)
// 의 React 포트. 든 행은 목록에서 빠져나와 절대 위치로 뜨고, 그 자리엔 빈 칸(placeholder)
// 이 FLIP 으로 자리를 옮겨 다닌다. 놓으면 행이 placeholder 자리로 되돌아가고 placeholder 는 사라진다.
import { useEffect, useRef } from "react";
import styles from "./DragDropPlaceholder.module.scss";
export type DragDropPlaceholderProps = {
duration?: string;
easing?: string;
color?: string;
};
const HINT = "든 행이 빠진 자리를 빈 칸이 대신 옮겨 다니다가 놓으면 그 자리로 들어갑니다";
const ROW_TXT = ["배송 준비", "배송 중", "고객 인수", "티켓 발급"];
type Box<T> = { current: T };
type DragState = { row: HTMLElement; offsetInRow: number; listRect: DOMRect };
export default function DragDropPlaceholder({
duration = "200ms",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6"
}: DragDropPlaceholderProps) {
const listRef = useRef<HTMLUListElement | null>(null);
const stateRef = useRef<HTMLElement | null>(null);
const rafRef = useRef(0) as Box<number>;
useEffect(() => {
const list = listRef.current;
const stateEl = stateRef.current;
if (!list || !stateEl) return;
const placeholder = document.createElement("li");
placeholder.className = styles.placeholder;
let drag: DragState | null = null;
function staticRows(): HTMLElement[] {
return Array.prototype.slice
.call(list!.children)
.filter((r: HTMLElement) => r !== placeholder && r !== (drag && drag.row));
}
function flipMovePlaceholder(target: HTMLElement | null): void {
const rows = staticRows();
const first = rows.map((s) => s.getBoundingClientRect());
list!.insertBefore(placeholder, target);
rows.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(pointerY: number): void {
const rows = staticRows();
let target: HTMLElement | null = null;
for (let i = 0; i < rows.length; i++) {
const r = rows[i].getBoundingClientRect();
if (pointerY < r.top + r.height / 2) {
target = rows[i];
break;
}
}
flipMovePlaceholder(target);
const idx = Array.prototype.indexOf.call(list!.children, placeholder);
stateEl!.textContent = "slot " + (idx + 1);
}
function beginDrag(row: HTMLElement, clientY: number): void {
const r = row.getBoundingClientRect();
const listRect = list!.getBoundingClientRect();
placeholder.style.setProperty("--h", r.height + "px");
list!.insertBefore(placeholder, row);
row.classList.add(styles.rowDrag);
row.style.setProperty("--rowTop", (r.top - listRect.top) + "px");
row.style.setProperty("--rowW", r.width + "px");
row.style.setProperty("--ty", "0px");
drag = { row, offsetInRow: clientY - r.top, listRect };
const idx = Array.prototype.indexOf.call(list!.children, placeholder);
stateEl!.textContent = "slot " + (idx + 1);
}
function moveDrag(clientY: number): void {
if (!drag) return;
const rowTop = parseFloat(drag.row.style.getPropertyValue("--rowTop"));
const ty = clientY - drag.offsetInRow - drag.listRect.top - rowTop;
drag.row.style.setProperty("--ty", ty.toFixed(1) + "px");
reflow(clientY);
}
function endDrag(): void {
if (!drag) return;
const row = drag.row;
drag = null;
list!.insertBefore(row, placeholder);
placeholder.remove();
row.classList.remove(styles.rowDrag);
row.style.transition = "transform " + duration + " " + easing;
row.style.setProperty("--ty", "0px");
setTimeout(() => {
row.style.transition = "";
row.style.removeProperty("--rowTop");
row.style.removeProperty("--rowW");
}, 210);
}
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 row = list!.children[0] as HTMLElement;
const r = row.getBoundingClientRect();
beginDrag(row, r.top + r.height / 2);
timeouts.push(
window.setTimeout(() => {
if (drag) {
const t = list!.children[3] as HTMLElement;
const tr = t.getBoundingClientRect();
moveDrag(tr.top + tr.height / 2 + 6);
}
}, 220)
);
timeouts.push(
window.setTimeout(() => {
if (drag) {
const t = list!.children[1] as HTMLElement;
const tr = t.getBoundingClientRect();
moveDrag(tr.top + tr.height / 2 - 4);
}
}, 520)
);
timeouts.push(window.setTimeout(endDrag, 860));
}
if (reduce) {
// 정지 상태 그대로 — 연출 없음
} 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);
if (placeholder.parentNode) placeholder.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.dd}>
<p className={styles.hint}>{HINT}</p>
<ul className={styles.list} ref={listRef}>
{ROW_TXT.map((txt, i) => (
<li key={i} className={styles.row}>
<span>{txt}</span>
</li>
))}
</ul>
<p className={styles.meta}>
<b className={styles.state} ref={stateRef} style={{ color }}>slot 1</b>
</p>
</div>
</main>
);
}09 자리 표시자 — 들어올린 행의 높이를 옮겨 담아 부풀리는 계산 — 측정 누적면적 25.853% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 드래그 앤 드롭 UI 리스트 — 목표 인덱스 계산을 얹은 9종
이 데모가 속한 글
드래그 앤 드롭 UI 리스트 — 목표 인덱스 계산을 얹은 9종포인터가 놓인 위치에서 목표 인덱스를 계산해 리스트·칸반·그리드가 자리를 바꾸고, 롱프레스·Alt 화살표·가장자리 자동 스크롤까지 — 드래그로 순서 바꾸는 리스트 9종, 코드는 비밀번호 zip으로 드립니다. 드래그 이 글의 데모 전부 보기


