01Priority sort that holds two columns at once
Pressing a header flips its triangle and the rows glide into their new places, and adding a second header as a tiebreaker pins a small 1 or 2 badge on each so the table itself tells you which column is read first.
Best forLeaderboards where tied scores need a date to break them
aria-sorttranslateYrotate

<main class="stage">
<div class="ms is-demo" id="ms" role="grid" aria-label="Team ranking">
<div class="ms__head" role="row">
<span class="ms__h ms__h--text">Team</span>
<button class="ms__h" type="button" id="ms-score" data-key="score" aria-sort="descending">
<span class="ms__label">Score</span>
<span class="ms__ord" aria-hidden="true">1</span>
<span class="ms__caret" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M208 160H48l80-80Z" opacity=".2"/><path d="m213.66 154.34l-80-80a8 8 0 0 0-11.32 0l-80 80A8 8 0 0 0 48 168h160a8 8 0 0 0 5.66-13.66M67.31 152L128 91.31L188.69 152Z"/></g></svg></span>
</button>
<button class="ms__h" type="button" id="ms-date" data-key="date" aria-sort="none">
<span class="ms__label">Date</span>
<span class="ms__ord" aria-hidden="true">2</span>
<span class="ms__caret" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M208 160H48l80-80Z" opacity=".2"/><path d="m213.66 154.34l-80-80a8 8 0 0 0-11.32 0l-80 80A8 8 0 0 0 48 168h160a8 8 0 0 0 5.66-13.66M67.31 152L128 91.31L188.69 152Z"/></g></svg></span>
</button>
</div>
<div class="ms__body" id="ms-body" role="rowgroup"></div>
</div>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
$row-h: 26px;
$line: color.mix($subject-cream, $stage-ink, 14%);
$row-bg: color.mix($subject-cream, $stage-ink, 7%);
.ms {
box-sizing: border-box;
width: min(300px, 92vw);
border: 1px solid $line;
border-radius: $r-card;
background: $stage-ink;
overflow: hidden;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: $row-h;
}
// ── 헤더 ─────────────────────────────────────────────
.ms__head {
display: grid;
grid-template-columns: 1fr 72px 72px;
height: $row-h;
border-bottom: 1px solid $line;
background: $row-bg;
}
.ms__h {
display: inline-flex;
align-items: center;
gap: $sp-1;
margin: 0;
padding: 0 $sp-2;
border: 0;
background: none;
color: rgba(255, 255, 255, .58);
font-family: inherit;
font-size: 11px;
line-height: $row-h;
font-weight: 700;
text-align: left;
cursor: pointer;
}
.ms__h--text { cursor: default; }
.ms__h.is-on { color: $subject-blue-light; }
.ms__h:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
.ms__caret {
display: inline-flex;
width: 10px;
height: 10px;
opacity: .28;
transform: rotate(180deg);
transition: transform $duration $easing, opacity $duration $easing;
}
.ms__h.is-on .ms__caret { opacity: 1; }
.ms__h[aria-sort="ascending"] .ms__caret { transform: rotate(0deg); }
// 순번 배지 — 두 열을 함께 잡았을 때만 나온다
.ms__ord {
display: inline-flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
border-radius: $r-pill;
background: $color;
color: $subject-cream;
font-size: 9px;
line-height: 12px;
font-weight: 700;
}
.ms__ord[hidden] { display: none; }
// ── 행 ───────────────────────────────────────────────
.ms__row {
display: grid;
grid-template-columns: 1fr 72px 72px;
height: $row-h;
background: $stage-ink;
will-change: transform;
}
.ms__row + .ms__row { border-top: 1px solid rgba(255, 255, 255, .06); }
.ms__row > * {
padding: 0 $sp-2;
font-size: 12px;
line-height: $row-h;
font-weight: 500;
overflow: hidden;
white-space: nowrap;
}
.ms__row b { color: $subject-mint; font-weight: 700; }
.ms__row > :last-child { color: rgba(255, 255, 255, .5); }
// ── 자동 루프 (2s) ───────────────────────────────────
// 14% 에 날짜를 보조 열로 더한다(배지 1·2 컷 등장) → 동점 두 쌍이 **시차를 두고** 자리를 바꾼다.
// 두 쌍을 같은 순간에 겹치게 하면 네 줄이 두 줄로 보이는 프레임이 생겨 그 장이 poster 로 뽑힌다
// (실측). 그래서 위로 가는 줄만 z-index 와 그림자로 띄워 어느 줄이 지나가는지 남긴다.
// 복귀는 82% 에 끝나 24장 표본 안에 들어온다.
.ms.is-demo .ms__ord {
display: inline-flex;
animation-name: ms-ord;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ms.is-demo #ms-date {
animation-name: ms-date-on;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ms.is-demo #ms-date .ms__caret {
transition: none;
animation-name: ms-date-caret;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.ms.is-demo .ms__row {
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ms.is-demo .ms__row:nth-child(1) { animation-name: ms-down-a; }
.ms.is-demo .ms__row:nth-child(2) { animation-name: ms-up-a; z-index: 2; position: relative; }
.ms.is-demo .ms__row:nth-child(3) { animation-name: ms-down-b; }
.ms.is-demo .ms__row:nth-child(4) { animation-name: ms-up-b; z-index: 2; position: relative; }
@keyframes ms-down-a {
0% { transform: translateY(0); }
14% { transform: translateY(0); }
32% { transform: translateY($row-h); }
62% { transform: translateY($row-h); }
78% { transform: translateY(0); }
100% { transform: translateY(0); }
}
@keyframes ms-up-a {
0% { transform: translateY(0); box-shadow: none; }
14% { transform: translateY(0); box-shadow: $shadow-press; }
32% { transform: translateY(-$row-h); box-shadow: $shadow-press; }
36% { transform: translateY(-$row-h); box-shadow: none; }
62% { transform: translateY(-$row-h); box-shadow: none; }
78% { transform: translateY(0); box-shadow: none; }
100% { transform: translateY(0); box-shadow: none; }
}
@keyframes ms-down-b {
0% { transform: translateY(0); }
34% { transform: translateY(0); }
52% { transform: translateY($row-h); }
66% { transform: translateY($row-h); }
82% { transform: translateY(0); }
100% { transform: translateY(0); }
}
@keyframes ms-up-b {
0% { transform: translateY(0); box-shadow: none; }
34% { transform: translateY(0); box-shadow: $shadow-press; }
52% { transform: translateY(-$row-h); box-shadow: $shadow-press; }
56% { transform: translateY(-$row-h); box-shadow: none; }
66% { transform: translateY(-$row-h); box-shadow: none; }
82% { transform: translateY(0); box-shadow: none; }
100% { transform: translateY(0); box-shadow: none; }
}
@keyframes ms-ord {
0% { opacity: 0; }
14% { opacity: 1; }
60% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes ms-date-on {
0% { color: rgba(255, 255, 255, .58); }
14% { color: $color; }
60% { color: rgba(255, 255, 255, .58); }
100% { color: rgba(255, 255, 255, .58); }
}
@keyframes ms-date-caret {
0% { opacity: .28; transform: rotate(180deg); }
14% { opacity: 1; transform: rotate(180deg); }
30% { opacity: 1; transform: rotate(0deg); }
60% { opacity: 1; transform: rotate(0deg); }
70% { opacity: .28; transform: rotate(180deg); }
100% { opacity: .28; transform: rotate(180deg); }
}
// 320×200 — 세로 여유가 174px 뿐이라 좁은 화면에서는 열 폭만 줄인다
@media (max-width: 340px) {
.ms__head, .ms__row { grid-template-columns: 1fr 56px 60px; }
.ms__h, .ms__row > * { padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.ms.is-demo .ms__row,
.ms.is-demo .ms__ord,
.ms.is-demo #ms-date,
.ms.is-demo #ms-date .ms__caret { animation: none !important; }
.ms.is-demo .ms__ord { opacity: 1; }
.ms.is-demo #ms-date { color: $color; }
.ms.is-demo #ms-date .ms__caret { opacity: 1; transform: rotate(0deg); }
.ms__caret { 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);
}
.ms {
box-sizing: border-box;
width: min(300px, 92vw);
border: 1px solid rgb(21.7568627451%, 20.3058823529%, 21.3960784314%);
border-radius: 16px;
background: #17141a;
overflow: hidden;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
font-size: 12px;
line-height: 26px;
}
.ms__head {
display: grid;
grid-template-columns: 1fr 72px 72px;
height: 26px;
border-bottom: 1px solid rgb(21.7568627451%, 20.3058823529%, 21.3960784314%);
background: rgb(15.3882352941%, 14.0745098039%, 15.7960784314%);
}
.ms__h {
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0;
padding: 0 8px;
border: 0;
background: none;
color: rgba(255, 255, 255, 0.58);
font-family: inherit;
font-size: 11px;
line-height: 26px;
font-weight: 700;
text-align: left;
cursor: pointer;
}
.ms__h--text {
cursor: default;
}
.ms__h.is-on {
color: #5b8cf8;
}
.ms__h:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.ms__caret {
display: inline-flex;
width: 10px;
height: 10px;
opacity: 0.28;
transform: rotate(180deg);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ms__h.is-on .ms__caret {
opacity: 1;
}
.ms__h[aria-sort=ascending] .ms__caret {
transform: rotate(0deg);
}
.ms__ord {
display: inline-flex;
align-items: center;
justify-content: center;
width: 12px;
height: 12px;
border-radius: 999px;
background: #2f6df6;
color: #fff7e6;
font-size: 9px;
line-height: 12px;
font-weight: 700;
}
.ms__ord[hidden] {
display: none;
}
.ms__row {
display: grid;
grid-template-columns: 1fr 72px 72px;
height: 26px;
background: #17141a;
will-change: transform;
}
.ms__row + .ms__row {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.ms__row > * {
padding: 0 8px;
font-size: 12px;
line-height: 26px;
font-weight: 500;
overflow: hidden;
white-space: nowrap;
}
.ms__row b {
color: #4cd4a6;
font-weight: 700;
}
.ms__row > :last-child {
color: rgba(255, 255, 255, 0.5);
}
.ms.is-demo .ms__ord {
display: inline-flex;
animation-name: ms-ord;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ms.is-demo #ms-date {
animation-name: ms-date-on;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.ms.is-demo #ms-date .ms__caret {
transition: none;
animation-name: ms-date-caret;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.ms.is-demo .ms__row {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.ms.is-demo .ms__row:nth-child(1) {
animation-name: ms-down-a;
}
.ms.is-demo .ms__row:nth-child(2) {
animation-name: ms-up-a;
z-index: 2;
position: relative;
}
.ms.is-demo .ms__row:nth-child(3) {
animation-name: ms-down-b;
}
.ms.is-demo .ms__row:nth-child(4) {
animation-name: ms-up-b;
z-index: 2;
position: relative;
}
@keyframes ms-down-a {
0% {
transform: translateY(0);
}
14% {
transform: translateY(0);
}
32% {
transform: translateY(26px);
}
62% {
transform: translateY(26px);
}
78% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@keyframes ms-up-a {
0% {
transform: translateY(0);
box-shadow: none;
}
14% {
transform: translateY(0);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
32% {
transform: translateY(-26px);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
36% {
transform: translateY(-26px);
box-shadow: none;
}
62% {
transform: translateY(-26px);
box-shadow: none;
}
78% {
transform: translateY(0);
box-shadow: none;
}
100% {
transform: translateY(0);
box-shadow: none;
}
}
@keyframes ms-down-b {
0% {
transform: translateY(0);
}
34% {
transform: translateY(0);
}
52% {
transform: translateY(26px);
}
66% {
transform: translateY(26px);
}
82% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@keyframes ms-up-b {
0% {
transform: translateY(0);
box-shadow: none;
}
34% {
transform: translateY(0);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
52% {
transform: translateY(-26px);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
56% {
transform: translateY(-26px);
box-shadow: none;
}
66% {
transform: translateY(-26px);
box-shadow: none;
}
82% {
transform: translateY(0);
box-shadow: none;
}
100% {
transform: translateY(0);
box-shadow: none;
}
}
@keyframes ms-ord {
0% {
opacity: 0;
}
14% {
opacity: 1;
}
60% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes ms-date-on {
0% {
color: rgba(255, 255, 255, 0.58);
}
14% {
color: #2f6df6;
}
60% {
color: rgba(255, 255, 255, 0.58);
}
100% {
color: rgba(255, 255, 255, 0.58);
}
}
@keyframes ms-date-caret {
0% {
opacity: 0.28;
transform: rotate(180deg);
}
14% {
opacity: 1;
transform: rotate(180deg);
}
30% {
opacity: 1;
transform: rotate(0deg);
}
60% {
opacity: 1;
transform: rotate(0deg);
}
70% {
opacity: 0.28;
transform: rotate(180deg);
}
100% {
opacity: 0.28;
transform: rotate(180deg);
}
}
@media (max-width: 340px) {
.ms__head, .ms__row {
grid-template-columns: 1fr 56px 60px;
}
.ms__h, .ms__row > * {
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.ms.is-demo .ms__row,
.ms.is-demo .ms__ord,
.ms.is-demo #ms-date,
.ms.is-demo #ms-date .ms__caret {
animation: none !important;
}
.ms.is-demo .ms__ord {
opacity: 1;
}
.ms.is-demo #ms-date {
color: #2f6df6;
}
.ms.is-demo #ms-date .ms__caret {
opacity: 1;
transform: rotate(0deg);
}
.ms__caret {
transition: none;
}
}// 실제 동작: 헤더를 누르면 그 열로 정렬하고, Shift 를 누른 채 누르면 보조 열로 더한다.
// 자리 이동은 FLIP — 새 자리에 그린 뒤 옛 자리로 되돌려 놓고 0 으로 달린다.
var ms = document.getElementById('ms');
var msBody = document.getElementById('ms-body');
var MS = [
{ id: 'a', team: 'Amber', score: 92, date: '03-14' },
{ id: 'b', team: 'Blue', score: 92, date: '02-02' },
{ id: 'c', team: 'Mint', score: 88, date: '05-21' },
{ id: 'd', team: 'Pink', score: 88, date: '01-09' }
];
var msKeys = [{ k: 'score', dir: -1 }];
function msRender() {
msBody.innerHTML = MS.map(function (r) {
return '<div class="ms__row" role="row" data-id="' + r.id + '">' +
'<span role="gridcell">' + r.team + '</span>' +
'<b role="gridcell">' + r.score + '</b>' +
'<span role="gridcell">' + r.date + '</span></div>';
}).join('');
}
function msMark() {
[['score', 'ms-score'], ['date', 'ms-date']].forEach(function (p) {
var i = -1;
msKeys.forEach(function (k, n) { if (k.k === p[0]) i = n; });
var el = document.getElementById(p[1]);
el.setAttribute('aria-sort', i < 0 ? 'none' : (msKeys[i].dir < 0 ? 'descending' : 'ascending'));
el.classList.toggle('is-on', i >= 0);
var ord = el.querySelector('.ms__ord');
if (i >= 0) { ord.textContent = String(i + 1); } // 빠진 열의 배지에 0 을 적지 않는다
ord.hidden = i < 0 || msKeys.length < 2;
});
}
function msSort(key, add) {
ms.classList.remove('is-demo');
var was = {};
[].forEach.call(msBody.children, function (el) { was[el.dataset.id] = el.getBoundingClientRect().top; });
var hit = null;
msKeys.forEach(function (k) { if (k.k === key) hit = k; });
if (hit) hit.dir = -hit.dir;
else msKeys = add ? msKeys.concat([{ k: key, dir: 1 }]).slice(-2) : [{ k: key, dir: -1 }];
MS.sort(function (a, b) {
for (var i = 0; i < msKeys.length; i++) {
var k = msKeys[i];
if (a[k.k] < b[k.k]) return -k.dir;
if (a[k.k] > b[k.k]) return k.dir;
}
return 0;
});
msRender(); msMark();
[].forEach.call(msBody.children, function (el) {
var d = was[el.dataset.id] - el.getBoundingClientRect().top;
if (!d) return;
el.style.transition = 'none';
el.style.transform = 'translateY(' + d + 'px)';
requestAnimationFrame(function () {
el.style.transition = 'transform 320ms cubic-bezier(.2, .8, .2, 1)';
el.style.transform = 'translateY(0)';
});
});
}
['ms-score', 'ms-date'].forEach(function (id) {
document.getElementById(id).addEventListener('click', function (e) {
msSort(this.dataset.key, e.shiftKey);
});
});
msRender(); msMark();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MultiColumnSort.module.scss";
export type SortRow = { id: string; team: string; score: number; date: string };
export type MultiColumnSortProps = {
rows?: SortRow[];
tableLabel?: string;
teamLabel?: string;
scoreLabel?: string;
dateLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type ClickEvt = { shiftKey: boolean };
type ColKey = "score" | "date";
type SortKey = { k: ColKey; dir: number };
const DEFAULT_ROWS: SortRow[] = [
{ id: "a", team: "Amber", score: 92, date: "03-14" },
{ id: "b", team: "Blue", score: 92, date: "02-02" },
{ id: "c", team: "Mint", score: 88, date: "05-21" },
{ id: "d", team: "Pink", score: 88, date: "01-09" },
];
function Caret() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M208 160H48l80-80Z" opacity=".2" />
<path d="m213.66 154.34l-80-80a8 8 0 0 0-11.32 0l-80 80A8 8 0 0 0 48 168h160a8 8 0 0 0 5.66-13.66M67.31 152L128 91.31L188.69 152Z" />
</g>
</svg>
);
}
// 한 열만 비교한다 — 점수는 수, 날짜는 글자. 유니온을 그대로 < 로 재면 tsc 가 막는다
function cmp(a: SortRow, b: SortRow, k: ColKey): number {
if (k === "score") return a.score < b.score ? -1 : a.score > b.score ? 1 : 0;
return a.date < b.date ? -1 : a.date > b.date ? 1 : 0;
}
function sortRows(list: SortRow[], ks: SortKey[]): SortRow[] {
return list.slice().sort((a, b) => {
for (let i = 0; i < ks.length; i++) {
const c = cmp(a, b, ks[i].k);
if (c) return c * ks[i].dir;
}
return 0;
});
}
export default function MultiColumnSort({
rows = DEFAULT_ROWS,
tableLabel = "Team ranking",
teamLabel = "Team",
scoreLabel = "Score",
dateLabel = "Date",
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: MultiColumnSortProps) {
const [list, setList] = useState<SortRow[]>(rows);
const [keys, setKeys] = useState<SortKey[]>([{ k: "score", dir: -1 }]);
const [live, setLive] = useState(demo);
const bodyRef = useRef<HTMLDivElement>(null);
// ambient useRef 는 항상 T | null 을 돌려준다 — 지역 별칭으로 한 번만 좁혀 쓴다
const wasRef = useRef<{ [id: string]: number }>({});
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// FLIP — 새 자리에 그린 뒤 옛 자리로 되돌려 놓고 0 으로 달린다
useEffect(() => {
const body = bodyRef.current;
const was = wasRef.current;
if (!body || !was) return;
const kids: HTMLElement[] = Array.prototype.slice.call(body.children);
kids.forEach((el) => {
const id = el.getAttribute("data-id") || "";
const from = was[id];
if (from === undefined) return;
const d = from - el.getBoundingClientRect().top;
if (!d) return;
el.style.transition = "none";
el.style.transform = `translateY(${d}px)`;
requestAnimationFrame(() => {
el.style.transition = `transform 320ms ${easing}`;
el.style.transform = "translateY(0)";
});
});
wasRef.current = {};
}, [list]);
function onSort(key: ColKey, add: boolean) {
setLive(false);
const body = bodyRef.current;
const was: { [id: string]: number } = {};
if (body) {
const kids: HTMLElement[] = Array.prototype.slice.call(body.children);
kids.forEach((el) => { was[el.getAttribute("data-id") || ""] = el.getBoundingClientRect().top; });
}
wasRef.current = was;
let next: SortKey[];
const has = keys.filter((k) => k.k === key).length > 0;
if (has) next = keys.map((k) => (k.k === key ? { k: k.k, dir: -k.dir } : k));
else next = add ? keys.concat([{ k: key, dir: 1 }]).slice(-2) : [{ k: key, dir: -1 }];
setKeys(next);
setList(sortRows(list, next));
}
const heads: { key: ColKey; label: string; ord: string }[] = [
{ key: "score", label: scoreLabel, ord: "1" },
{ key: "date", label: dateLabel, ord: "2" },
];
return (
<div className={`${styles.root} ${live ? styles.isDemo : ""}`} style={vars} role="grid" aria-label={tableLabel}>
<div className={styles.head} role="row">
<span className={`${styles.h} ${styles.hText}`}>{teamLabel}</span>
{heads.map((h) => {
let at = -1;
keys.forEach((k, n) => { if (k.k === h.key) at = n; });
const on = at >= 0;
return (
<button
key={h.key}
className={`${styles.h} ${h.key === "date" ? styles.hDate : ""} ${on ? styles.isOn : ""}`}
type="button"
aria-sort={!on ? "none" : keys[at].dir < 0 ? "descending" : "ascending"}
onClick={(e: ClickEvt) => onSort(h.key, e.shiftKey)}
>
<span className={styles.label}>{h.label}</span>
<span className={styles.ord} aria-hidden="true" hidden={!on || keys.length < 2}>
{on ? String(at + 1) : h.ord}
</span>
<span className={styles.caret} aria-hidden="true"><Caret /></span>
</button>
);
})}
</div>
<div className={styles.body} role="rowgroup" ref={bodyRef}>
{list.map((r) => (
<div className={styles.row} role="row" data-id={r.id} key={r.id}>
<span role="gridcell">{r.team}</span>
<b role="gridcell">{r.score}</b>
<span role="gridcell">{r.date}</span>
</div>
))}
</div>
</div>
);
}01 Priority sort that holds two columns at once — Measured area 4.558% · moving frames 18/23Original by GODRICH · SCSS and React ship in the zip · 9 Data Table UI Patterns — Drag, Resize, Select
The post this demo lives in
9 Data Table UI Patterns — Drag, Resize, SelectA data table ui is a grid of rows and columns you can reorder, narrow, and pull values out of by hand. All nine sort a r See all demos in this post


