07Table Whose Rows Physically Swap Places When Sorted
Clicking a column header doesn't just relist the values — each row animates from its old spot to its new sorted position along real coordinates, physically swapping places (a different demo from 257's multi-column sort and 362's row insert/delete transitions — this one isolates just the row-position movement from a single-column sort click).
Best forAdmin tables sortable by price or name
sortByupdateHeadersaria-sort

<main class="stage">
<div class="srt is-demo" id="srt">
<table class="srt__table" id="srtTable">
<thead>
<tr>
<th scope="col" class="srt__th" data-key="name" aria-sort="none">
<button type="button" class="srt__headerBtn">Item name</button>
</th>
<th scope="col" class="srt__th srt__th--num" data-key="qty" aria-sort="none">
<button type="button" class="srt__headerBtn">Quantity</button>
</th>
</tr>
</thead>
<tbody id="srtBody">
<tr data-id="1" data-name="Apple" data-qty="12"><td>Apple</td><td class="srt__num">12</td></tr>
<tr data-id="2" data-name="Banana" data-qty="27"><td>Banana</td><td class="srt__num">27</td></tr>
<tr data-id="3" data-name="Cherry" data-qty="8"><td>Cherry</td><td class="srt__num">8</td></tr>
<tr data-id="4" data-name="Jujube" data-qty="19"><td>Jujube</td><td class="srt__num">19</td></tr>
<tr data-id="5" data-name="Grape" data-qty="33"><td>Grape</td><td class="srt__num">33</td></tr>
</tbody>
</table>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 변수 3개
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue-light;
// 계산 겹: index.html 의 sortBy() 가 FLIP(First-Last-Invert-Play) 으로 행의 이전 위치와
// 새 위치 차이를 구해 그 차이만큼 transform 을 되돌렸다가 트랜지션으로 흘려보낸다.
@include stage($stage-ink);
.srt {
width: min(100%, 300px);
}
.srt__table {
width: 100%;
border-collapse: collapse;
border-radius: $r-card;
overflow: hidden;
background: rgba(255, 255, 255, .05);
}
.srt__th {
padding: 0;
text-align: left;
background: rgba(255, 255, 255, .1);
&--num {
text-align: right;
}
}
.srt__headerBtn {
box-sizing: border-box;
width: 100%;
border: 0;
background: transparent;
padding: $sp-2 $sp-3;
color: $stage-paper;
font-weight: 700;
font-size: 12px;
text-align: inherit;
cursor: pointer;
&:focus-visible {
outline: 2px solid $color;
outline-offset: -2px;
}
}
.srt__table tbody tr {
--reorder-ms: #{$duration};
transition: transform var(--reorder-ms) $easing;
}
.srt__table td {
box-sizing: border-box;
padding: $sp-2 $sp-3;
color: $stage-paper;
font-weight: 600;
font-size: 12px;
border-top: 1px solid rgba(255, 255, 255, .08);
}
.srt__num {
text-align: right;
color: $color;
}
@media (max-width: 340px) {
.srt__headerBtn { padding: $sp-1 $sp-2; font-size: 11px; }
.srt__table td { padding: $sp-1 $sp-2; font-size: 11px; }
}
// reduce: 실사용 경로 적용 — 행 이동 트랜지션만 없앤다. 정렬 계산·순서 변경은 그대로 동작(즉시 반영)
@media (prefers-reduced-motion: reduce) {
.srt__table tbody tr { transition: none; }
}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);
}
.srt {
width: min(100%, 300px);
}
.srt__table {
width: 100%;
border-collapse: collapse;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.05);
}
.srt__th {
padding: 0;
text-align: left;
background: rgba(255, 255, 255, 0.1);
}
.srt__th--num {
text-align: right;
}
.srt__headerBtn {
box-sizing: border-box;
width: 100%;
border: 0;
background: transparent;
padding: 8px 12px;
color: #ffffff;
font-weight: 700;
font-size: 12px;
text-align: inherit;
cursor: pointer;
}
.srt__headerBtn:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: -2px;
}
.srt__table tbody tr {
--reorder-ms: 300ms;
transition: transform var(--reorder-ms) cubic-bezier(0.2, 0.8, 0.2, 1);
}
.srt__table td {
box-sizing: border-box;
padding: 8px 12px;
color: #ffffff;
font-weight: 600;
font-size: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.srt__num {
text-align: right;
color: #5b8cf8;
}
@media (max-width: 340px) {
.srt__headerBtn {
padding: 4px 8px;
font-size: 11px;
}
.srt__table td {
padding: 4px 8px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.srt__table tbody tr {
transition: none;
}
}(function () {
var SORT_REORDER_MS = 250; // 변형 대상 — 정렬로 행이 이동하는 애니메이션 길이
var AUTO_STEP_MS = 900;
var AUTO_KEYS = ['name', 'qty'];
var root = document.getElementById('srt');
var table = document.getElementById('srtTable');
var tbody = document.getElementById('srtBody');
var headerCells = Array.prototype.slice.call(table.querySelectorAll('th[data-key]'));
table.style.setProperty('--reorder-ms', SORT_REORDER_MS + 'ms');
var currentKey = null;
var currentDir = 'asc';
function getRows() {
return Array.prototype.slice.call(tbody.querySelectorAll('tr'));
}
function comparator(key, dir) {
return function (a, b) {
var av = a.dataset[key];
var bv = b.dataset[key];
if (key === 'qty') { av = Number(av); bv = Number(bv); }
var cmp = av < bv ? -1 : (av > bv ? 1 : 0);
return dir === 'asc' ? cmp : -cmp;
};
}
function updateHeaders() {
headerCells.forEach(function (th) {
if (th.dataset.key === currentKey) {
th.setAttribute('aria-sort', currentDir === 'asc' ? 'ascending' : 'descending');
} else {
th.setAttribute('aria-sort', 'none');
}
});
}
// 계산 겹: FLIP — 정렬 전 각 행의 화면 위치(First)를 재고, DOM 순서를 실제로 바꾼 뒤(Last)
// 그 차이(Invert)만큼 transform 으로 되돌려 놓았다가 트랜지션으로 제자리로 흘려보낸다(Play).
function sortBy(key) {
var dir = (key === currentKey) ? (currentDir === 'asc' ? 'desc' : 'asc') : 'asc';
currentKey = key;
currentDir = dir;
var rows = getRows();
var first = {};
rows.forEach(function (row) { first[row.dataset.id] = row.getBoundingClientRect().top; });
rows.sort(comparator(key, dir));
rows.forEach(function (row) { tbody.appendChild(row); });
rows.forEach(function (row) {
var last = row.getBoundingClientRect().top;
var delta = first[row.dataset.id] - last;
if (delta) {
row.style.transition = 'none';
row.style.transform = 'translateY(' + delta + 'px)';
void row.offsetHeight;
row.style.transition = '';
row.style.transform = 'translateY(0)';
}
});
updateHeaders();
}
function human() {
if (root.classList.contains('is-demo')) {
root.classList.remove('is-demo');
if (timer) { window.clearInterval(timer); timer = null; }
}
}
headerCells.forEach(function (th) {
var btn = th.querySelector('button');
btn.addEventListener('click', function () {
human();
sortBy(th.dataset.key);
});
});
var timer = null;
var seqPos = 0;
function autoTick() {
sortBy(AUTO_KEYS[seqPos % AUTO_KEYS.length]);
seqPos += 1;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
timer = window.setInterval(autoTick, AUTO_STEP_MS);
} else {
// reduce: 실사용 경로 적용 — 자동 정렬 루프를 멈춘다. 헤더 클릭 정렬·FLIP 계산은 그대로 동작(트랜지션만 scss 에서 제거)
}
})();// SortableTable — vanilla 판(run/381/demo/07-sortable-table/index.html)의 React 포트.
// FLIP 기법: 재정렬 전 각 행의 위치(First)를 재고, DOM 순서를 바꾼 뒤 위치(Last)를 다시 재
// 그 차이(Invert)만큼 역이동시켰다가 트랜지션으로 0 으로 풀어(Play) 자연스럽게 미끄러지게 한다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./SortableTable.module.scss";
export type SortableTableProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type SortKey = "name" | "qty";
type SortDir = "asc" | "desc";
const ROWS: { id: number; name: string; qty: number }[] = [
{ id: 1, name: "사과", qty: 12 },
{ id: 2, name: "바나나", qty: 27 },
{ id: 3, name: "체리", qty: 8 },
{ id: 4, name: "대추", qty: 19 },
{ id: 5, name: "포도", qty: 33 },
];
export default function SortableTable({
duration = "300ms", // vanilla $duration: $dur-base
easing = "cubic-bezier(.2, .8, .2, 1)", // vanilla $easing: $ease-spring
color = "#5b8cf8", // vanilla $color: $subject-blue-light
}: SortableTableProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const table = root.querySelector<HTMLTableElement>('[data-role="table"]');
const tbody = root.querySelector<HTMLTableSectionElement>('[data-role="tbody"]');
if (!table || !tbody) return;
const headerCells = Array.prototype.slice.call(
table.querySelectorAll("th[data-key]")
) as HTMLTableCellElement[];
const SORT_REORDER_MS = 250; // 변형 대상 — 정렬로 행이 이동하는 애니메이션 길이
const AUTO_STEP_MS = 900;
const AUTO_KEYS: SortKey[] = ["name", "qty"];
table.style.setProperty("--reorder-ms", SORT_REORDER_MS + "ms");
let currentKey: SortKey | null = null;
let currentDir: SortDir = "asc";
function getRows(): HTMLTableRowElement[] {
return Array.prototype.slice.call(tbody!.querySelectorAll("tr"));
}
function comparator(key: SortKey, dir: SortDir) {
return (a: HTMLTableRowElement, b: HTMLTableRowElement): number => {
let av: string | number = a.dataset[key] as string;
let bv: string | number = b.dataset[key] as string;
if (key === "qty") {
av = Number(av);
bv = Number(bv);
}
const cmp = av < bv ? -1 : av > bv ? 1 : 0;
return dir === "asc" ? cmp : -cmp;
};
}
function updateHeaders(): void {
headerCells.forEach((th) => {
if (th.dataset.key === currentKey) {
th.setAttribute("aria-sort", currentDir === "asc" ? "ascending" : "descending");
} else {
th.setAttribute("aria-sort", "none");
}
});
}
function human(): void {
if (root!.classList.contains("is-demo")) {
root!.classList.remove("is-demo");
if (timer) {
window.clearInterval(timer);
timer = null;
}
}
}
function sortBy(key: SortKey): void {
const dir: SortDir = key === currentKey ? (currentDir === "asc" ? "desc" : "asc") : "asc";
currentKey = key;
currentDir = dir;
const rows = getRows();
const first: Record<string, number> = {};
rows.forEach((row) => {
first[row.dataset.id as string] = row.getBoundingClientRect().top;
});
rows.sort(comparator(key, dir));
rows.forEach((row) => tbody!.appendChild(row));
rows.forEach((row) => {
const last = row.getBoundingClientRect().top;
const delta = first[row.dataset.id as string] - last;
if (delta) {
row.style.transition = "none";
row.style.transform = "translateY(" + delta + "px)";
void row.offsetHeight; // 리플로우 강제 — Invert 값을 실제로 적용한 뒤 되돌려야 트랜지션이 재생된다
row.style.transition = "";
row.style.transform = "translateY(0)";
}
});
updateHeaders();
}
const clickHandlers: (() => void)[] = [];
const buttons: HTMLButtonElement[] = [];
headerCells.forEach((th) => {
const btn = th.querySelector<HTMLButtonElement>("button")!;
const onClick = () => {
human();
sortBy(th.dataset.key as SortKey);
};
btn.addEventListener("click", onClick);
buttons.push(btn);
clickHandlers.push(onClick);
});
let timer: number | null = null;
let seqPos = 0;
function autoTick(): void {
sortBy(AUTO_KEYS[seqPos % AUTO_KEYS.length]);
seqPos += 1;
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
timer = window.setInterval(autoTick, AUTO_STEP_MS);
}
// reduce: 실사용 경로 적용 — 자동 정렬 루프를 멈춘다. 헤더 클릭 정렬·FLIP 계산은 그대로 동작(트랜지션만 scss 에서 제거)
return () => {
buttons.forEach((btn, i) => {
btn.removeEventListener("click", clickHandlers[i]);
});
if (timer) {
window.clearInterval(timer);
}
};
}, []);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
return (
<main className={styles.stage}>
<div className={`${styles.srt} is-demo`} ref={rootRef} style={vars}>
<table className={styles.srt__table} data-role="table">
<thead>
<tr>
<th scope="col" className={styles.srt__th} data-key="name" aria-sort="none">
<button type="button" className={styles.srt__headerBtn}>
품목명
</button>
</th>
<th
scope="col"
className={`${styles.srt__th} ${styles["srt__th--num"]}`}
data-key="qty"
aria-sort="none"
>
<button type="button" className={styles.srt__headerBtn}>
수량
</button>
</th>
</tr>
</thead>
<tbody data-role="tbody">
{ROWS.map((row) => (
<tr key={row.id} data-id={row.id} data-name={row.name} data-qty={row.qty}>
<td>{row.name}</td>
<td className={styles.srt__num}>{row.qty}</td>
</tr>
))}
</tbody>
</table>
</div>
</main>
);
}07 Table Whose Rows Physically Swap Places When Sorted — Measured area 2.227% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · Infinite Scroll Implementation — 9 List UI Basics
The post this demo lives in
Infinite Scroll Implementation — 9 List UI BasicsAmong nine infinite scroll implementation ideas, one slides in new rows with no spinner, one swaps row order on sort cli See all demos in this post
If you like this effect - similar demos
Lists & tables01 Priority sort that holds two columns at once9 Data Table UI Patterns — Drag, Resize, Select
Lists & tables04 Drag-handle reorder9 Swipe to Delete List UI Patterns (Copy-Paste)
Lists & tables01 Leaderboard Rows That FLIP-Swap Rank PositionsLeaderboard UI Design: 9 Rank-Change List Patterns