05Header bấm để sắp xếp
Bấm vào tiêu đề cột sẽ lật mũi tên và sắp xếp lại các dòng theo cột đó, xen kẽ tăng dần và giảm dần.
Dùng choBảng dữ liệu quản trị, bảng so sánh giá
aria-sort 속성click 토글화살표 rotate transform

<main class="stage">
<table class="sort-table is-demo">
<caption class="sort-table__caption">Điểm của người tham gia</caption>
<thead>
<tr>
<th><button class="sort-table__th" type="button" data-key="name" aria-sort="none">Tên<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">Điểm<span class="sort-table__arrow" aria-hidden="true">▾</span></button></th>
</tr>
</thead>
<tbody>
<tr><td>Do-yoon</td><td data-score="92">92</td></tr>
<tr><td>Seo-yeon</td><td data-score="88">88</td></tr>
<tr><td>Min-jun</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 Header bấm để sắp xếp — Diện tích đo được 3.665% · khung hình chuyển động 19/23Nguyên tác GODRICH · SCSS và React nằm trong tệp zip · 9 mẫu List UI: swipe to delete css và phân trang
Bài viết chứa demo này
9 mẫu List UI: swipe to delete css và phân trangSwipe to delete css là vuốt một dòng để lộ nút xóa phía sau — thao tác bạn hay thấy trong app, giờ có luôn trên web. Bài Xem tất cả demo trong bài này


