03머리를 들어 옮기는 열 순서 바꾸기
헤더를 잡으면 그 열 전체가 살짝 떠오르며 그림자가 생기고, 옮겨 갈 자리에는 빈 홈이 스르르 벌어져 손을 놓기 전에 결과가 먼저 보입니다.
쓰임보고 싶은 열을 왼쪽으로 몰아 두고 쓰는 대시보드 표
pointerdowntranslateXbox-shadow

<main class="stage">
<div class="rd is-demo" id="rd">
<div class="rd__slot" aria-hidden="true"></div>
<div class="rd__cols" id="rd-cols" role="grid" aria-label="회원 표">
<div class="rd__col" role="columnheader">
<span class="rd__h"><span class="rd__grab" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2"/><path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg></span>이름</span>
<span class="rd__c">하늘</span><span class="rd__c">가온</span><span class="rd__c">노을</span>
</div>
<div class="rd__col" role="columnheader">
<span class="rd__h"><span class="rd__grab" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2"/><path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg></span>등급</span>
<span class="rd__c">금</span><span class="rd__c">은</span><span class="rd__c">동</span>
</div>
<div class="rd__col" role="columnheader">
<span class="rd__h"><span class="rd__grab" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2"/><path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg></span>적립</span>
<span class="rd__c">820</span><span class="rd__c">410</span><span class="rd__c">175</span>
</div>
</div>
</div>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
$row-h: 26px;
$col-w: 96px;
$panel: color.mix($subject-ink, $stage-orange, 84%);
$lifted: color.mix($subject-ink, $stage-orange, 66%); // 집어 든 열은 바탕을 한 단계 밝혀 띄운다
$line: rgba(255, 247, 230, .16);
.rd {
position: relative;
box-sizing: border-box;
padding: $sp-1;
border-radius: $r-card;
background: $panel;
color: $color;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// 놓을 자리 — 열이 비켜난 동안만 보이는 빈 홈
.rd__slot {
position: absolute;
left: 4px; top: 4px;
width: $col-w;
height: calc(100% - 8px);
border: 2px dashed rgba(255, 247, 230, .42);
border-radius: $r-sm;
opacity: 0;
}
.rd__cols {
position: relative;
display: flex;
}
.rd__col {
flex: 0 0 auto;
width: $col-w;
display: flex;
flex-direction: column;
border-radius: $r-sm;
background: $panel;
transition: transform $duration $easing;
}
.rd__col.is-lift {
z-index: 2;
background: $lifted;
box-shadow: $shadow-raised;
transition: none;
}
.rd__h {
display: flex;
align-items: center;
gap: $sp-1;
height: $row-h;
padding: 0 $sp-2;
border-bottom: 2px solid $line;
font-size: 11px;
line-height: $row-h;
font-weight: 700;
cursor: grab;
touch-action: none;
user-select: none;
}
.rd__col.is-lift .rd__h { cursor: grabbing; }
.rd__grab {
display: inline-flex;
width: 11px;
height: 11px;
opacity: .55;
}
.rd__c {
height: $row-h;
padding: 0 $sp-2;
font-size: 12px;
line-height: $row-h;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
}
.rd__c + .rd__c { border-top: 1px solid rgba(255, 247, 230, .09); }
// ── 자동 루프 (2s) ───────────────────────────────────
// 10% 에 셋째 열이 떠오르고(그림자 컷) 10~38% 에 왼쪽 끝으로 옮겨 간다. 나머지 두 열은
// 같은 구간에 한 칸씩 오른쪽으로 비킨다 — 세 열의 이동 거리를 하나의 열 폭으로 묶어 둔다.
// 58~84% 에 되돌아오고 84% 뒤에는 아무것도 움직이지 않는다(복귀는 90% 안).
.rd.is-demo .rd__col {
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rd.is-demo .rd__col:nth-child(1),
.rd.is-demo .rd__col:nth-child(2) { animation-name: rd-aside; }
.rd.is-demo .rd__col:nth-child(3) { animation-name: rd-carry; }
.rd.is-demo .rd__col:nth-child(3) {
z-index: 2;
}
.rd.is-demo .rd__slot {
animation-name: rd-slot;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rd-aside {
0% { transform: translateX(0); }
10% { transform: translateX(0); }
38% { transform: translateX($col-w); }
58% { transform: translateX($col-w); }
84% { transform: translateX(0); }
100% { transform: translateX(0); }
}
@keyframes rd-carry {
0% { transform: translateX(0) translateY(0); box-shadow: none; background: $panel; }
10% { transform: translateX(0) translateY(-16px); box-shadow: $shadow-raised; background: $lifted; }
38% { transform: translateX($col-w * -2) translateY(-16px); box-shadow: $shadow-raised; background: $lifted; }
58% { transform: translateX($col-w * -2) translateY(-16px); box-shadow: $shadow-raised; background: $lifted; }
84% { transform: translateX(0) translateY(0); box-shadow: none; background: $panel; }
100% { transform: translateX(0) translateY(0); box-shadow: none; background: $panel; }
}
@keyframes rd-slot {
0% { opacity: 0; }
10% { opacity: 1; }
58% { opacity: 0; }
100% { opacity: 0; }
}
// 320×200 — 세 열이 288px 이라 여백만 줄이고 열 폭은 그대로 둔다
@media (max-width: 340px) {
.rd { padding: 0; }
.rd__slot { left: 0; top: 0; height: 100%; }
.rd__h, .rd__c { padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.rd.is-demo .rd__col,
.rd.is-demo .rd__slot { animation: none !important; }
.rd.is-demo .rd__col:nth-child(3) { box-shadow: $shadow-raised; }
.rd__col { transition: none; }
}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);
}
.rd {
position: relative;
box-sizing: border-box;
padding: 4px;
border-radius: 16px;
background: rgb(23.5764705882%, 11.4196078431%, 10.5098039216%);
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.rd__slot {
position: absolute;
left: 4px;
top: 4px;
width: 96px;
height: calc(100% - 8px);
border: 2px dashed rgba(255, 247, 230, 0.42);
border-radius: 8px;
opacity: 0;
}
.rd__cols {
position: relative;
display: flex;
}
.rd__col {
flex: 0 0 auto;
width: 96px;
display: flex;
flex-direction: column;
border-radius: 8px;
background: rgb(23.5764705882%, 11.4196078431%, 10.5098039216%);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rd__col.is-lift {
z-index: 2;
background: rgb(39.9529411765%, 15.4431372549%, 10.862745098%);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transition: none;
}
.rd__h {
display: flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 8px;
border-bottom: 2px solid rgba(255, 247, 230, 0.16);
font-size: 11px;
line-height: 26px;
font-weight: 700;
cursor: grab;
touch-action: none;
user-select: none;
}
.rd__col.is-lift .rd__h {
cursor: grabbing;
}
.rd__grab {
display: inline-flex;
width: 11px;
height: 11px;
opacity: 0.55;
}
.rd__c {
height: 26px;
padding: 0 8px;
font-size: 12px;
line-height: 26px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
}
.rd__c + .rd__c {
border-top: 1px solid rgba(255, 247, 230, 0.09);
}
.rd.is-demo .rd__col {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rd.is-demo .rd__col:nth-child(1),
.rd.is-demo .rd__col:nth-child(2) {
animation-name: rd-aside;
}
.rd.is-demo .rd__col:nth-child(3) {
animation-name: rd-carry;
}
.rd.is-demo .rd__col:nth-child(3) {
z-index: 2;
}
.rd.is-demo .rd__slot {
animation-name: rd-slot;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rd-aside {
0% {
transform: translateX(0);
}
10% {
transform: translateX(0);
}
38% {
transform: translateX(96px);
}
58% {
transform: translateX(96px);
}
84% {
transform: translateX(0);
}
100% {
transform: translateX(0);
}
}
@keyframes rd-carry {
0% {
transform: translateX(0) translateY(0);
box-shadow: none;
background: rgb(23.5764705882%, 11.4196078431%, 10.5098039216%);
}
10% {
transform: translateX(0) translateY(-16px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(39.9529411765%, 15.4431372549%, 10.862745098%);
}
38% {
transform: translateX(-192px) translateY(-16px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(39.9529411765%, 15.4431372549%, 10.862745098%);
}
58% {
transform: translateX(-192px) translateY(-16px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(39.9529411765%, 15.4431372549%, 10.862745098%);
}
84% {
transform: translateX(0) translateY(0);
box-shadow: none;
background: rgb(23.5764705882%, 11.4196078431%, 10.5098039216%);
}
100% {
transform: translateX(0) translateY(0);
box-shadow: none;
background: rgb(23.5764705882%, 11.4196078431%, 10.5098039216%);
}
}
@keyframes rd-slot {
0% {
opacity: 0;
}
10% {
opacity: 1;
}
58% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.rd {
padding: 0;
}
.rd__slot {
left: 0;
top: 0;
height: 100%;
}
.rd__h, .rd__c {
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.rd.is-demo .rd__col,
.rd.is-demo .rd__slot {
animation: none !important;
}
.rd.is-demo .rd__col:nth-child(3) {
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.rd__col {
transition: none;
}
}// 실제 동작: 헤더를 잡으면 그 열이 통째로 떠오르고, 놓을 자리의 열들이 자리를 비켜 준다.
var rd = document.getElementById('rd');
var rdCols = document.getElementById('rd-cols');
var rdW = 96, rdFrom = 0, rdAt = 0, rdTo = 0, rdEl = null;
function rdList() { return [].slice.call(rdCols.children); }
function rdMove(e) {
var dx = e.clientX - rdAt;
rdEl.style.transform = 'translateX(' + dx + 'px)';
rdTo = Math.max(0, Math.min(rdList().length - 1, rdFrom + Math.round(dx / rdW)));
rdList().forEach(function (el, i) {
if (el === rdEl) return;
var shift = 0;
if (rdTo > rdFrom && i > rdFrom && i <= rdTo) shift = -rdW;
if (rdTo < rdFrom && i >= rdTo && i < rdFrom) shift = rdW;
el.style.transform = 'translateX(' + shift + 'px)';
});
}
function rdUp() {
var list = rdList();
list.forEach(function (el) { el.style.transition = 'none'; el.style.transform = ''; });
var after = list[rdTo > rdFrom ? rdTo + 1 : rdTo];
rdCols.insertBefore(rdEl, after || null);
rd.classList.remove('is-drag');
rdEl.classList.remove('is-lift');
requestAnimationFrame(function () { list.forEach(function (el) { el.style.transition = ''; }); });
window.removeEventListener('pointermove', rdMove);
window.removeEventListener('pointerup', rdUp);
rdEl = null;
}
rdList().forEach(function (col) {
col.querySelector('.rd__h').addEventListener('pointerdown', function (e) {
rd.classList.remove('is-demo');
rd.classList.add('is-drag');
rdEl = col; rdEl.classList.add('is-lift');
rdW = col.getBoundingClientRect().width;
rdFrom = rdList().indexOf(col); rdTo = rdFrom; rdAt = e.clientX;
window.addEventListener('pointermove', rdMove);
window.addEventListener('pointerup', rdUp);
});
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ColumnReorderDrag.module.scss";
export type ReorderCol = { key: string; head: string; cells: string[] };
export type ColumnReorderDragProps = {
columns?: ReorderCol[];
tableLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type PtrEvt = { clientX: number };
const DEFAULT_COLS: ReorderCol[] = [
{ key: "name", head: "Name", cells: ["Sky", "Gaon", "Sunset"] },
{ key: "tier", head: "Tier", cells: ["Gold", "Silver", "Bronze"] },
{ key: "points", head: "Points", cells: ["820", "410", "175"] },
];
function Grab() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2" />
<path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12" />
</g>
</svg>
);
}
function moveItem(list: ReorderCol[], from: number, to: number): ReorderCol[] {
const next = list.slice();
const it = next.splice(from, 1)[0];
next.splice(to, 0, it);
return next;
}
export default function ColumnReorderDrag({
columns = DEFAULT_COLS,
tableLabel = "Member table",
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: ColumnReorderDragProps) {
const [cols, setCols] = useState<ReorderCol[]>(columns);
const [lift, setLift] = useState(-1);
const [target, setTarget] = useState(-1);
const [dx, setDx] = useState(0);
// 놓는 순간 한 프레임만 전환을 끈다 — 바닐라의 transition:none → rAF 복구와 같은 뜻
const [settle, setSettle] = useState(false);
const [live, setLive] = useState(demo);
// ambient useRef 는 항상 T | null 을 돌려준다 — 지역 별칭으로 한 번만 좁혀 쓴다
const dragRef = useRef<{ from: number; to: number; at: number; w: number }>({ from: 0, to: 0, at: 0, w: 96 });
const drag = dragRef.current || { from: 0, to: 0, at: 0, w: 96 };
const elsRef = useRef<(HTMLElement | null)[]>([]);
const els: (HTMLElement | null)[] = elsRef.current || [];
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
if (lift < 0) return;
function move(e: PtrEvt) {
const d = e.clientX - drag.at;
setDx(d);
const t = Math.max(0, Math.min(cols.length - 1, drag.from + Math.round(d / drag.w)));
drag.to = t;
setTarget(t);
}
function up() {
setSettle(true);
setCols(moveItem(cols, drag.from, drag.to));
setLift(-1);
setTarget(-1);
setDx(0);
}
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
return () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
}, [lift, cols]);
useEffect(() => {
if (!settle) return;
const id = requestAnimationFrame(() => setSettle(false));
return () => cancelAnimationFrame(id);
}, [settle]);
function onGrab(i: number, e: PtrEvt) {
setLive(false);
const el = els[i];
drag.w = el ? el.getBoundingClientRect().width : 96;
drag.from = i;
drag.to = i;
drag.at = e.clientX;
setLift(i);
setTarget(i);
setDx(0);
}
function styleOf(i: number): CSSProperties | undefined {
if (settle) return { transition: "none" };
if (lift < 0) return undefined;
if (i === lift) return { transform: `translateX(${dx}px)` };
let shift = 0;
if (target > lift && i > lift && i <= target) shift = -drag.w;
if (target < lift && i >= target && i < lift) shift = drag.w;
return { transform: `translateX(${shift}px)` };
}
return (
<div className={`${styles.root} ${live ? styles.isDemo : ""}`} style={vars}>
<div className={styles.slot} aria-hidden="true" />
<div className={styles.cols} role="grid" aria-label={tableLabel}>
{cols.map((col, i) => (
<div
key={col.key}
className={`${styles.col} ${lift === i ? styles.isLift : ""}`}
style={styleOf(i)}
role="columnheader"
ref={(el: HTMLElement | null) => { els[i] = el; }}
>
<span className={styles.h} onPointerDown={(e: PtrEvt) => onGrab(i, e)}>
<span className={styles.grab} aria-hidden="true"><Grab /></span>
{col.head}
</span>
{col.cells.map((v) => (
<span className={styles.c} key={v}>{v}</span>
))}
</div>
))}
</div>
</div>
);
}03 머리를 들어 옮기는 열 순서 바꾸기 — 측정 누적면적 24.881% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 데이터 테이블 UI 9종 — 열을 끌어 넓히고 셀을 골라 복사하는 표
이 데모가 속한 글
데이터 테이블 UI 9종 — 열을 끌어 넓히고 셀을 골라 복사하는 표데이터 테이블 ui는 줄과 칸으로 늘어놓은 값을 사람이 직접 만져서 다시 배열하고 좁히고 꺼내 가는 화면입니다. 아홉 개 전부 시늉이 아니라 실제로 값을 다시 정렬하고 문자열을 비교하며, 합계는 볼 때마다 그 자리에 이 글의 데모 전부 보기


