03정렬 가능한 미니 테이블
열 제목을 누르면 화살표 방향이 바뀌며 행 순서가 실제로 다시 매겨지고, 상태 칸은 색이 다른 알약 배지로 구분됩니다.
쓰임주문·회원 목록처럼 표 하나만 필요한 화면, 완성된 대시보드 없이
clickaria-sortorder

<main class="stage">
<div class="tb-wrap is-demo">
<span class="tb__scan" aria-hidden="true"></span>
<table class="tb">
<thead>
<tr>
<th><button class="tb__sort" type="button" data-key="name" aria-sort="none">이름 <span class="tb__arrow" aria-hidden="true"></span></button></th>
<th><button class="tb__sort" type="button" data-key="amount" aria-sort="none">금액 <span class="tb__arrow" aria-hidden="true"></span></button></th>
<th>상태</th>
</tr>
</thead>
<tbody class="tb__body">
<tr data-name="김도윤" data-amount="128000"><td>김도윤</td><td>₩128,000</td><td><span class="tb__pill tb__pill--ok">완료</span></td></tr>
<tr data-name="이서준" data-amount="54000"><td>이서준</td><td>₩54,000</td><td><span class="tb__pill tb__pill--wait">대기</span></td></tr>
<tr data-name="박하은" data-amount="212000"><td>박하은</td><td>₩212,000</td><td><span class="tb__pill tb__pill--ok">완료</span></td></tr>
<tr data-name="정유나" data-amount="98000"><td>정유나</td><td>₩98,000</td><td><span class="tb__pill tb__pill--err">실패</span></td></tr>
</tbody>
</table>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-paper);
.tb-wrap {
position: relative;
box-sizing: border-box;
width: min(420px, 92vw);
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
isolation: isolate;
}
.tb__scan {
position: absolute;
left: 0; right: 0; top: -30px;
height: 30px;
background: linear-gradient(180deg, rgba(47, 109, 246, 0), rgba(47, 109, 246, .16));
pointer-events: none;
z-index: 1;
opacity: 0;
}
.tb {
box-sizing: border-box;
width: 100%;
border-collapse: collapse;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
background: #fff;
}
.tb th, .tb td {
box-sizing: border-box;
text-align: left;
padding: $sp-2 $sp-3;
}
.tb thead th {
font-size: 10px; font-weight: 700; color: rgba(23, 20, 26, .72);
border-bottom: 1px solid rgba(23, 20, 26, .08);
}
.tb__sort {
border: 0; background: transparent; cursor: pointer; padding: 0;
font: inherit; font-weight: 700; color: inherit;
display: inline-flex; align-items: center; gap: 4px;
}
.tb__arrow {
width: 0; height: 0;
border-left: 4px solid transparent; border-right: 4px solid transparent;
border-top: 5px solid rgba(23, 20, 26, .35);
transition: transform $duration $easing;
}
.tb__sort[aria-sort="ascending"] .tb__arrow { transform: rotate(180deg); }
.tb__sort[aria-sort="descending"] .tb__arrow { transform: rotate(0deg); }
.tb__body tr { border-bottom: 1px solid rgba(23, 20, 26, .06); }
.tb__body tr:last-child { border-bottom: 0; }
.tb__pill {
display: inline-block;
padding: $sp-1 $sp-2;
border-radius: $r-pill;
font-weight: 700; font-size: 10px;
}
.tb__pill--ok { background: #e3f3e6; color: #47905f; }
.tb__pill--wait { background: #fff2d6; color: rgba(23, 20, 26, .72); }
.tb__pill--err { background: $error-soft; color: rgba(23, 20, 26, .72); }
// ── 자동 루프: 하이라이트 띠가 표 위에서 아래로 계속 훑고 지나간다 ──────────────
.tb-wrap.is-demo .tb__scan { animation: tb-scan 2s linear infinite; }
@keyframes tb-scan {
0% { transform: translateY(0); opacity: 1; }
92% { opacity: 1; }
100% { transform: translateY(200px); opacity: 0; }
}
@media (max-width: 340px) {
.tb { font-size: 11px; }
.tb th, .tb td { padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.tb-wrap.is-demo .tb__scan { animation: none !important; }
.tb__arrow { transition: 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);
}
.tb-wrap {
position: relative;
box-sizing: border-box;
width: min(420px, 92vw);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
isolation: isolate;
}
.tb__scan {
position: absolute;
left: 0;
right: 0;
top: -30px;
height: 30px;
background: linear-gradient(180deg, rgba(47, 109, 246, 0), rgba(47, 109, 246, 0.16));
pointer-events: none;
z-index: 1;
opacity: 0;
}
.tb {
box-sizing: border-box;
width: 100%;
border-collapse: collapse;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
background: #fff;
}
.tb th, .tb td {
box-sizing: border-box;
text-align: left;
padding: 8px 12px;
}
.tb thead th {
font-size: 10px;
font-weight: 700;
color: rgba(23, 20, 26, 0.72);
border-bottom: 1px solid rgba(23, 20, 26, 0.08);
}
.tb__sort {
border: 0;
background: transparent;
cursor: pointer;
padding: 0;
font: inherit;
font-weight: 700;
color: inherit;
display: inline-flex;
align-items: center;
gap: 4px;
}
.tb__arrow {
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid rgba(23, 20, 26, 0.35);
transition: transform 220ms ease-out;
}
.tb__sort[aria-sort=ascending] .tb__arrow {
transform: rotate(180deg);
}
.tb__sort[aria-sort=descending] .tb__arrow {
transform: rotate(0deg);
}
.tb__body tr {
border-bottom: 1px solid rgba(23, 20, 26, 0.06);
}
.tb__body tr:last-child {
border-bottom: 0;
}
.tb__pill {
display: inline-block;
padding: 4px 8px;
border-radius: 999px;
font-weight: 700;
font-size: 10px;
}
.tb__pill--ok {
background: #e3f3e6;
color: #47905f;
}
.tb__pill--wait {
background: #fff2d6;
color: rgba(23, 20, 26, 0.72);
}
.tb__pill--err {
background: #ffe0d8;
color: rgba(23, 20, 26, 0.72);
}
.tb-wrap.is-demo .tb__scan {
animation: tb-scan 2s linear infinite;
}
@keyframes tb-scan {
0% {
transform: translateY(0);
opacity: 1;
}
92% {
opacity: 1;
}
100% {
transform: translateY(200px);
opacity: 0;
}
}
@media (max-width: 340px) {
.tb {
font-size: 11px;
}
.tb th, .tb td {
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.tb-wrap.is-demo .tb__scan {
animation: none !important;
}
.tb__arrow {
transition: none;
}
}// 실제 동작: 열 제목을 누르면 행이 실제로 그 값 기준으로 다시 정렬된다(asc/desc 토글).
var wrap = document.querySelector('.tb-wrap');
var tbody = document.querySelector('.tb__body');
var buttons = document.querySelectorAll('.tb__sort');
buttons.forEach(function (btn) {
var dir = 1;
btn.addEventListener('click', function () {
wrap.classList.remove('is-demo');
buttons.forEach(function (o) { if (o !== btn) o.setAttribute('aria-sort', 'none'); });
dir = btn.getAttribute('aria-sort') === 'ascending' ? -1 : 1;
btn.setAttribute('aria-sort', dir === 1 ? 'ascending' : 'descending');
var key = btn.dataset.key;
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
rows.sort(function (a, b) {
var av = a.dataset[key], bv = b.dataset[key];
if (key === 'amount') { av = Number(av); bv = Number(bv); return (av - bv) * dir; }
return av.localeCompare(bv) * dir;
});
rows.forEach(function (r) { tbody.appendChild(r); });
});
});import { useMemo, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SortableTable.module.scss";
export type SortableTableProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Key = "name" | "amount";
type Status = "ok" | "wait" | "err";
type Row = { name: string; amount: number; status: Status; statusLabel: string };
const ROWS: Row[] = [
{ name: "김도윤", amount: 128000, status: "ok", statusLabel: "완료" },
{ name: "이서준", amount: 54000, status: "wait", statusLabel: "대기" },
{ name: "박하은", amount: 212000, status: "ok", statusLabel: "완료" },
{ name: "정유나", amount: 98000, status: "err", statusLabel: "실패" },
];
export default function SortableTable({ duration = 220, easing = "ease-out", color = "#2f6df6" }: SortableTableProps) {
const [sort, setSort] = useState<{ key: Key; dir: 1 | -1 } | null>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const rows = useMemo(() => {
if (!sort) return ROWS;
const copy = [...ROWS];
copy.sort((a, b) => {
if (sort.key === "amount") return (a.amount - b.amount) * sort.dir;
return a.name.localeCompare(b.name) * sort.dir;
});
return copy;
}, [sort]);
function toggle(key: Key) {
setSort((s) => ({ key, dir: s && s.key === key ? (s.dir === 1 ? -1 : 1) : 1 }));
}
function ariaSort(key: Key): "ascending" | "descending" | "none" {
if (!sort || sort.key !== key) return "none";
return sort.dir === 1 ? "ascending" : "descending";
}
return (
<table className={styles.tb} style={vars}>
<thead>
<tr>
<th>
<button type="button" aria-sort={ariaSort("name")} onClick={() => toggle("name")}>
이름 <span className={styles.arrow} />
</button>
</th>
<th>
<button type="button" aria-sort={ariaSort("amount")} onClick={() => toggle("amount")}>
금액 <span className={styles.arrow} />
</button>
</th>
<th>상태</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.name}>
<td>{r.name}</td>
<td>₩{r.amount.toLocaleString()}</td>
<td><span className={`${styles.pill} ${styles[r.status]}`}>{r.statusLabel}</span></td>
</tr>
))}
</tbody>
</table>
);
}03 정렬 가능한 미니 테이블 — 측정 누적면적 54.986% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 대시보드 사이드바·위젯 UI 9종 — 완성 페이지 말고 이 조각 하나만
이 데모가 속한 글
대시보드 사이드바·위젯 UI 9종 — 완성 페이지 말고 이 조각 하나만대시보드 사이드바 하나, 통계 카드 한 줄, 알림 드롭다운 하나처럼 완성된 관리자 페이지 전체가 아니라 화면 한 조각만 그대로 가져다 붙이는 대시보드 위젯 아홉 개를 모았습니다. 아홉 개 중 하나는 숫자가 실제로 세 이 글의 데모 전부 보기


