08ゴーストプレビュー — 移動速度を傾きに変える残像コピー
ポインターに追従する複製要素の位置をpointermoveごとに更新しつつ、直前フレームとのx移動量の差を角度に変換してtransform: rotateで少し傾け、停止や方向転換で角度が0へ戻るtransitionを重ねると慣性のように見えます。
使いどころカードドラッグ、ファイル移動
pointermovetransform: rotatetransition

<main class="stage">
<div class="gp">
<p class="gp__hint">フレーム間のx移動量を角度に変換し、残像が傾きます</p>
<ul class="gp__list" id="gpList">
<li class="gp__row" data-id="0"><span class="gp__txt">メッセージ</span></li>
<li class="gp__row" data-id="1"><span class="gp__txt">写真</span></li>
<li class="gp__row" data-id="2"><span class="gp__txt">メモ</span></li>
<li class="gp__row" data-id="3"><span class="gp__txt">音楽</span></li>
</ul>
<p class="gp__meta"><b class="gp__state" id="gpState">angle 0°</b></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: 220ms;
$easing: cubic-bezier(.2,.8,.2,1);
$color: $subject-blue-deep;
@include stage($stage-paper);
.gp {
width: 100%;
max-width: 340px;
display: flex;
flex-direction: column;
gap: $sp-2;
}
.gp__hint {
margin: 0;
padding: 0 $sp-1;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: $subject-ink;
}
.gp__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: $sp-1;
overflow-y: auto;
}
.gp__row {
--ty: 0px;
--rot: 0deg;
transform: translateY(var(--ty)) rotate(var(--rot));
transform-origin: center;
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;
}
.gp__row--drag {
cursor: grabbing;
box-shadow: $shadow-raised;
}
.gp__meta {
margin: 0;
padding: 0 $sp-1;
}
.gp__state {
font-weight: 700;
font-size: 12px;
color: $subject-ink;
}
@media (max-width: 340px) {
.gp { max-width: 260px; gap: $sp-1; }
.gp__hint { font-size: 10px; }
.gp__row { font-size: 11px; padding: $sp-1; }
.gp__list { max-height: 88px; }
}
@media (prefers-reduced-motion: reduce) {
.gp__row {
transition: none !important;
transform: translateY(var(--ty)) rotate(0deg) !important;
}
}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);
}
.gp {
width: 100%;
max-width: 340px;
display: flex;
flex-direction: column;
gap: 8px;
}
.gp__hint {
margin: 0;
padding: 0 4px;
font-weight: 400;
font-size: 11px;
line-height: 1.4;
color: #17141a;
}
.gp__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 4px;
overflow-y: auto;
}
.gp__row {
--ty: 0px;
--rot: 0deg;
transform: translateY(var(--ty)) rotate(var(--rot));
transform-origin: center;
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;
}
.gp__row--drag {
cursor: grabbing;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.gp__meta {
margin: 0;
padding: 0 4px;
}
.gp__state {
font-weight: 700;
font-size: 12px;
color: #17141a;
}
@media (max-width: 340px) {
.gp {
max-width: 260px;
gap: 4px;
}
.gp__hint {
font-size: 10px;
}
.gp__row {
font-size: 11px;
padding: 4px;
}
.gp__list {
max-height: 88px;
}
}
@media (prefers-reduced-motion: reduce) {
.gp__row {
transition: none !important;
transform: translateY(var(--ty)) rotate(0deg) !important;
}
}// 고스트 프리뷰(08) — pointermove 의 프레임 간 x 이동량(dx)을 기울기 목표값으로 바꾸고,
// 매 rAF 마다 현재 각도를 목표로 lerp 시켜 든 카드에 transform: rotate 로 남긴다.
// 이동을 멈추거나 놓으면 목표가 0으로 떨어져 흔들림이 자연히 가라앉는다.
(function () {
var list = document.getElementById('gpList');
var stateEl = document.getElementById('gpState');
if (!list || !stateEl) return;
var LERP = 0.25;
var K = 2.4; // dx(px/frame) → 각도(deg) 배율
var MAX_ANGLE = 18;
var drag = null; // { row, offsetInRow, lastX, angle, target, raf }
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);
}
function tick() {
if (!drag) return;
drag.angle += (drag.target - drag.angle) * LERP;
drag.row.style.setProperty('--rot', drag.angle.toFixed(2) + 'deg');
stateEl.textContent = 'angle ' + Math.round(drag.angle) + '°';
drag.target *= 0.82; // 매 프레임 목표를 줄여 이동이 멈추면 0으로 가라앉는다
drag.raf = requestAnimationFrame(tick);
}
function beginDrag(row, clientX, clientY) {
var r = row.getBoundingClientRect();
row.classList.add('gp__row--drag');
drag = { row: row, offsetInRow: clientY - r.top, lastX: clientX, angle: 0, target: 0, raf: null };
drag.raf = requestAnimationFrame(tick);
}
function moveDrag(clientX, clientY) {
if (!drag) return;
var listRect = list.getBoundingClientRect();
var ty = clientY - drag.offsetInRow - listRect.top - drag.row.offsetTop;
drag.row.style.setProperty('--ty', ty.toFixed(1) + 'px');
var dx = clientX - drag.lastX;
drag.lastX = clientX;
var t = dx * K;
if (t > MAX_ANGLE) t = MAX_ANGLE;
if (t < -MAX_ANGLE) t = -MAX_ANGLE;
drag.target = t;
reflow(drag.row, clientY);
}
function endDrag() {
if (!drag) return;
var row = drag.row;
if (drag.raf) cancelAnimationFrame(drag.raf);
drag = null;
row.classList.remove('gp__row--drag');
row.style.transition = 'transform 220ms cubic-bezier(.2,.8,.2,1)';
row.style.setProperty('--ty', '0px');
row.style.setProperty('--rot', '0deg');
setTimeout(function () { row.style.transition = ''; }, 240);
stateEl.textContent = 'angle 0°';
}
function onDown(e) {
var row = e.target.closest('.gp__row');
if (!row) return;
stop();
row.setPointerCapture(e.pointerId);
beginDrag(row, e.clientX, e.clientY);
}
function onMove(e) { moveDrag(e.clientX, 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[1];
var r = row.getBoundingClientRect();
var cx = r.left + r.width / 2, cy = r.top + r.height / 2;
beginDrag(row, cx, cy);
moveDrag(cx + 46, cy + 4);
setTimeout(function () { if (drag) moveDrag(cx - 40, cy + 34); }, 180);
setTimeout(function () { if (drag) moveDrag(cx + 30, cy + 60); }, 360);
setTimeout(function () { if (drag) moveDrag(cx, cy + 84); }, 540);
setTimeout(function () { endDrag(); }, 860);
}
if (reduce) {
// 정지 상태 그대로 — 연출 없음
} else {
loopOnce();
setInterval(function () { if (playing) loopOnce(); }, 1700);
}
})();// DragGhostPreview — vanilla 판(run/336/demo/08-drag-ghost-preview/index.html)
// 의 React 포트. pointermove 의 프레임 간 x 이동량(dx)을 기울기 목표값으로 바꾸고,
// 매 rAF 마다 현재 각도를 목표로 lerp 시켜 든 카드에 transform: rotate 로 남긴다.
// 이동을 멈추거나 놓으면 목표가 0으로 떨어져 흔들림이 자연히 가라앉는다.
import { useEffect, useRef } from "react";
import styles from "./DragGhostPreview.module.scss";
export type DragGhostPreviewProps = {
duration?: string;
easing?: string;
color?: string;
};
const HINT = "프레임 사이 x 이동량을 각도로 환산해 잔상이 기울어집니다";
const ROW_TXT = ["메시지", "사진", "메모", "음악"];
type Box<T> = { current: T };
type DragState = { row: HTMLElement; offsetInRow: number; lastX: number; angle: number; target: number; raf: number };
export default function DragGhostPreview({
duration = "220ms",
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#0a4fe8"
}: DragGhostPreviewProps) {
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 LERP = 0.25;
const K = 2.4; // dx(px/frame) → 각도(deg) 배율
const MAX_ANGLE = 18;
let drag: DragState | null = null;
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 tick(): void {
if (!drag) return;
drag.angle += (drag.target - drag.angle) * LERP;
drag.row.style.setProperty("--rot", drag.angle.toFixed(2) + "deg");
stateEl!.textContent = "angle " + Math.round(drag.angle) + "°";
drag.target *= 0.82; // 매 프레임 목표를 줄여 이동이 멈추면 0으로 가라앉는다
drag.raf = requestAnimationFrame(tick);
rafRef.current = drag.raf;
}
function beginDrag(row: HTMLElement, clientX: number, clientY: number): void {
const r = row.getBoundingClientRect();
row.classList.add(styles.rowDrag);
drag = { row, offsetInRow: clientY - r.top, lastX: clientX, angle: 0, target: 0, raf: 0 };
drag.raf = requestAnimationFrame(tick);
rafRef.current = drag.raf;
}
function moveDrag(clientX: number, clientY: number): void {
if (!drag) return;
const listRect = list!.getBoundingClientRect();
const ty = clientY - drag.offsetInRow - listRect.top - drag.row.offsetTop;
drag.row.style.setProperty("--ty", ty.toFixed(1) + "px");
const dx = clientX - drag.lastX;
drag.lastX = clientX;
let t = dx * K;
if (t > MAX_ANGLE) t = MAX_ANGLE;
if (t < -MAX_ANGLE) t = -MAX_ANGLE;
drag.target = t;
reflow(drag.row, clientY);
}
function endDrag(): void {
if (!drag) return;
const row = drag.row;
if (drag.raf) cancelAnimationFrame(drag.raf);
drag = null;
row.classList.remove(styles.rowDrag);
row.style.transition = "transform 220ms cubic-bezier(.2,.8,.2,1)";
row.style.setProperty("--ty", "0px");
row.style.setProperty("--rot", "0deg");
setTimeout(() => {
row.style.transition = "";
}, 240);
stateEl!.textContent = "angle 0°";
}
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.clientX, e.clientY);
}
function onMove(e: PointerEvent): void {
moveDrag(e.clientX, 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[1] as HTMLElement;
const r = row.getBoundingClientRect();
const cx = r.left + r.width / 2;
const cy = r.top + r.height / 2;
beginDrag(row, cx, cy);
moveDrag(cx + 46, cy + 4);
timeouts.push(
window.setTimeout(() => {
if (drag) moveDrag(cx - 40, cy + 34);
}, 180)
);
timeouts.push(
window.setTimeout(() => {
if (drag) moveDrag(cx + 30, cy + 60);
}, 360)
);
timeouts.push(
window.setTimeout(() => {
if (drag) moveDrag(cx, cy + 84);
}, 540)
);
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);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div className={styles.gp}>
<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 }}>angle 0°</b>
</p>
</div>
</main>
);
}08 ゴーストプレビュー — 移動速度を傾きに変える残像コピー — 計測 累積面積 25.037% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · ドラッグアンドドロップ 並び替えリスト — 挿入先インデックス計算を重ねた9種
このデモが属する記事
ドラッグアンドドロップ 並び替えリスト — 挿入先インデックス計算を重ねた9種ドラッグアンドドロップ 並び替えは、指を離すたびに座標を計算し直す一枚の層です。ポインター位置から挿入先インデックスを計算し、リスト・カンバン・グリッドが並び替わります。ロングプレス・Alt矢印・端での自動スクロールまで — ドラッグ並び替 この記事のデモをすべて見る


