05クリックソートヘッダー
列見出しをクリックすると矢印が反転し、その列を基準に昇順・降順が交互に並び替わる。
使いどころ管理画面のデータテーブル、価格比較表
aria-sort 속성click 토글화살표 rotate transform

<main class="stage">
<table class="sort-table is-demo">
<caption class="sort-table__caption">参加者のスコア</caption>
<thead>
<tr>
<th><button class="sort-table__th" type="button" data-key="name" aria-sort="none">名前<span class="sort-table__arrow" aria-hidden="true">▾</span></button></th>
<th><button class="sort-table__th" type="button" data-key="score" aria-sort="descending">スコア<span class="sort-table__arrow" aria-hidden="true">▾</span></button></th>
</tr>
</thead>
<tbody>
<tr><td>ドユン</td><td data-score="92">92</td></tr>
<tr><td>ソヨン</td><td data-score="88">88</td></tr>
<tr><td>ミンジュン</td><td data-score="76">76</td></tr>
</tbody>
</table>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick; // 화살표가 뒤집히는 시간
$easing: $ease-out;
$color: $subject-blue; // 정렬 중인 열 강조 색
@include stage($stage-ink);
.sort-table {
position: relative;
width: min(240px, 78vw);
border-collapse: separate;
border-spacing: 0;
border-radius: $r-card;
overflow: hidden;
background: $stage-paper;
font-size: 13px;
}
.sort-table__caption {
position: absolute;
width: 1px; height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.sort-table th, .sort-table td {
padding: $sp-2 $sp-3;
text-align: left;
}
.sort-table thead th { background: rgba($subject-blue, .1); }
// tbody 행에도 불투명 배경을 줘야 한다 — 안 주면 정렬 데모 루프에서 두 행이 translateY 로 겹칠 때
// 배경이 없는 행 뒤로 다른 행 글자가 그대로 비쳐 보인다 (실측으로 확인)
.sort-table tbody td { background: $stage-paper; }
.sort-table tbody tr:not(:last-child) td { border-bottom: 1px solid rgba(23, 20, 26, .08); }
.sort-table__th {
display: flex;
align-items: center;
gap: $sp-1;
width: 100%;
border: 0;
background: none;
padding: 0;
font-family: inherit;
font-size: inherit;
font-weight: 700;
color: $stage-ink;
cursor: pointer;
}
.sort-table__th:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.sort-table__th[aria-sort="none"] .sort-table__arrow { opacity: .35; transform: rotate(0deg); }
.sort-table__th[aria-sort="descending"] .sort-table__arrow { opacity: 1; transform: rotate(0deg); color: $subject-blue-deep; } // 연파랑 헤더 위 파랑 3.96:1 → deep 5.29:1
.sort-table__th[aria-sort="ascending"] .sort-table__arrow { opacity: 1; transform: rotate(180deg); color: $subject-blue-deep; }
.sort-table__arrow { display: inline-block; font-size: 10px; transition: transform $duration $easing, opacity $duration $easing; }
.sort-table.is-demo tbody tr:nth-child(1) { animation: sort-row-a $dur-loop $easing infinite; }
.sort-table.is-demo tbody tr:nth-child(2) { animation: sort-row-b $dur-loop $easing infinite; }
.sort-table.is-demo [data-key="score"] .sort-table__arrow { animation: sort-arrow-loop $dur-loop $easing infinite; }
@keyframes sort-row-a {
0%, 10% { transform: translateY(0); }
45%, 65% { transform: translateY(37px); }
95%, 100% { transform: translateY(0); }
}
@keyframes sort-row-b {
0%, 10% { transform: translateY(0); }
45%, 65% { transform: translateY(-37px); }
95%, 100% { transform: translateY(0); }
}
@keyframes sort-arrow-loop {
0%, 10% { transform: rotate(0deg); }
50%, 60% { transform: rotate(180deg); }
95%, 100% { transform: rotate(0deg); }
}
@media (prefers-reduced-motion: reduce) {
.sort-table__arrow { transition: none; }
.sort-table.is-demo tbody tr:nth-child(1),
.sort-table.is-demo tbody tr:nth-child(2),
.sort-table.is-demo [data-key="score"] .sort-table__arrow { animation: 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);
}
.sort-table {
position: relative;
width: min(240px, 78vw);
border-collapse: separate;
border-spacing: 0;
border-radius: 16px;
overflow: hidden;
background: #ffffff;
font-size: 13px;
}
.sort-table__caption {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.sort-table th, .sort-table td {
padding: 8px 12px;
text-align: left;
}
.sort-table thead th {
background: rgba(47, 109, 246, 0.1);
}
.sort-table tbody td {
background: #ffffff;
}
.sort-table tbody tr:not(:last-child) td {
border-bottom: 1px solid rgba(23, 20, 26, 0.08);
}
.sort-table__th {
display: flex;
align-items: center;
gap: 4px;
width: 100%;
border: 0;
background: none;
padding: 0;
font-family: inherit;
font-size: inherit;
font-weight: 700;
color: #17141a;
cursor: pointer;
}
.sort-table__th:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.sort-table__th[aria-sort=none] .sort-table__arrow {
opacity: 0.35;
transform: rotate(0deg);
}
.sort-table__th[aria-sort=descending] .sort-table__arrow {
opacity: 1;
transform: rotate(0deg);
color: #0a4fe8;
}
.sort-table__th[aria-sort=ascending] .sort-table__arrow {
opacity: 1;
transform: rotate(180deg);
color: #0a4fe8;
}
.sort-table__arrow {
display: inline-block;
font-size: 10px;
transition: transform 220ms ease-out, opacity 220ms ease-out;
}
.sort-table.is-demo tbody tr:nth-child(1) {
animation: sort-row-a 2s ease-out infinite;
}
.sort-table.is-demo tbody tr:nth-child(2) {
animation: sort-row-b 2s ease-out infinite;
}
.sort-table.is-demo [data-key=score] .sort-table__arrow {
animation: sort-arrow-loop 2s ease-out infinite;
}
@keyframes sort-row-a {
0%, 10% {
transform: translateY(0);
}
45%, 65% {
transform: translateY(37px);
}
95%, 100% {
transform: translateY(0);
}
}
@keyframes sort-row-b {
0%, 10% {
transform: translateY(0);
}
45%, 65% {
transform: translateY(-37px);
}
95%, 100% {
transform: translateY(0);
}
}
@keyframes sort-arrow-loop {
0%, 10% {
transform: rotate(0deg);
}
50%, 60% {
transform: rotate(180deg);
}
95%, 100% {
transform: rotate(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.sort-table__arrow {
transition: none;
}
.sort-table.is-demo tbody tr:nth-child(1),
.sort-table.is-demo tbody tr:nth-child(2),
.sort-table.is-demo [data-key=score] .sort-table__arrow {
animation: none;
}
}(function () {
var table = document.querySelector('.sort-table');
var tbody = table.querySelector('tbody');
var ths = table.querySelectorAll('.sort-table__th');
ths.forEach(function (btn) {
btn.addEventListener('click', function () {
table.classList.remove('is-demo');
var key = btn.dataset.key;
var dir = btn.getAttribute('aria-sort') === 'ascending' ? 'descending' : 'ascending';
ths.forEach(function (b) { b.setAttribute('aria-sort', 'none'); });
btn.setAttribute('aria-sort', dir);
var rows = Array.prototype.slice.call(tbody.rows);
rows.sort(function (a, b) {
var av = key === 'score' ? Number(a.cells[1].dataset.score) : a.cells[0].textContent;
var bv = key === 'score' ? Number(b.cells[1].dataset.score) : b.cells[0].textContent;
var cmp = av > bv ? 1 : av < bv ? -1 : 0;
return dir === 'ascending' ? cmp : -cmp;
});
rows.forEach(function (r) { tbody.appendChild(r); });
});
});
})();import { useState, type CSSProperties } from "react";
import styles from "./SortableHeaderClick.module.scss";
export type Row = { name: string; score: number };
export type SortableHeaderClickProps = {
rows: Row[];
/** 화살표가 뒤집히는 시간 (기본 220ms) */
duration?: number;
easing?: string;
/** 정렬 중인 열 강조 색 */
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Key = "name" | "score";
type Dir = "none" | "ascending" | "descending";
export default function SortableHeaderClick({
rows,
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: SortableHeaderClickProps) {
const [key, setKey] = useState<Key>("score");
const [dir, setDir] = useState<Dir>("descending");
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const sorted = rows.slice().sort((a, b) => {
const cmp = a[key] > b[key] ? 1 : a[key] < b[key] ? -1 : 0;
return dir === "ascending" ? cmp : -cmp;
});
const click = (k: Key) => {
setDir(k === key && dir === "ascending" ? "descending" : "ascending");
setKey(k);
};
const sortOf = (k: Key): Dir => (k === key ? dir : "none");
return (
<table className={styles.table} style={vars}>
<caption className={styles.caption}>참가자 점수</caption>
<thead>
<tr>
<th>
<button className={styles.th} type="button" aria-sort={sortOf("name")} onClick={() => click("name")}>
이름<span className={styles.arrow} aria-hidden="true">▾</span>
</button>
</th>
<th>
<button className={styles.th} type="button" aria-sort={sortOf("score")} onClick={() => click("score")}>
점수<span className={styles.arrow} aria-hidden="true">▾</span>
</button>
</th>
</tr>
</thead>
<tbody>
{sorted.map((r) => (
<tr key={r.name}>
<td>{r.name}</td>
<td>{r.score}</td>
</tr>
))}
</tbody>
</table>
);
}05 クリックソートヘッダー — 計測 累積面積 3.665% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · スワイプ削除リストUI 9選 — ページネーションまで
このデモが属する記事
スワイプ削除リストUI 9選 — ページネーションまでスワイプ削除とは、リストの行を横に押すと裏に隠れていた削除ボタンが現れる動きのことで、アプリで見たあの操作をウェブでもそのまま使いたい人は多いです。この記事では、そのスワイプ削除を含むリストのインタラクションを9個、実際に動くコードでまとめ この記事のデモをすべて見る


