06드래그로 순서 바꾸는 사진 그리드
사진 칸을 손가락이나 마우스로 눌러 옆 칸 쪽으로 끌면 두 칸의 자리가 서로 바뀌고, 놓는 순간 나머지 칸도 자연스럽게 자리를 정리합니다.
쓰임대표 사진을 직접 고르는 상품 이미지 관리 화면, 순서가 중요한 포트폴리오 대표작 정렬
pointerdown/move/uptransform 스왑키보드 화살표 이동

<main class="stage">
<ul class="rg-list is-demo" aria-label="드래그하거나 화살표 키로 사진 순서 바꾸기">
<li class="rg-tile" tabindex="0"><span class="rg-tile__img rg-tile__img--a"></span><span class="rg-tile__handle" aria-hidden="true">⠿</span></li>
<li class="rg-tile" tabindex="0"><span class="rg-tile__img rg-tile__img--b"></span><span class="rg-tile__handle" aria-hidden="true">⠿</span></li>
<li class="rg-tile" tabindex="0"><span class="rg-tile__img rg-tile__img--c"></span><span class="rg-tile__handle" aria-hidden="true">⠿</span></li>
</ul>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 스왑 스냅 시간
$easing: $ease-out;
$color: $subject-blue; // 핸들·포커스 색
@include stage($stage-paper);
.rg-list {
position: relative;
display: flex;
list-style: none;
margin: 0;
padding: 0;
gap: $sp-2;
}
.rg-tile {
position: relative;
width: 64px;
height: 64px;
border-radius: $r-sm;
outline: none;
cursor: grab;
touch-action: none;
box-shadow: $shadow-press;
transition: transform $duration $easing;
&.is-dragging { transition: none; z-index: 2; box-shadow: $shadow-raised; cursor: grabbing; }
}
.rg-tile:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.rg-tile__img { position: absolute; inset: 0; border-radius: inherit; }
.rg-tile__img--a { background: linear-gradient(155deg, $color 0%, $stage-ink 100%); }
.rg-tile__img--b { background: linear-gradient(205deg, $subject-cream 0%, $color 100%); }
.rg-tile__img--c { background: linear-gradient(115deg, $color 0%, $subject-cream 60%); }
.rg-tile__handle {
position: absolute;
right: 2px; bottom: 2px;
width: 16px; height: 16px;
display: grid;
place-items: center;
background: rgba(255, 255, 255, .88);
border-radius: $r-xs;
font-size: 9px;
line-height: 1;
color: $stage-ink;
}
.rg-list.is-demo .rg-tile:nth-child(1) { animation: swap-a $dur-loop $easing infinite; }
.rg-list.is-demo .rg-tile:nth-child(2) { animation: swap-b $dur-loop $easing infinite; }
@keyframes swap-a {
0%, 10% { transform: translateX(0); }
45%, 65% { transform: translateX(72px); }
95%, 100% { transform: translateX(0); }
}
@keyframes swap-b {
0%, 10% { transform: translateX(0); }
45%, 65% { transform: translateX(-72px); }
95%, 100% { transform: translateX(0); }
}
@media (prefers-reduced-motion: reduce) {
.rg-tile { transition: none; }
.rg-list.is-demo .rg-tile:nth-child(1),
.rg-list.is-demo .rg-tile:nth-child(2) { animation: 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);
}
.rg-list {
position: relative;
display: flex;
list-style: none;
margin: 0;
padding: 0;
gap: 8px;
}
.rg-tile {
position: relative;
width: 64px;
height: 64px;
border-radius: 8px;
outline: none;
cursor: grab;
touch-action: none;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms ease-out;
}
.rg-tile.is-dragging {
transition: none;
z-index: 2;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: grabbing;
}
.rg-tile:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.rg-tile__img {
position: absolute;
inset: 0;
border-radius: inherit;
}
.rg-tile__img--a {
background: linear-gradient(155deg, #2f6df6 0%, #17141a 100%);
}
.rg-tile__img--b {
background: linear-gradient(205deg, #fff7e6 0%, #2f6df6 100%);
}
.rg-tile__img--c {
background: linear-gradient(115deg, #2f6df6 0%, #fff7e6 60%);
}
.rg-tile__handle {
position: absolute;
right: 2px;
bottom: 2px;
width: 16px;
height: 16px;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.88);
border-radius: 4px;
font-size: 9px;
line-height: 1;
color: #17141a;
}
.rg-list.is-demo .rg-tile:nth-child(1) {
animation: swap-a 2s ease-out infinite;
}
.rg-list.is-demo .rg-tile:nth-child(2) {
animation: swap-b 2s ease-out infinite;
}
@keyframes swap-a {
0%, 10% {
transform: translateX(0);
}
45%, 65% {
transform: translateX(72px);
}
95%, 100% {
transform: translateX(0);
}
}
@keyframes swap-b {
0%, 10% {
transform: translateX(0);
}
45%, 65% {
transform: translateX(-72px);
}
95%, 100% {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.rg-tile {
transition: none;
}
.rg-list.is-demo .rg-tile:nth-child(1),
.rg-list.is-demo .rg-tile:nth-child(2) {
animation: none;
}
}(function () {
var list = document.querySelector('.rg-list');
Array.prototype.forEach.call(list.children, function (tile) {
var startX = 0, dx = 0, dragging = false;
tile.addEventListener('pointerdown', function (e) {
list.classList.remove('is-demo');
dragging = true; startX = e.clientX; tile.classList.add('is-dragging');
tile.setPointerCapture(e.pointerId);
});
tile.addEventListener('pointermove', function (e) {
if (!dragging) return;
dx = e.clientX - startX;
tile.style.transform = 'translateX(' + dx + 'px)';
});
function end() {
if (!dragging) return;
dragging = false; tile.classList.remove('is-dragging');
var target = dx > 30 ? tile.nextElementSibling : dx < -30 ? tile.previousElementSibling : null;
tile.style.transform = '';
if (target) list.insertBefore(dx > 0 ? target : tile, dx > 0 ? tile : target);
dx = 0;
}
tile.addEventListener('pointerup', end);
tile.addEventListener('pointercancel', end);
tile.addEventListener('keydown', function (e) {
list.classList.remove('is-demo');
var sib = e.key === 'ArrowRight' ? tile.nextElementSibling : e.key === 'ArrowLeft' ? tile.previousElementSibling : null;
if (!sib) return;
e.preventDefault();
if (e.key === 'ArrowRight') list.insertBefore(sib, tile); else list.insertBefore(tile, sib);
tile.focus({ preventScroll: true });
});
});
})();import { useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./DragReorderPhotoGrid.module.scss";
type ReactKeyboardEvent<T> = { key: string; preventDefault: () => void; currentTarget: T };
export type DragReorderPhotoGridProps = {
/** 스왑 스냅 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 핸들 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const TONES = ["a", "b", "c"] as const;
/** 사진 칸을 밀어 옆 칸과 자리를 바꾼다 — 손가락·마우스 드래그, 좌우 화살표 키 둘 다 된다 */
export default function DragReorderPhotoGrid({
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: DragReorderPhotoGridProps) {
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const [order, setOrder] = useState<number[]>([0, 1, 2]);
const dragState = useRef<{ pos: number; startX: number; dx: number } | null>(null);
const [dragging, setDragging] = useState<number | null>(null);
const [offset, setOffset] = useState(0);
function swap(pos: number, dir: 1 | -1) {
setOrder((prev) => {
const next = [...prev];
const target = pos + dir;
if (target < 0 || target >= next.length) return prev;
[next[pos], next[target]] = [next[target], next[pos]];
return next;
});
}
function onPointerDown(e: ReactPointerEvent<HTMLLIElement>, pos: number) {
(e.target as Element).setPointerCapture(e.pointerId);
dragState.current = { pos, startX: e.clientX, dx: 0 };
setDragging(pos);
}
function onPointerMove(e: ReactPointerEvent<HTMLLIElement>) {
if (!dragState.current) return;
dragState.current.dx = e.clientX - dragState.current.startX;
setOffset(dragState.current.dx);
}
function onPointerUp() {
if (!dragState.current) return;
const { pos, dx } = dragState.current;
if (dx > 30) swap(pos, 1);
else if (dx < -30) swap(pos, -1);
dragState.current = null;
setDragging(null);
setOffset(0);
}
function onKeyDown(e: ReactKeyboardEvent<HTMLLIElement>, pos: number) {
if (e.key === "ArrowRight") { e.preventDefault(); swap(pos, 1); }
else if (e.key === "ArrowLeft") { e.preventDefault(); swap(pos, -1); }
}
return (
<ul className={styles.list} style={vars} aria-label="Drag or use arrow keys to reorder photos">
{order.map((tileId, pos) => (
<li
key={tileId}
tabIndex={0}
className={`${styles.tile} ${dragging === pos ? styles.isDragging : ""}`}
style={dragging === pos ? { transform: `translateX(${offset}px)` } : undefined}
onPointerDown={(e: ReactPointerEvent<HTMLLIElement>) => onPointerDown(e, pos)}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={(e: ReactKeyboardEvent<HTMLLIElement>) => onKeyDown(e, pos)}
>
<span className={`${styles.img} ${styles["img-" + TONES[tileId]]}`} />
<span className={styles.handle} aria-hidden="true">⠿</span>
</li>
))}
</ul>
);
}06 드래그로 순서 바꾸는 사진 그리드 — 측정 누적면적 7.467% · 움직인 프레임 17/23GODRICH 원작 · zip 에 SCSS·React 포함 · 포트폴리오 갤러리 UI 9종 — 드래그 정렬부터 모프 전환까지
이 데모가 속한 글
포트폴리오 갤러리 UI 9종 — 드래그 정렬부터 모프 전환까지포트폴리오 갤러리는 사진이나 작업물을 격자로 늘어놓고, 마우스를 올리거나 클릭했을 때 확대·정렬·전환이 일어나는 화면입니다. 핀터레스트에 저장해 둔 그 포트폴리오 갤러리를 아홉 가지 방식으로 나눠 그대로 코드로 드립 이 글의 데모 전부 보기


