데이터 테이블 UI 9종 — 열을 끌어 넓히고 셀을 골라 복사하는 표
데이터 테이블 ui는 줄과 칸으로 늘어놓은 값을 사람이 직접 만져서 다시 배열하고 좁히고 꺼내 가는 화면입니다. 아홉 개 전부 시늉이 아니라 실제로 값을 다시 정렬하고 문자열을 비교하며, 합계는 볼 때마다 그 자리에서 다시 더합니다.
01 table-multi-column-sort
02 table-column-resize-drag
03 table-column-reorder-drag
04 table-column-visibility-menu
05 table-cell-range-select-copy
06 table-column-filter-chips
07 table-conditional-cell-format
08 table-sticky-summary-row
09 table-keyboard-grid-nav
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 두 열을 한꺼번에 잡는 우선순위 정렬
- 02 헤더 경계를 끌어 넓히는 열 너비
- 03 머리를 들어 옮기는 열 순서 바꾸기
- 04 체크를 떼면 열이 사라지는 목록 메뉴
- 05 셀을 끌어 네모로 묶고 복사하기
- 06 열마다 따로 거르는 필터 알약
- 07 숫자 크기대로 칠해지는 셀 색과 막대
- 08 바닥에 붙어 다시 계산되는 합계 줄
- 09 화살표로 칸을 옮겨 다니는 포커스 테두리
순서는 표를 만드는 사람이 아니라 표를 읽는 사람이 막히는 순서입니다. 먼저 줄이 너무 많아 원하는 값이 어디 있는지 모르니 순서를 바꾸고(01), 글자가 잘려 보이니 칸을 넓히고(02), 자주 보는 열을 왼쪽으로 끌어 옵니다(03). 그래도 가로로 넘치면 모바일 테이블 ui 처럼 안 보는 열을 아예 접습니다(04). 여기까지가 표의 모양을 손보는 일입니다. 다음 둘은 값을 꺼내는 일입니다. 필요한 칸만 네모로 묶어 가져가고(05), 조건을 걸어 줄 수를 줄입니다(06). 마지막 셋은 표가 사람에게 말을 거는 쪽입니다. 숫자가 큰지 작은지를 색으로 먼저 알리고(07), 아무리 스크롤해도 합계는 바닥에 남고(08), 마우스가 없어도 칸 사이를 걸어 다닐 수 있어야 합니다(09). 값이 파일이면 이 표는 파일 트리 만들기로 옮겨 가고, 비교가 목적이면 이미지 비교 슬라이더 UI가 그 칸을 대신합니다. 열 너비는 끌어 당긴 폭을 픽셀로 기억했다가 화면이 좁아지면 비율로 되돌리는 규칙을 정했고, 정렬은 숫자와 문자를 나눠 기준을 삼았습니다. 셀을 선택해 복사할 때는 머리글까지 함께 담아 붙여 넣었을 때 표가 다시 성립하도록 맞췄고, 화면이 좁아지는 순간에는 열 순서 바꾸기가 잠기고 너비 조절만 남습니다.
01두 열을 한꺼번에 잡는 우선순위 정렬
테이블 디자인 ui 는 무엇이 보이나
점수가 같은 줄이 두 개씩 나오면 한 열만으로는 순서가 안 정해집니다. 이 부품은 Shift 를 누른 채 다른 헤더를 누르면 그 열을 보조 기준으로 더하고, 헤더마다 1 과 2 라는 순번 배지를 붙여 어느 쪽이 먼저 읽히는지 보여 줍니다.

<main class="stage">
<div class="ms is-demo" id="ms" role="grid" aria-label="팀 순위">
<div class="ms__head" role="row">
<span class="ms__h ms__h--text">팀</span>
<button class="ms__h" type="button" id="ms-score" data-key="score" aria-sort="descending">
<span class="ms__label">점수</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">날짜</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: '노랑', score: 92, date: '03-14' },
{ id: 'b', team: '파랑', score: 92, date: '02-02' },
{ id: 'c', team: '민트', score: 88, date: '05-21' },
{ id: 'd', team: '분홍', 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 · 두 열을 한꺼번에 잡는 우선순위 정렬 — 측정 누적면적 4.558% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
비교 함수는 키 배열을 앞에서부터 훑다가 값이 갈리는 첫 키에서 바로 결과를 돌려줍니다.
function msSort(key, add) {
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();
}
02헤더 경계를 끌어 넓히는 열 너비
화면에서 일어나는 일
열 폭을 정하는 값은 한 곳에만 있어야 합니다. 이 부품은 --w1 이라는 길이형 커스텀 속성 하나만 두고 모든 행이 그 값을 받아 쓰며, 끌고 가는 동안 표를 세로로 가로지르는 안내선도 같은 값으로 함께 움직입니다.

<main class="stage">
<div class="cr is-demo" id="cr">
<div class="cr__grid" id="cr-grid" role="grid" aria-label="상품 목록">
<div class="cr__row cr__row--head" role="row">
<span class="cr__c cr__c--h" role="columnheader">상품명</span>
<span class="cr__grip" id="cr-grip" role="separator" aria-label="열 너비 조절" tabindex="0" aria-orientation="vertical">
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor" aria-hidden="true"><g><path d="m232 128l-32 32H56l-32-32l32-32h144Z" opacity=".2"/><path d="m237.66 133.66l-32 32a8 8 0 0 1-11.32-11.32L212.69 136H43.31l18.35 18.34a8 8 0 0 1-11.32 11.32l-32-32a8 8 0 0 1 0-11.32l32-32a8 8 0 0 1 11.32 11.32L43.31 120h169.38l-18.35-18.34a8 8 0 0 1 11.32-11.32l32 32a8 8 0 0 1 0 11.32"/></g></svg>
</span>
<span class="cr__c cr__c--h" role="columnheader">수량</span>
</div>
<div class="cr__row" role="row"><span class="cr__c" role="gridcell">앵두잼 한 병</span><i></i><span class="cr__c" role="gridcell">12</span></div>
<div class="cr__row" role="row"><span class="cr__c" role="gridcell">민트 비누 묶음</span><i></i><span class="cr__c" role="gridcell">7</span></div>
<div class="cr__row" role="row"><span class="cr__c" role="gridcell">라일락 편지지</span><i></i><span class="cr__c" role="gridcell">24</span></div>
</div>
<div class="cr__guide" id="cr-guide" aria-hidden="true"></div>
</div>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
$row-h: 26px;
$ink: $subject-ink;
$line: color.mix($ink, $stage-yellow, 22%);
// 열 폭은 길이형 커스텀 속성 하나. 등록하지 않으면 keyframes 가 보간하지 못하고 한 번에 튄다
@property --w1 {
syntax: "<length>";
inherits: true;
initial-value: 132px;
}
.cr {
position: relative;
box-sizing: border-box;
width: min(300px, 92vw);
border: 2px solid $ink;
border-radius: $r-card;
background: $stage-paper;
overflow: hidden;
color: $ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cr__grid { --w1: 132px; display: block; }
.cr__row {
display: grid;
grid-template-columns: var(--w1) 12px 1fr;
align-items: center;
height: $row-h;
}
.cr__row + .cr__row { border-top: 1px solid $line; }
.cr__row--head {
border-bottom: 2px solid $ink;
background: color.mix($ink, $stage-yellow, 8%);
}
.cr__c {
padding: 0 $sp-2;
font-size: 12px;
line-height: $row-h;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cr__c--h {
font-size: 11px;
font-weight: 700;
text-transform: none;
}
// ── 손잡이 ────────────────────────────────────────────
.cr__grip {
display: inline-flex;
align-items: center;
justify-content: center;
height: $row-h;
color: rgba(23, 20, 26, .42);
font-size: 10px;
line-height: $row-h;
cursor: col-resize;
touch-action: none;
}
.cr__grip:hover, .cr .cr__grip:focus-visible { color: $color; }
.cr__grip:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
// ── 끄는 동안 따라오는 안내선 ─────────────────────────
.cr__guide {
position: absolute;
left: 0; top: 0;
z-index: 3; // 행 배경이 불투명해서 이게 없으면 머리글 칸에서만 보인다
width: 2px;
height: 100%;
background: $color;
opacity: 0;
transform: translateX(132px);
pointer-events: none;
}
.cr.is-drag .cr__guide { opacity: 1; }
// ── 자동 루프 (2s) ───────────────────────────────────
// 8~34% 넓히고 46~72% 좁히고 72~86% 제자리로. 폭은 --w1 하나만 움직이고
// 안내선은 같은 값을 translateX 로 따라간다 — 두 값을 따로 쓰면 어긋난다.
.cr.is-demo .cr__grid {
animation-name: cr-w;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.cr.is-demo .cr__guide {
animation-name: cr-guide;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cr.is-demo .cr__grip {
animation-name: cr-grip;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cr-w {
0% { --w1: 132px; }
8% { --w1: 132px; }
34% { --w1: 180px; }
46% { --w1: 180px; }
72% { --w1: 100px; }
86% { --w1: 132px; }
100% { --w1: 132px; }
}
@keyframes cr-guide {
0% { opacity: 0; transform: translateX(132px); }
8% { opacity: 1; transform: translateX(132px); }
34% { opacity: 1; transform: translateX(180px); }
46% { opacity: 1; transform: translateX(180px); }
72% { opacity: 1; transform: translateX(100px); }
86% { opacity: 0; transform: translateX(132px); }
100% { opacity: 0; transform: translateX(132px); }
}
@keyframes cr-grip {
0% { color: rgba(23, 20, 26, .42); }
8% { color: $color; }
86% { color: rgba(23, 20, 26, .42); }
100% { color: rgba(23, 20, 26, .42); }
}
// 320×200 — 폭이 좁아지므로 시작 폭과 여백만 줄인다
@media (max-width: 340px) {
.cr__grid { --w1: 116px; }
.cr__c { padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.cr.is-demo .cr__grid,
.cr.is-demo .cr__guide,
.cr.is-demo .cr__grip { animation: none !important; }
.cr.is-demo .cr__grid { --w1: 180px; }
.cr.is-demo .cr__guide { opacity: 1; transform: translateX(180px); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
@property --w1 {
syntax: "<length>";
inherits: true;
initial-value: 132px;
}
.cr {
position: relative;
box-sizing: border-box;
width: min(300px, 92vw);
border: 2px solid #17141a;
border-radius: 16px;
background: #ffffff;
overflow: hidden;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cr__grid {
--w1: 132px;
display: block;
}
.cr__row {
display: grid;
grid-template-columns: var(--w1) 12px 1fr;
align-items: center;
height: 26px;
}
.cr__row + .cr__row {
border-top: 1px solid rgb(79.9843137255%, 65.9607843137%, 21.5137254902%);
}
.cr__row--head {
border-bottom: 2px solid #17141a;
background: rgb(92.7215686275%, 76.3921568627%, 23.5450980392%);
}
.cr__c {
padding: 0 8px;
font-size: 12px;
line-height: 26px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cr__c--h {
font-size: 11px;
font-weight: 700;
text-transform: none;
}
.cr__grip {
display: inline-flex;
align-items: center;
justify-content: center;
height: 26px;
color: rgba(23, 20, 26, 0.42);
font-size: 10px;
line-height: 26px;
cursor: col-resize;
touch-action: none;
}
.cr__grip:hover, .cr .cr__grip:focus-visible {
color: #2f6df6;
}
.cr__grip:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.cr__guide {
position: absolute;
left: 0;
top: 0;
z-index: 3;
width: 2px;
height: 100%;
background: #2f6df6;
opacity: 0;
transform: translateX(132px);
pointer-events: none;
}
.cr.is-drag .cr__guide {
opacity: 1;
}
.cr.is-demo .cr__grid {
animation-name: cr-w;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.cr.is-demo .cr__guide {
animation-name: cr-guide;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cr.is-demo .cr__grip {
animation-name: cr-grip;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cr-w {
0% {
--w1: 132px;
}
8% {
--w1: 132px;
}
34% {
--w1: 180px;
}
46% {
--w1: 180px;
}
72% {
--w1: 100px;
}
86% {
--w1: 132px;
}
100% {
--w1: 132px;
}
}
@keyframes cr-guide {
0% {
opacity: 0;
transform: translateX(132px);
}
8% {
opacity: 1;
transform: translateX(132px);
}
34% {
opacity: 1;
transform: translateX(180px);
}
46% {
opacity: 1;
transform: translateX(180px);
}
72% {
opacity: 1;
transform: translateX(100px);
}
86% {
opacity: 0;
transform: translateX(132px);
}
100% {
opacity: 0;
transform: translateX(132px);
}
}
@keyframes cr-grip {
0% {
color: rgba(23, 20, 26, 0.42);
}
8% {
color: #2f6df6;
}
86% {
color: rgba(23, 20, 26, 0.42);
}
100% {
color: rgba(23, 20, 26, 0.42);
}
}
@media (max-width: 340px) {
.cr__grid {
--w1: 116px;
}
.cr__c {
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.cr.is-demo .cr__grid,
.cr.is-demo .cr__guide,
.cr.is-demo .cr__grip {
animation: none !important;
}
.cr.is-demo .cr__grid {
--w1: 180px;
}
.cr.is-demo .cr__guide {
opacity: 1;
transform: translateX(180px);
}
}// 실제 동작: 손잡이를 끌면 첫 열의 폭만 바뀐다. 폭은 grid-template-columns 의 첫 값 하나.
var cr = document.getElementById('cr');
var crGrid = document.getElementById('cr-grid');
var crGrip = document.getElementById('cr-grip');
var crGuide = document.getElementById('cr-guide');
var CR_MIN = 88, CR_MAX = 200;
var crW = 132, crFrom = 0, crAt = 0;
function crSet(w) {
crW = Math.max(CR_MIN, Math.min(CR_MAX, w));
crGrid.style.setProperty('--w1', crW + 'px');
}
function crMove(e) {
cr.classList.remove('is-demo');
crSet(crFrom + (e.clientX - crAt));
crGuide.style.transform = 'translateX(' + crW + 'px)';
}
function crUp() {
cr.classList.remove('is-drag');
window.removeEventListener('pointermove', crMove);
window.removeEventListener('pointerup', crUp);
}
crGrip.addEventListener('pointerdown', function (e) {
cr.classList.remove('is-demo');
cr.classList.add('is-drag');
crFrom = crW; crAt = e.clientX;
crGuide.style.transform = 'translateX(' + crW + 'px)';
window.addEventListener('pointermove', crMove);
window.addEventListener('pointerup', crUp);
});
// 손잡이를 두 번 누르면 가장 긴 글자에 맞춰 넓힌다
crGrip.addEventListener('dblclick', function () {
cr.classList.remove('is-demo');
var wide = 0;
[].forEach.call(crGrid.querySelectorAll('.cr__row:not(.cr__row--head) .cr__c:first-child'), function (el) {
wide = Math.max(wide, el.scrollWidth + 20);
});
crSet(wide);
});
crGrip.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault(); cr.classList.remove('is-demo');
crSet(crW + (e.key === 'ArrowRight' ? 8 : -8));
});
crSet(crW);import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ColumnResizeDrag.module.scss";
export type ResizeRow = { name: string; qty: string };
export type ColumnResizeDragProps = {
rows?: ResizeRow[];
tableLabel?: string;
nameLabel?: string;
qtyLabel?: string;
gripLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type WidthVars = CSSProperties & { "--w1"?: string };
type PtrEvt = { clientX: number };
type KeyEvt = { key: string; preventDefault: () => void };
const MIN = 88;
const MAX = 200;
const DEFAULT_ROWS: ResizeRow[] = [
{ name: "Cherry jam, one jar", qty: "12" },
{ name: "Mint soap bundle", qty: "7" },
{ name: "Lilac letter paper", qty: "24" },
];
function Grip() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor" aria-hidden="true">
<g>
<path d="m232 128l-32 32H56l-32-32l32-32h144Z" opacity=".2" />
<path d="m237.66 133.66l-32 32a8 8 0 0 1-11.32-11.32L212.69 136H43.31l18.35 18.34a8 8 0 0 1-11.32 11.32l-32-32a8 8 0 0 1 0-11.32l32-32a8 8 0 0 1 11.32 11.32L43.31 120h169.38l-18.35-18.34a8 8 0 0 1 11.32-11.32l32 32a8 8 0 0 1 0 11.32" />
</g>
</svg>
);
}
function clampW(w: number): number {
return Math.max(MIN, Math.min(MAX, w));
}
export default function ColumnResizeDrag({
rows = DEFAULT_ROWS,
tableLabel = "Product list",
nameLabel = "Product",
qtyLabel = "Qty",
gripLabel = "Resize column",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ColumnResizeDragProps) {
const [w, setW] = useState(132);
const [guideX, setGuideX] = useState(132);
const [dragging, setDragging] = useState(false);
const [live, setLive] = useState(demo);
// ambient useRef 는 항상 T | null 을 돌려준다 — 지역 별칭으로 한 번만 좁혀 쓴다
const dragRef = useRef<{ from: number; at: number }>({ from: 132, at: 0 });
const drag = dragRef.current || { from: 132, at: 0 };
const cellsRef = useRef<(HTMLElement | null)[]>([]);
const cells: (HTMLElement | null)[] = cellsRef.current || [];
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const gridVars: WidthVars = { "--w1": `${w}px` };
// 끄는 동안에만 창 전체를 듣는다 — 손잡이 밖으로 나가도 폭이 따라온다
useEffect(() => {
if (!dragging) return;
function move(e: PtrEvt) {
const next = clampW(drag.from + (e.clientX - drag.at));
setW(next);
setGuideX(next);
}
function up() { setDragging(false); }
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
return () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
}, [dragging]);
function onDown(e: PtrEvt) {
setLive(false);
drag.from = w;
drag.at = e.clientX;
setGuideX(w);
setDragging(true);
}
// 손잡이를 두 번 누르면 가장 긴 글자에 맞춰 넓힌다
function onFit() {
setLive(false);
let wide = 0;
cells.forEach((el) => { if (el) wide = Math.max(wide, el.scrollWidth + 20); });
setW(clampW(wide));
}
function onKey(e: KeyEvt) {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
setLive(false);
setW(clampW(w + (e.key === "ArrowRight" ? 8 : -8)));
}
const cls = [styles.root, live ? styles.isDemo : "", dragging ? styles.isDrag : ""].join(" ");
return (
<div className={cls} style={vars}>
<div className={styles.grid} style={gridVars} role="grid" aria-label={tableLabel}>
<div className={`${styles.row} ${styles.rowHead}`} role="row">
<span className={`${styles.c} ${styles.cH}`} role="columnheader">{nameLabel}</span>
<span
className={styles.grip}
role="separator"
aria-label={gripLabel}
aria-orientation="vertical"
tabIndex={0}
onPointerDown={onDown}
onDoubleClick={onFit}
onKeyDown={onKey}
>
<Grip />
</span>
<span className={`${styles.c} ${styles.cH}`} role="columnheader">{qtyLabel}</span>
</div>
{rows.map((r, i) => (
<div className={styles.row} role="row" key={r.name}>
<span
className={styles.c}
role="gridcell"
ref={(el: HTMLElement | null) => { cells[i] = el; }}
>
{r.name}
</span>
<i />
<span className={styles.c} role="gridcell">{r.qty}</span>
</div>
))}
</div>
<div className={styles.guide} aria-hidden="true" style={{ transform: `translateX(${guideX}px)` }} />
</div>
);
}02 · 헤더 경계를 끌어 넓히는 열 너비 — 측정 누적면적 3.553% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
폭을 두 군데에 적어 두면 선과 칸이 반드시 어긋납니다.
@property --w1 {
syntax: "<length>";
inherits: true;
initial-value: 132px;
}
.cr__row {
display: grid;
grid-template-columns: var(--w1) 12px 1fr;
}
@keyframes cr-w {
0% { --w1: 132px; }
8% { --w1: 132px; }
34% { --w1: 180px; }
46% { --w1: 180px; }
72% { --w1: 100px; }
86% { --w1: 132px; }
100% { --w1: 132px; }
}
03머리를 들어 옮기는 열 순서 바꾸기
어떤 움직임인가
표를 행으로 짜면 열 하나를 통째로 옮기기가 어렵습니다. 그래서 이 부품은 열마다 세로 묶음을 하나씩 만들어 나란히 세워 두고, 잡힌 묶음만 16px 들어 올려 바탕을 한 단계 밝힙니다.

<main class="stage">
<div class="rd is-demo" id="rd">
<div class="rd__slot" aria-hidden="true"></div>
<div class="rd__cols" id="rd-cols" role="grid" aria-label="회원 표">
<div class="rd__col" role="columnheader">
<span class="rd__h"><span class="rd__grab" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2"/><path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg></span>이름</span>
<span class="rd__c">하늘</span><span class="rd__c">가온</span><span class="rd__c">노을</span>
</div>
<div class="rd__col" role="columnheader">
<span class="rd__h"><span class="rd__grab" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2"/><path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg></span>등급</span>
<span class="rd__c">금</span><span class="rd__c">은</span><span class="rd__c">동</span>
</div>
<div class="rd__col" role="columnheader">
<span class="rd__h"><span class="rd__grab" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2"/><path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12"/></g></svg></span>적립</span>
<span class="rd__c">820</span><span class="rd__c">410</span><span class="rd__c">175</span>
</div>
</div>
</div>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
$row-h: 26px;
$col-w: 96px;
$panel: color.mix($subject-ink, $stage-orange, 84%);
$lifted: color.mix($subject-ink, $stage-orange, 66%); // 집어 든 열은 바탕을 한 단계 밝혀 띄운다
$line: rgba(255, 247, 230, .16);
.rd {
position: relative;
box-sizing: border-box;
padding: $sp-1;
border-radius: $r-card;
background: $panel;
color: $color;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// 놓을 자리 — 열이 비켜난 동안만 보이는 빈 홈
.rd__slot {
position: absolute;
left: 4px; top: 4px;
width: $col-w;
height: calc(100% - 8px);
border: 2px dashed rgba(255, 247, 230, .42);
border-radius: $r-sm;
opacity: 0;
}
.rd__cols {
position: relative;
display: flex;
}
.rd__col {
flex: 0 0 auto;
width: $col-w;
display: flex;
flex-direction: column;
border-radius: $r-sm;
background: $panel;
transition: transform $duration $easing;
}
.rd__col.is-lift {
z-index: 2;
background: $lifted;
box-shadow: $shadow-raised;
transition: none;
}
.rd__h {
display: flex;
align-items: center;
gap: $sp-1;
height: $row-h;
padding: 0 $sp-2;
border-bottom: 2px solid $line;
font-size: 11px;
line-height: $row-h;
font-weight: 700;
cursor: grab;
touch-action: none;
user-select: none;
}
.rd__col.is-lift .rd__h { cursor: grabbing; }
.rd__grab {
display: inline-flex;
width: 11px;
height: 11px;
opacity: .55;
}
.rd__c {
height: $row-h;
padding: 0 $sp-2;
font-size: 12px;
line-height: $row-h;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
}
.rd__c + .rd__c { border-top: 1px solid rgba(255, 247, 230, .09); }
// ── 자동 루프 (2s) ───────────────────────────────────
// 10% 에 셋째 열이 떠오르고(그림자 컷) 10~38% 에 왼쪽 끝으로 옮겨 간다. 나머지 두 열은
// 같은 구간에 한 칸씩 오른쪽으로 비킨다 — 세 열의 이동 거리를 하나의 열 폭으로 묶어 둔다.
// 58~84% 에 되돌아오고 84% 뒤에는 아무것도 움직이지 않는다(복귀는 90% 안).
.rd.is-demo .rd__col {
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rd.is-demo .rd__col:nth-child(1),
.rd.is-demo .rd__col:nth-child(2) { animation-name: rd-aside; }
.rd.is-demo .rd__col:nth-child(3) { animation-name: rd-carry; }
.rd.is-demo .rd__col:nth-child(3) {
z-index: 2;
}
.rd.is-demo .rd__slot {
animation-name: rd-slot;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rd-aside {
0% { transform: translateX(0); }
10% { transform: translateX(0); }
38% { transform: translateX($col-w); }
58% { transform: translateX($col-w); }
84% { transform: translateX(0); }
100% { transform: translateX(0); }
}
@keyframes rd-carry {
0% { transform: translateX(0) translateY(0); box-shadow: none; background: $panel; }
10% { transform: translateX(0) translateY(-16px); box-shadow: $shadow-raised; background: $lifted; }
38% { transform: translateX($col-w * -2) translateY(-16px); box-shadow: $shadow-raised; background: $lifted; }
58% { transform: translateX($col-w * -2) translateY(-16px); box-shadow: $shadow-raised; background: $lifted; }
84% { transform: translateX(0) translateY(0); box-shadow: none; background: $panel; }
100% { transform: translateX(0) translateY(0); box-shadow: none; background: $panel; }
}
@keyframes rd-slot {
0% { opacity: 0; }
10% { opacity: 1; }
58% { opacity: 0; }
100% { opacity: 0; }
}
// 320×200 — 세 열이 288px 이라 여백만 줄이고 열 폭은 그대로 둔다
@media (max-width: 340px) {
.rd { padding: 0; }
.rd__slot { left: 0; top: 0; height: 100%; }
.rd__h, .rd__c { padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.rd.is-demo .rd__col,
.rd.is-demo .rd__slot { animation: none !important; }
.rd.is-demo .rd__col:nth-child(3) { box-shadow: $shadow-raised; }
.rd__col { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rd {
position: relative;
box-sizing: border-box;
padding: 4px;
border-radius: 16px;
background: rgb(23.5764705882%, 11.4196078431%, 10.5098039216%);
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.rd__slot {
position: absolute;
left: 4px;
top: 4px;
width: 96px;
height: calc(100% - 8px);
border: 2px dashed rgba(255, 247, 230, 0.42);
border-radius: 8px;
opacity: 0;
}
.rd__cols {
position: relative;
display: flex;
}
.rd__col {
flex: 0 0 auto;
width: 96px;
display: flex;
flex-direction: column;
border-radius: 8px;
background: rgb(23.5764705882%, 11.4196078431%, 10.5098039216%);
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rd__col.is-lift {
z-index: 2;
background: rgb(39.9529411765%, 15.4431372549%, 10.862745098%);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transition: none;
}
.rd__h {
display: flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 8px;
border-bottom: 2px solid rgba(255, 247, 230, 0.16);
font-size: 11px;
line-height: 26px;
font-weight: 700;
cursor: grab;
touch-action: none;
user-select: none;
}
.rd__col.is-lift .rd__h {
cursor: grabbing;
}
.rd__grab {
display: inline-flex;
width: 11px;
height: 11px;
opacity: 0.55;
}
.rd__c {
height: 26px;
padding: 0 8px;
font-size: 12px;
line-height: 26px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
}
.rd__c + .rd__c {
border-top: 1px solid rgba(255, 247, 230, 0.09);
}
.rd.is-demo .rd__col {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.rd.is-demo .rd__col:nth-child(1),
.rd.is-demo .rd__col:nth-child(2) {
animation-name: rd-aside;
}
.rd.is-demo .rd__col:nth-child(3) {
animation-name: rd-carry;
}
.rd.is-demo .rd__col:nth-child(3) {
z-index: 2;
}
.rd.is-demo .rd__slot {
animation-name: rd-slot;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rd-aside {
0% {
transform: translateX(0);
}
10% {
transform: translateX(0);
}
38% {
transform: translateX(96px);
}
58% {
transform: translateX(96px);
}
84% {
transform: translateX(0);
}
100% {
transform: translateX(0);
}
}
@keyframes rd-carry {
0% {
transform: translateX(0) translateY(0);
box-shadow: none;
background: rgb(23.5764705882%, 11.4196078431%, 10.5098039216%);
}
10% {
transform: translateX(0) translateY(-16px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(39.9529411765%, 15.4431372549%, 10.862745098%);
}
38% {
transform: translateX(-192px) translateY(-16px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(39.9529411765%, 15.4431372549%, 10.862745098%);
}
58% {
transform: translateX(-192px) translateY(-16px);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(39.9529411765%, 15.4431372549%, 10.862745098%);
}
84% {
transform: translateX(0) translateY(0);
box-shadow: none;
background: rgb(23.5764705882%, 11.4196078431%, 10.5098039216%);
}
100% {
transform: translateX(0) translateY(0);
box-shadow: none;
background: rgb(23.5764705882%, 11.4196078431%, 10.5098039216%);
}
}
@keyframes rd-slot {
0% {
opacity: 0;
}
10% {
opacity: 1;
}
58% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.rd {
padding: 0;
}
.rd__slot {
left: 0;
top: 0;
height: 100%;
}
.rd__h, .rd__c {
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.rd.is-demo .rd__col,
.rd.is-demo .rd__slot {
animation: none !important;
}
.rd.is-demo .rd__col:nth-child(3) {
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.rd__col {
transition: none;
}
}// 실제 동작: 헤더를 잡으면 그 열이 통째로 떠오르고, 놓을 자리의 열들이 자리를 비켜 준다.
var rd = document.getElementById('rd');
var rdCols = document.getElementById('rd-cols');
var rdW = 96, rdFrom = 0, rdAt = 0, rdTo = 0, rdEl = null;
function rdList() { return [].slice.call(rdCols.children); }
function rdMove(e) {
var dx = e.clientX - rdAt;
rdEl.style.transform = 'translateX(' + dx + 'px)';
rdTo = Math.max(0, Math.min(rdList().length - 1, rdFrom + Math.round(dx / rdW)));
rdList().forEach(function (el, i) {
if (el === rdEl) return;
var shift = 0;
if (rdTo > rdFrom && i > rdFrom && i <= rdTo) shift = -rdW;
if (rdTo < rdFrom && i >= rdTo && i < rdFrom) shift = rdW;
el.style.transform = 'translateX(' + shift + 'px)';
});
}
function rdUp() {
var list = rdList();
list.forEach(function (el) { el.style.transition = 'none'; el.style.transform = ''; });
var after = list[rdTo > rdFrom ? rdTo + 1 : rdTo];
rdCols.insertBefore(rdEl, after || null);
rd.classList.remove('is-drag');
rdEl.classList.remove('is-lift');
requestAnimationFrame(function () { list.forEach(function (el) { el.style.transition = ''; }); });
window.removeEventListener('pointermove', rdMove);
window.removeEventListener('pointerup', rdUp);
rdEl = null;
}
rdList().forEach(function (col) {
col.querySelector('.rd__h').addEventListener('pointerdown', function (e) {
rd.classList.remove('is-demo');
rd.classList.add('is-drag');
rdEl = col; rdEl.classList.add('is-lift');
rdW = col.getBoundingClientRect().width;
rdFrom = rdList().indexOf(col); rdTo = rdFrom; rdAt = e.clientX;
window.addEventListener('pointermove', rdMove);
window.addEventListener('pointerup', rdUp);
});
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ColumnReorderDrag.module.scss";
export type ReorderCol = { key: string; head: string; cells: string[] };
export type ColumnReorderDragProps = {
columns?: ReorderCol[];
tableLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type PtrEvt = { clientX: number };
const DEFAULT_COLS: ReorderCol[] = [
{ key: "name", head: "Name", cells: ["Sky", "Gaon", "Sunset"] },
{ key: "tier", head: "Tier", cells: ["Gold", "Silver", "Bronze"] },
{ key: "points", head: "Points", cells: ["820", "410", "175"] },
];
function Grab() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M208 32v192a16 16 0 0 1-16 16H64a16 16 0 0 1-16-16V32a16 16 0 0 1 16-16h128a16 16 0 0 1 16 16" opacity=".2" />
<path d="M104 60a12 12 0 1 1-12-12a12 12 0 0 1 12 12m60 12a12 12 0 1 0-12-12a12 12 0 0 0 12 12m-72 44a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12m-72 68a12 12 0 1 0 12 12a12 12 0 0 0-12-12m72 0a12 12 0 1 0 12 12a12 12 0 0 0-12-12" />
</g>
</svg>
);
}
function moveItem(list: ReorderCol[], from: number, to: number): ReorderCol[] {
const next = list.slice();
const it = next.splice(from, 1)[0];
next.splice(to, 0, it);
return next;
}
export default function ColumnReorderDrag({
columns = DEFAULT_COLS,
tableLabel = "Member table",
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: ColumnReorderDragProps) {
const [cols, setCols] = useState<ReorderCol[]>(columns);
const [lift, setLift] = useState(-1);
const [target, setTarget] = useState(-1);
const [dx, setDx] = useState(0);
// 놓는 순간 한 프레임만 전환을 끈다 — 바닐라의 transition:none → rAF 복구와 같은 뜻
const [settle, setSettle] = useState(false);
const [live, setLive] = useState(demo);
// ambient useRef 는 항상 T | null 을 돌려준다 — 지역 별칭으로 한 번만 좁혀 쓴다
const dragRef = useRef<{ from: number; to: number; at: number; w: number }>({ from: 0, to: 0, at: 0, w: 96 });
const drag = dragRef.current || { from: 0, to: 0, at: 0, w: 96 };
const elsRef = useRef<(HTMLElement | null)[]>([]);
const els: (HTMLElement | null)[] = elsRef.current || [];
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
useEffect(() => {
if (lift < 0) return;
function move(e: PtrEvt) {
const d = e.clientX - drag.at;
setDx(d);
const t = Math.max(0, Math.min(cols.length - 1, drag.from + Math.round(d / drag.w)));
drag.to = t;
setTarget(t);
}
function up() {
setSettle(true);
setCols(moveItem(cols, drag.from, drag.to));
setLift(-1);
setTarget(-1);
setDx(0);
}
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
return () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
}, [lift, cols]);
useEffect(() => {
if (!settle) return;
const id = requestAnimationFrame(() => setSettle(false));
return () => cancelAnimationFrame(id);
}, [settle]);
function onGrab(i: number, e: PtrEvt) {
setLive(false);
const el = els[i];
drag.w = el ? el.getBoundingClientRect().width : 96;
drag.from = i;
drag.to = i;
drag.at = e.clientX;
setLift(i);
setTarget(i);
setDx(0);
}
function styleOf(i: number): CSSProperties | undefined {
if (settle) return { transition: "none" };
if (lift < 0) return undefined;
if (i === lift) return { transform: `translateX(${dx}px)` };
let shift = 0;
if (target > lift && i > lift && i <= target) shift = -drag.w;
if (target < lift && i >= target && i < lift) shift = drag.w;
return { transform: `translateX(${shift}px)` };
}
return (
<div className={`${styles.root} ${live ? styles.isDemo : ""}`} style={vars}>
<div className={styles.slot} aria-hidden="true" />
<div className={styles.cols} role="grid" aria-label={tableLabel}>
{cols.map((col, i) => (
<div
key={col.key}
className={`${styles.col} ${lift === i ? styles.isLift : ""}`}
style={styleOf(i)}
role="columnheader"
ref={(el: HTMLElement | null) => { els[i] = el; }}
>
<span className={styles.h} onPointerDown={(e: PtrEvt) => onGrab(i, e)}>
<span className={styles.grab} aria-hidden="true"><Grab /></span>
{col.head}
</span>
{col.cells.map((v) => (
<span className={styles.c} key={v}>{v}</span>
))}
</div>
))}
</div>
</div>
);
}03 · 머리를 들어 옮기는 열 순서 바꾸기 — 측정 누적면적 24.881% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
사이에 낀 열들은 방향에 따라 열 폭만큼 비켜서고, 놓일 자리에는 점선 홈이 남습니다.
function rdMove(e) {
var dx = e.clientX - rdAt;
rdEl.style.transform = 'translateX(' + dx + 'px)';
rdTo = Math.max(0, Math.min(rdList().length - 1, rdFrom + Math.round(dx / rdW)));
rdList().forEach(function (el, i) {
if (el === rdEl) return;
var shift = 0;
if (rdTo > rdFrom && i > rdFrom && i <= rdTo) shift = -rdW;
if (rdTo < rdFrom && i >= rdTo && i < rdFrom) shift = rdW;
el.style.transform = 'translateX(' + shift + 'px)';
});
}
04체크를 떼면 열이 사라지는 목록 메뉴
무엇이 보이나
숨긴 열을 display: none 으로 지우면 남은 칸이 제멋대로 벌어집니다. 이 부품은 대신 행의 grid-template-columns 를 1fr 1fr 0fr 로 바꿔 그 칸만 0 으로 접고, 셀에는 min-width: 0 과 넘침 자르기를 걸어 글자가 새지 않게 합니다.

<main class="stage">
<div class="cv is-demo" id="cv">
<div class="cv__bar">
<button class="cv__btn" type="button" id="cv-btn" aria-expanded="false" aria-controls="cv-menu">
<span class="cv__ic" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M128 56c-80 0-112 72-112 72s32 72 112 72s112-72 112-72s-32-72-112-72m0 112a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2"/><path d="M247.31 124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57 61.26 162.88 48 128 48S61.43 61.26 36.34 86.35C17.51 105.18 9 124 8.69 124.76a8 8 0 0 0 0 6.5c.35.79 8.82 19.57 27.65 38.4C61.43 194.74 93.12 208 128 208s66.57-13.26 91.66-38.34c18.83-18.83 27.3-37.61 27.65-38.4a8 8 0 0 0 0-6.5M128 192c-30.78 0-57.67-11.19-79.93-33.25A133.5 133.5 0 0 1 25 128a133.3 133.3 0 0 1 23.07-30.75C70.33 75.19 97.22 64 128 64s57.67 11.19 79.93 33.25A133.5 133.5 0 0 1 231.05 128c-7.21 13.46-38.62 64-103.05 64m0-112a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32"/></g></svg></span>
<span class="cv__btn-t">열</span>
<span class="cv__num"><b class="cv__n3">3</b><b class="cv__n2">2</b></span>
</button>
</div>
<div class="cv__menu" id="cv-menu" role="group" aria-label="열 고르기">
<div class="cv__menu-in">
<button class="cv__opt" type="button" role="checkbox" aria-checked="true" data-col="1"><span class="cv__box" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span>주문</button>
<button class="cv__opt" type="button" role="checkbox" aria-checked="true" data-col="2"><span class="cv__box" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span>수량</button>
<button class="cv__opt" type="button" role="checkbox" aria-checked="true" data-col="3"><span class="cv__box" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span>적립</button>
</div>
</div>
<div class="cv__grid" id="cv-grid" role="grid" aria-label="주문 표">
<div class="cv__row" role="row"><span class="cv__h" data-col="1" role="columnheader">주문</span><span class="cv__h" data-col="2" role="columnheader">수량</span><span class="cv__h" data-col="3" role="columnheader">적립</span></div>
<div class="cv__row" role="row"><span data-col="1" role="gridcell">A-1043</span><span data-col="2" role="gridcell">3</span><span data-col="3" role="gridcell">820</span></div>
<div class="cv__row" role="row"><span data-col="1" role="gridcell">A-1044</span><span data-col="2" role="gridcell">1</span><span data-col="3" role="gridcell">410</span></div>
</div>
</div>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
$row-h: 24px;
$ink: $subject-ink;
$line: color.mix($ink, $stage-paper, 14%);
.cv {
position: relative;
box-sizing: border-box;
width: min(292px, 92vw);
// 열 목록이 절대 배치라 레이아웃 높이에 안 잡힌다. 열린 목록이 무대 밖으로
// 삐져나오지 않도록 그 높이만큼을 아래 여백으로 미리 잡아 둔다 (BD9 함정)
padding-bottom: $sp-4;
color: $ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// ── 위 줄 버튼 ────────────────────────────────────────
.cv__bar { display: flex; height: 28px; }
.cv__btn {
display: inline-flex;
align-items: center;
gap: $sp-1;
height: 24px;
padding: 0 $sp-2;
border: 2px solid $ink;
border-radius: $r-pill;
background: $stage-paper;
color: $ink;
font-family: inherit;
font-size: 11px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cv__btn:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.cv__ic { display: inline-flex; width: 13px; height: 13px; color: $color; }
.cv__num {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: $r-pill;
background: $color;
color: $stage-paper;
font-size: 10px;
line-height: 16px;
}
.cv__n3, .cv__n2 { position: absolute; font-weight: 700; }
.cv__n2 { opacity: 0; }
// ── 열 목록 (절대 배치) ───────────────────────────────
.cv__menu {
position: absolute;
left: 0; top: 28px;
z-index: 3;
display: flex;
flex-direction: column;
width: 116px;
padding: $sp-1;
border: 2px solid $ink;
border-radius: $r-control;
background: $stage-paper;
box-shadow: $shadow-raised;
opacity: 0;
visibility: hidden;
transform: translateY(-6px);
transition: opacity $duration $easing, transform $duration $easing, visibility 0s $duration;
}
.cv.is-open .cv__menu {
opacity: 1;
visibility: visible;
transform: translateY(0);
transition: opacity $duration $easing, transform $duration $easing, visibility 0s;
}
.cv__menu-in { display: flex; flex-direction: column; }
.cv__opt {
display: flex;
align-items: center;
gap: $sp-2;
height: $row-h;
padding: 0 $sp-1;
border: 0;
border-radius: $r-xs;
background: none;
color: $ink;
font-family: inherit;
font-size: 11px;
line-height: $row-h;
font-weight: 600;
text-align: left;
cursor: pointer;
}
.cv__opt:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
.cv__box { display: inline-flex; width: 13px; height: 13px; color: $color; }
.cv__opt[aria-checked="false"] { color: rgba(23, 20, 26, .38); }
.cv__opt[aria-checked="false"] .cv__box { opacity: 0; }
// ── 표 ───────────────────────────────────────────────
.cv__grid {
margin-top: $sp-1;
border: 2px solid $ink;
border-radius: $r-control;
overflow: hidden;
}
.cv__row {
display: grid;
grid-template-columns: var(--t, 1fr 1fr 1fr);
height: $row-h;
}
.cv__row + .cv__row { border-top: 1px solid $line; }
.cv__row:first-child { background: color.mix($ink, $stage-paper, 8%); }
.cv__row > * {
min-width: 0;
padding: 0 $sp-2;
font-size: 11px;
line-height: $row-h;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
}
.cv__h { font-weight: 700; }
.cv__row > .is-off { opacity: 0; }
// ── 자동 루프 (2s) ───────────────────────────────────
// 10% 목록 컷 등장 → 10~40% 세 줄이 시차를 두고 떠오른다(작은 transform 으로 프레임을 채운다)
// → 46% 셋째 열이 0fr 로 컷 접힘 + 배지 3→2 → 64% 되돌림 → 76~86% 줄이 내려앉고 목록 컷 퇴장.
// 닫힘을 열림과 똑같은 컷으로 두면 **닫히는 장의 변화 면적이 더 커서** 그 프레임이 poster 로
// 뽑힌다(248-09 실측과 같은 계열). 그래서 안쪽 통(.cv__menu-in)을 먼저 흐리게 줄여 두고
// 바깥 통만 컷으로 끈다 — 꺼지는 순간에 사라지는 잉크가 적어진다.
.cv.is-demo .cv__menu {
animation-name: cv-menu;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cv.is-demo .cv__menu-in {
animation-name: cv-dim;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
.cv.is-demo .cv__opt {
animation-name: cv-opt;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cv.is-demo .cv__opt:nth-child(2) { animation-delay: 80ms; }
.cv.is-demo .cv__opt:nth-child(3) { animation-delay: 160ms; }
.cv.is-demo .cv__opt:nth-child(3) .cv__box {
animation-name: cv-tick;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cv.is-demo .cv__row {
animation-name: cv-cols;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cv.is-demo .cv__row > :nth-child(3) {
animation-name: cv-fade;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cv.is-demo .cv__n3 {
animation-name: cv-fade;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cv.is-demo .cv__n2 {
animation-name: cv-show;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cv-menu {
0% { opacity: 0; visibility: hidden; transform: translateY(0); }
10% { opacity: 1; visibility: visible; transform: translateY(0); }
86% { opacity: 0; visibility: hidden; transform: translateY(0); }
100% { opacity: 0; visibility: hidden; transform: translateY(0); }
}
@keyframes cv-dim {
0% { opacity: 1; }
74% { opacity: 1; }
86% { opacity: .14; }
100% { opacity: .14; }
}
@keyframes cv-opt {
0% { transform: translateY(-10px); }
10% { transform: translateY(-10px); }
40% { transform: translateY(0); }
76% { transform: translateY(0); }
86% { transform: translateY(-10px); }
100% { transform: translateY(-10px); }
}
@keyframes cv-tick {
0% { opacity: 1; }
46% { opacity: 0; }
64% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes cv-cols {
0% { grid-template-columns: 1fr 1fr 1fr; }
46% { grid-template-columns: 1fr 1fr 0fr; }
64% { grid-template-columns: 1fr 1fr 1fr; }
100% { grid-template-columns: 1fr 1fr 1fr; }
}
@keyframes cv-fade {
0% { opacity: 1; }
46% { opacity: 0; }
64% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes cv-show {
0% { opacity: 0; }
46% { opacity: 1; }
64% { opacity: 0; }
100% { opacity: 0; }
}
// 320×200 — 목록 폭과 셀 여백만 줄인다 (세로는 이미 여유가 없다)
@media (max-width: 340px) {
.cv__menu { width: 104px; }
.cv__row > * { padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.cv.is-demo .cv__menu,
.cv.is-demo .cv__menu-in,
.cv.is-demo .cv__opt,
.cv.is-demo .cv__opt:nth-child(3) .cv__box,
.cv.is-demo .cv__row,
.cv.is-demo .cv__row > :nth-child(3),
.cv.is-demo .cv__n3,
.cv.is-demo .cv__n2 { animation: none !important; }
.cv.is-demo .cv__menu { opacity: 1; visibility: visible; transform: translateY(0); }
.cv__menu { 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);
}
.cv {
position: relative;
box-sizing: border-box;
width: min(292px, 92vw);
padding-bottom: 16px;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cv__bar {
display: flex;
height: 28px;
}
.cv__btn {
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 8px;
border: 2px solid #17141a;
border-radius: 999px;
background: #ffffff;
color: #17141a;
font-family: inherit;
font-size: 11px;
line-height: 20px;
font-weight: 700;
cursor: pointer;
}
.cv__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 2px;
}
.cv__ic {
display: inline-flex;
width: 13px;
height: 13px;
color: #2f6df6;
}
.cv__num {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 999px;
background: #2f6df6;
color: #ffffff;
font-size: 10px;
line-height: 16px;
}
.cv__n3, .cv__n2 {
position: absolute;
font-weight: 700;
}
.cv__n2 {
opacity: 0;
}
.cv__menu {
position: absolute;
left: 0;
top: 28px;
z-index: 3;
display: flex;
flex-direction: column;
width: 116px;
padding: 4px;
border: 2px solid #17141a;
border-radius: 12px;
background: #ffffff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
opacity: 0;
visibility: hidden;
transform: translateY(-6px);
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), visibility 0s 220ms;
}
.cv.is-open .cv__menu {
opacity: 1;
visibility: visible;
transform: translateY(0);
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1), visibility 0s;
}
.cv__menu-in {
display: flex;
flex-direction: column;
}
.cv__opt {
display: flex;
align-items: center;
gap: 8px;
height: 24px;
padding: 0 4px;
border: 0;
border-radius: 4px;
background: none;
color: #17141a;
font-family: inherit;
font-size: 11px;
line-height: 24px;
font-weight: 600;
text-align: left;
cursor: pointer;
}
.cv__opt:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.cv__box {
display: inline-flex;
width: 13px;
height: 13px;
color: #2f6df6;
}
.cv__opt[aria-checked=false] {
color: rgba(23, 20, 26, 0.38);
}
.cv__opt[aria-checked=false] .cv__box {
opacity: 0;
}
.cv__grid {
margin-top: 4px;
border: 2px solid #17141a;
border-radius: 12px;
overflow: hidden;
}
.cv__row {
display: grid;
grid-template-columns: var(--t, 1fr 1fr 1fr);
height: 24px;
}
.cv__row + .cv__row {
border-top: 1px solid rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
}
.cv__row:first-child {
background: rgb(92.7215686275%, 92.6274509804%, 92.8156862745%);
}
.cv__row > * {
min-width: 0;
padding: 0 8px;
font-size: 11px;
line-height: 24px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
}
.cv__h {
font-weight: 700;
}
.cv__row > .is-off {
opacity: 0;
}
.cv.is-demo .cv__menu {
animation-name: cv-menu;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cv.is-demo .cv__menu-in {
animation-name: cv-dim;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
.cv.is-demo .cv__opt {
animation-name: cv-opt;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cv.is-demo .cv__opt:nth-child(2) {
animation-delay: 80ms;
}
.cv.is-demo .cv__opt:nth-child(3) {
animation-delay: 160ms;
}
.cv.is-demo .cv__opt:nth-child(3) .cv__box {
animation-name: cv-tick;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cv.is-demo .cv__row {
animation-name: cv-cols;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cv.is-demo .cv__row > :nth-child(3) {
animation-name: cv-fade;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cv.is-demo .cv__n3 {
animation-name: cv-fade;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cv.is-demo .cv__n2 {
animation-name: cv-show;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cv-menu {
0% {
opacity: 0;
visibility: hidden;
transform: translateY(0);
}
10% {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
86% {
opacity: 0;
visibility: hidden;
transform: translateY(0);
}
100% {
opacity: 0;
visibility: hidden;
transform: translateY(0);
}
}
@keyframes cv-dim {
0% {
opacity: 1;
}
74% {
opacity: 1;
}
86% {
opacity: 0.14;
}
100% {
opacity: 0.14;
}
}
@keyframes cv-opt {
0% {
transform: translateY(-10px);
}
10% {
transform: translateY(-10px);
}
40% {
transform: translateY(0);
}
76% {
transform: translateY(0);
}
86% {
transform: translateY(-10px);
}
100% {
transform: translateY(-10px);
}
}
@keyframes cv-tick {
0% {
opacity: 1;
}
46% {
opacity: 0;
}
64% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes cv-cols {
0% {
grid-template-columns: 1fr 1fr 1fr;
}
46% {
grid-template-columns: 1fr 1fr 0fr;
}
64% {
grid-template-columns: 1fr 1fr 1fr;
}
100% {
grid-template-columns: 1fr 1fr 1fr;
}
}
@keyframes cv-fade {
0% {
opacity: 1;
}
46% {
opacity: 0;
}
64% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes cv-show {
0% {
opacity: 0;
}
46% {
opacity: 1;
}
64% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.cv__menu {
width: 104px;
}
.cv__row > * {
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.cv.is-demo .cv__menu,
.cv.is-demo .cv__menu-in,
.cv.is-demo .cv__opt,
.cv.is-demo .cv__opt:nth-child(3) .cv__box,
.cv.is-demo .cv__row,
.cv.is-demo .cv__row > :nth-child(3),
.cv.is-demo .cv__n3,
.cv.is-demo .cv__n2 {
animation: none !important;
}
.cv.is-demo .cv__menu {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.cv__menu {
transition: none;
}
}// 실제 동작: 체크를 떼면 그 열의 셀 전부에 is-off 가 붙어 표에서 빠지고 버튼 옆 숫자가 줄어든다.
var cv = document.getElementById('cv');
var cvBtn = document.getElementById('cv-btn');
var cvGrid = document.getElementById('cv-grid');
var cvOn = { 1: true, 2: true, 3: true };
function cvPaint() {
var live = 0, track = [];
['1', '2', '3'].forEach(function (k) { if (cvOn[k]) live++; track.push(cvOn[k] ? '1fr' : '0fr'); });
[].forEach.call(cvGrid.querySelectorAll('[data-col]'), function (el) {
el.classList.toggle('is-off', !cvOn[el.dataset.col]);
});
cv.style.setProperty('--t', track.join(' '));
cvBtn.querySelector('.cv__n3').textContent = String(live);
}
cvBtn.addEventListener('click', function () {
cv.classList.remove('is-demo');
var open = cvBtn.getAttribute('aria-expanded') === 'true';
cvBtn.setAttribute('aria-expanded', open ? 'false' : 'true');
cv.classList.toggle('is-open', !open);
});
[].forEach.call(cv.querySelectorAll('.cv__opt'), function (opt) {
opt.addEventListener('click', function () {
cv.classList.remove('is-demo');
var next = opt.getAttribute('aria-checked') !== 'true';
var live = 0;
Object.keys(cvOn).forEach(function (k) { if (cvOn[k]) live++; });
if (!next && live < 2) return; // 마지막 한 열은 끄지 않는다
cvOn[opt.dataset.col] = next;
opt.setAttribute('aria-checked', next ? 'true' : 'false');
cvPaint();
});
});
cvPaint();import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ColumnVisibilityMenu.module.scss";
export type ColumnVisibilityMenuProps = {
buttonLabel?: string;
menuLabel?: string;
tableLabel?: string;
/** 열 이름 — 목록의 체크 항목이자 표의 머리글 */
columns?: string[];
/** 본문 행 — 열 개수와 길이를 맞춘다 */
rows?: string[][];
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--t"?: string;
};
function EyeIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M128 56c-80 0-112 72-112 72s32 72 112 72s112-72 112-72s-32-72-112-72m0 112a40 40 0 1 1 40-40a40 40 0 0 1-40 40" opacity=".2" />
<path d="M247.31 124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57 61.26 162.88 48 128 48S61.43 61.26 36.34 86.35C17.51 105.18 9 124 8.69 124.76a8 8 0 0 0 0 6.5c.35.79 8.82 19.57 27.65 38.4C61.43 194.74 93.12 208 128 208s66.57-13.26 91.66-38.34c18.83-18.83 27.3-37.61 27.65-38.4a8 8 0 0 0 0-6.5M128 192c-30.78 0-57.67-11.19-79.93-33.25A133.5 133.5 0 0 1 25 128a133.3 133.3 0 0 1 23.07-30.75C70.33 75.19 97.22 64 128 64s57.67 11.19 79.93 33.25A133.5 133.5 0 0 1 231.05 128c-7.21 13.46-38.62 64-103.05 64m0-112a48 48 0 1 0 48 48a48.05 48.05 0 0 0-48-48m0 80a32 32 0 1 1 32-32a32 32 0 0 1-32 32" />
</g>
</svg>
);
}
function CheckIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2" />
<path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32" />
</g>
</svg>
);
}
export default function ColumnVisibilityMenu({
buttonLabel = "Columns",
menuLabel = "Choose columns",
tableLabel = "Order table",
columns = ["Order", "Qty", "Points"],
rows = [
["A-1043", "3", "820"],
["A-1044", "1", "410"],
],
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ColumnVisibilityMenuProps) {
const [on, setOn] = useState<boolean[]>(columns.map(() => true));
const [open, setOpen] = useState(false);
const [live, setLive] = useState(demo);
const shown = on.filter(Boolean).length;
// 켜진 열만 1fr, 끈 열은 0fr 로 접는다
const track = on.map((v) => (v ? "1fr" : "0fr")).join(" ");
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--t": track,
};
const cls = [styles.root, live ? styles.isDemo : "", open ? styles.isOpen : ""].join(" ");
const toggle = (i: number) => {
setLive(false);
if (on[i] && shown < 2) return; // 마지막 한 열은 끄지 않는다
setOn(on.map((v, k) => (k === i ? !v : v)));
};
return (
<div className={cls} style={vars}>
<div className={styles.bar}>
<button
className={styles.btn}
type="button"
aria-expanded={open}
aria-controls="cv-menu"
onClick={() => {
setLive(false);
setOpen(!open);
}}
>
<span className={styles.ic} aria-hidden="true">
<EyeIcon />
</span>
<span className={styles.btnT}>{buttonLabel}</span>
<span className={styles.num}>
<b className={styles.n3}>{shown}</b>
<b className={styles.n2}>2</b>
</span>
</button>
</div>
<div className={styles.menu} id="cv-menu" role="group" aria-label={menuLabel}>
<div className={styles.menuIn}>
{columns.map((name, i) => (
<button
key={name}
className={styles.opt}
type="button"
role="checkbox"
aria-checked={on[i]}
onClick={() => toggle(i)}
>
<span className={styles.box} aria-hidden="true">
<CheckIcon />
</span>
{name}
</button>
))}
</div>
</div>
<div className={styles.grid} role="grid" aria-label={tableLabel}>
<div className={styles.row} role="row">
{columns.map((name, i) => (
<span
key={name}
className={[styles.h, on[i] ? "" : styles.off].join(" ")}
role="columnheader"
>
{name}
</span>
))}
</div>
{rows.map((row, ri) => (
<div className={styles.row} role="row" key={ri}>
{row.map((cell, i) => (
<span key={i} className={on[i] ? "" : styles.off} role="gridcell">
{cell}
</span>
))}
</div>
))}
</div>
</div>
);
}04 · 체크를 떼면 열이 사라지는 목록 메뉴 — 측정 누적면적 7.581% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
마지막 한 열은 끄지 못하게 막아 두었습니다.
function cvPaint() {
var live = 0, track = [];
['1', '2', '3'].forEach(function (k) { if (cvOn[k]) live++; track.push(cvOn[k] ? '1fr' : '0fr'); });
[].forEach.call(cvGrid.querySelectorAll('[data-col]'), function (el) {
el.classList.toggle('is-off', !cvOn[el.dataset.col]);
});
cv.style.setProperty('--t', track.join(' '));
cvBtn.querySelector('.cv__n3').textContent = String(live);
}
05셀을 끌어 네모로 묶고 복사하기
화면에서 일어나는 일
누른 칸이 기준점이고 지금 가리키는 칸이 반대 모서리입니다. 두 점의 행·열 최솟값과 최댓값이 곧 사각형이고, 선택 테두리는 칸 하나 크기의 요소를 scale(가로칸수, 세로칸수) 로 늘려 만듭니다.

<main class="stage">
<div class="rs is-demo" id="rs">
<div class="rs__grid" id="rs-grid" role="grid" aria-label="주간 판매">
<div class="rs__row rs__row--head" role="row">
<span role="columnheader">월</span><span role="columnheader">화</span><span role="columnheader">수</span><span role="columnheader">목</span>
</div>
<div class="rs__row" role="row"><b data-r="0" data-c="0" role="gridcell">12</b><b data-r="0" data-c="1" role="gridcell">18</b><b data-r="0" data-c="2" role="gridcell">9</b><b data-r="0" data-c="3" role="gridcell">24</b></div>
<div class="rs__row" role="row"><b data-r="1" data-c="0" role="gridcell">7</b><b data-r="1" data-c="1" role="gridcell">15</b><b data-r="1" data-c="2" role="gridcell">21</b><b data-r="1" data-c="3" role="gridcell">6</b></div>
<div class="rs__row" role="row"><b data-r="2" data-c="0" role="gridcell">30</b><b data-r="2" data-c="1" role="gridcell">11</b><b data-r="2" data-c="2" role="gridcell">3</b><b data-r="2" data-c="3" role="gridcell">17</b></div>
<div class="rs__box" id="rs-box" aria-hidden="true"></div>
</div>
<p class="rs__foot" id="rs-foot" role="status">
<span class="rs__s0">끌어서 고르세요</span>
<span class="rs__s1">1칸 · 합계 12</span>
<span class="rs__s2">4칸 · 합계 52</span>
<span class="rs__s3">9칸 · 합계 126</span>
</p>
<span class="rs__toast" aria-hidden="true"><span class="rs__toast-in"><span class="rs__ic"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg></span>복사했어요</span></span>
</div>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-sky;
@include stage($stage-ink);
$cell-w: 66px;
$cell-h: 26px;
$line: color.mix($subject-cream, $stage-ink, 14%);
.rs {
position: relative;
box-sizing: border-box;
width: 264px;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.rs__grid {
position: relative;
border: 1px solid $line;
border-radius: $r-control;
background: $stage-ink;
overflow: hidden;
}
.rs__row {
display: grid;
grid-template-columns: repeat(4, $cell-w);
height: $cell-h;
}
.rs__row + .rs__row { border-top: 1px solid rgba(255, 247, 230, .07); }
.rs__row > * {
padding: 0 $sp-2;
font-size: 12px;
line-height: $cell-h;
font-weight: 600;
text-align: right;
overflow: hidden;
white-space: nowrap;
transition: background $duration $easing;
}
.rs__row--head {
background: color.mix($subject-cream, $stage-ink, 8%);
}
.rs__row--head > * {
color: rgba(255, 247, 230, .56);
font-size: 11px;
font-weight: 700;
text-align: center;
}
.rs__row > .is-sel { background: rgba(110, 200, 255, .22); }
// ── 범위 네모 — 셀 하나 크기를 기준으로 늘린다 ────────
.rs__box {
position: absolute;
left: 0; top: 0;
width: $cell-w;
height: $cell-h;
border: 2px solid $color;
border-radius: $r-xs;
opacity: 0;
transform-origin: left top; // 규칙8: 앵커셀 좌표 계산 기준
transform: translate(0, $cell-h) scale(1, 1);
pointer-events: none;
}
// ── 아래 띠 ──────────────────────────────────────────
.rs__foot {
position: relative;
height: 20px;
margin: $sp-2 0 0;
font-size: 11px;
line-height: 20px;
font-weight: 600;
}
.rs__s0, .rs__s1, .rs__s2, .rs__s3 {
position: absolute;
left: 0; top: 0;
color: rgba(255, 247, 230, .62);
}
.rs__s1, .rs__s2, .rs__s3 { opacity: 0; color: $color; }
// ── 복사 쪽지 ────────────────────────────────────────
.rs__toast {
position: absolute;
right: 0; bottom: 24px;
opacity: 0;
}
.rs.is-copied .rs__toast { opacity: 1; }
.rs__toast-in {
display: inline-flex;
align-items: center;
gap: $sp-1;
padding: 0 $sp-2;
border-radius: $r-pill;
background: $subject-mint;
color: $subject-ink;
font-size: 11px;
line-height: 20px;
font-weight: 700;
transform: translateY(0);
}
.rs__ic { display: inline-flex; width: 12px; height: 12px; }
// ── 자동 루프 (2s) ───────────────────────────────────
// 10% 에 한 칸에서 시작해 44% 까지 세 칸 × 세 줄로 자란다(scale 하나로 — 네 변을 따로 재면 어긋난다).
// 칸 색과 아래 띠 문구는 네모가 그 칸에 닿는 순간마다 컷으로 바뀐다. 62% 에 쪽지가 튀어오르고
// 78~88% 에 한 칸으로 되돌아온다 — 복귀가 90% 안이라 24장 표본에 다 잡힌다.
.rs.is-demo .rs__box {
animation-name: rs-box;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.rs.is-demo .rs__row > b {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
// 네모가 1×1 → 2×2 → 3×3 으로 자라며 덮는 칸만 그 순간에 켠다
.rs.is-demo .rs__row:nth-child(2) > b:nth-child(1) { animation-name: rs-lit-a; }
.rs.is-demo .rs__row:nth-child(2) > b:nth-child(2),
.rs.is-demo .rs__row:nth-child(3) > b:nth-child(1),
.rs.is-demo .rs__row:nth-child(3) > b:nth-child(2) { animation-name: rs-lit-b; }
.rs.is-demo .rs__row:nth-child(2) > b:nth-child(3),
.rs.is-demo .rs__row:nth-child(3) > b:nth-child(3),
.rs.is-demo .rs__row:nth-child(4) > b:nth-child(1),
.rs.is-demo .rs__row:nth-child(4) > b:nth-child(2),
.rs.is-demo .rs__row:nth-child(4) > b:nth-child(3) { animation-name: rs-lit-c; }
.rs.is-demo .rs__s0 {
animation-name: rs-hide;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__s1 {
animation-name: rs-s1;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__s2 {
animation-name: rs-s2;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__s3 {
animation-name: rs-s3;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__toast {
animation-name: rs-toast;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__toast-in {
animation-name: rs-pop;
animation-duration: $dur-loop;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
// 줄어드는 구간에도 네모 크기와 아래 띠 문구가 **같은 순간에** 한 단계씩 내려간다.
// 네모만 줄이고 문구를 늦게 끄면 「2×2 네모 옆에 9칸·합계 126」 같은 거짓 장이 poster 로 뽑힌다 (실측)
@keyframes rs-box {
0% { opacity: 0; transform: translate(0, $cell-h) scale(1, 1); }
10% { opacity: 1; transform: translate(0, $cell-h) scale(1, 1); }
44% { opacity: 1; transform: translate(0, $cell-h) scale(3, 3); }
76% { opacity: 1; transform: translate(0, $cell-h) scale(3, 3); }
80% { opacity: 1; transform: translate(0, $cell-h) scale(2, 2); }
84% { opacity: 1; transform: translate(0, $cell-h) scale(1, 1); }
86% { opacity: 0; transform: translate(0, $cell-h) scale(1, 1); }
100% { opacity: 0; transform: translate(0, $cell-h) scale(1, 1); }
}
// 켜는 것과 끄는 것이 같은 크기면 아홉 칸이 한꺼번에 꺼지는 장이 제일 크게 변해 poster 가 된다.
// 그래서 끄는 쪽은 켠 순서의 역순으로 흩어 둔다 — 한 장에서 꺼지는 칸 수를 다섯 → 넷 → 하나로.
@keyframes rs-lit-a {
0% { background: none; }
10% { background: rgba(110, 200, 255, .22); }
86% { background: none; }
100% { background: none; }
}
@keyframes rs-s1 {
0% { opacity: 0; }
10% { opacity: 1; }
26% { opacity: 0; }
82% { opacity: 1; }
86% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes rs-lit-b {
0% { background: none; }
26% { background: rgba(110, 200, 255, .22); }
81% { background: none; }
100% { background: none; }
}
@keyframes rs-lit-c {
0% { background: none; }
40% { background: rgba(110, 200, 255, .22); }
78% { background: none; }
100% { background: none; }
}
@keyframes rs-hide {
0% { opacity: 1; }
10% { opacity: 0; }
88% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes rs-s2 {
0% { opacity: 0; }
26% { opacity: 1; }
40% { opacity: 0; }
78% { opacity: 1; }
82% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes rs-s3 {
0% { opacity: 0; }
40% { opacity: 1; }
78% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes rs-toast {
0% { opacity: 0; }
62% { opacity: 1; }
80% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes rs-pop {
0% { transform: translateY(10px); }
62% { transform: translateY(10px); }
70% { transform: translateY(0); }
80% { transform: translateY(0); }
100% { transform: translateY(0); }
}
// 320×200 — 264px 격자가 그대로 들어가므로 아래 띠 여백만 줄인다
@media (max-width: 340px) {
.rs__foot { margin-top: $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.rs.is-demo .rs__box,
.rs.is-demo .rs__row > b,
.rs.is-demo .rs__s0,
.rs.is-demo .rs__s1,
.rs.is-demo .rs__s2,
.rs.is-demo .rs__s3,
.rs.is-demo .rs__toast,
.rs.is-demo .rs__toast-in { animation: none !important; }
.rs.is-demo .rs__box { opacity: 1; transform: translate(0, $cell-h) scale(3, 3); }
.rs.is-demo .rs__s0 { opacity: 0; }
.rs.is-demo .rs__s3 { opacity: 1; }
.rs__row > * { 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);
}
.rs {
position: relative;
box-sizing: border-box;
width: 264px;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.rs__grid {
position: relative;
border: 1px solid rgb(21.7568627451%, 20.3058823529%, 21.3960784314%);
border-radius: 12px;
background: #17141a;
overflow: hidden;
}
.rs__row {
display: grid;
grid-template-columns: repeat(4, 66px);
height: 26px;
}
.rs__row + .rs__row {
border-top: 1px solid rgba(255, 247, 230, 0.07);
}
.rs__row > * {
padding: 0 8px;
font-size: 12px;
line-height: 26px;
font-weight: 600;
text-align: right;
overflow: hidden;
white-space: nowrap;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.rs__row--head {
background: rgb(16.2980392157%, 14.9647058824%, 16.5960784314%);
}
.rs__row--head > * {
color: rgba(255, 247, 230, 0.56);
font-size: 11px;
font-weight: 700;
text-align: center;
}
.rs__row > .is-sel {
background: rgba(110, 200, 255, 0.22);
}
.rs__box {
position: absolute;
left: 0;
top: 0;
width: 66px;
height: 26px;
border: 2px solid #6ec8ff;
border-radius: 4px;
opacity: 0;
transform-origin: left top;
transform: translate(0, 26px) scale(1, 1);
pointer-events: none;
}
.rs__foot {
position: relative;
height: 20px;
margin: 8px 0 0;
font-size: 11px;
line-height: 20px;
font-weight: 600;
}
.rs__s0, .rs__s1, .rs__s2, .rs__s3 {
position: absolute;
left: 0;
top: 0;
color: rgba(255, 247, 230, 0.62);
}
.rs__s1, .rs__s2, .rs__s3 {
opacity: 0;
color: #6ec8ff;
}
.rs__toast {
position: absolute;
right: 0;
bottom: 24px;
opacity: 0;
}
.rs.is-copied .rs__toast {
opacity: 1;
}
.rs__toast-in {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0 8px;
border-radius: 999px;
background: #4cd4a6;
color: #17141a;
font-size: 11px;
line-height: 20px;
font-weight: 700;
transform: translateY(0);
}
.rs__ic {
display: inline-flex;
width: 12px;
height: 12px;
}
.rs.is-demo .rs__box {
animation-name: rs-box;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__row > b {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__row:nth-child(2) > b:nth-child(1) {
animation-name: rs-lit-a;
}
.rs.is-demo .rs__row:nth-child(2) > b:nth-child(2),
.rs.is-demo .rs__row:nth-child(3) > b:nth-child(1),
.rs.is-demo .rs__row:nth-child(3) > b:nth-child(2) {
animation-name: rs-lit-b;
}
.rs.is-demo .rs__row:nth-child(2) > b:nth-child(3),
.rs.is-demo .rs__row:nth-child(3) > b:nth-child(3),
.rs.is-demo .rs__row:nth-child(4) > b:nth-child(1),
.rs.is-demo .rs__row:nth-child(4) > b:nth-child(2),
.rs.is-demo .rs__row:nth-child(4) > b:nth-child(3) {
animation-name: rs-lit-c;
}
.rs.is-demo .rs__s0 {
animation-name: rs-hide;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__s1 {
animation-name: rs-s1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__s2 {
animation-name: rs-s2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__s3 {
animation-name: rs-s3;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__toast {
animation-name: rs-toast;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.rs.is-demo .rs__toast-in {
animation-name: rs-pop;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
@keyframes rs-box {
0% {
opacity: 0;
transform: translate(0, 26px) scale(1, 1);
}
10% {
opacity: 1;
transform: translate(0, 26px) scale(1, 1);
}
44% {
opacity: 1;
transform: translate(0, 26px) scale(3, 3);
}
76% {
opacity: 1;
transform: translate(0, 26px) scale(3, 3);
}
80% {
opacity: 1;
transform: translate(0, 26px) scale(2, 2);
}
84% {
opacity: 1;
transform: translate(0, 26px) scale(1, 1);
}
86% {
opacity: 0;
transform: translate(0, 26px) scale(1, 1);
}
100% {
opacity: 0;
transform: translate(0, 26px) scale(1, 1);
}
}
@keyframes rs-lit-a {
0% {
background: none;
}
10% {
background: rgba(110, 200, 255, 0.22);
}
86% {
background: none;
}
100% {
background: none;
}
}
@keyframes rs-s1 {
0% {
opacity: 0;
}
10% {
opacity: 1;
}
26% {
opacity: 0;
}
82% {
opacity: 1;
}
86% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes rs-lit-b {
0% {
background: none;
}
26% {
background: rgba(110, 200, 255, 0.22);
}
81% {
background: none;
}
100% {
background: none;
}
}
@keyframes rs-lit-c {
0% {
background: none;
}
40% {
background: rgba(110, 200, 255, 0.22);
}
78% {
background: none;
}
100% {
background: none;
}
}
@keyframes rs-hide {
0% {
opacity: 1;
}
10% {
opacity: 0;
}
88% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes rs-s2 {
0% {
opacity: 0;
}
26% {
opacity: 1;
}
40% {
opacity: 0;
}
78% {
opacity: 1;
}
82% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes rs-s3 {
0% {
opacity: 0;
}
40% {
opacity: 1;
}
78% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes rs-toast {
0% {
opacity: 0;
}
62% {
opacity: 1;
}
80% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes rs-pop {
0% {
transform: translateY(10px);
}
62% {
transform: translateY(10px);
}
70% {
transform: translateY(0);
}
80% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@media (max-width: 340px) {
.rs__foot {
margin-top: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.rs.is-demo .rs__box,
.rs.is-demo .rs__row > b,
.rs.is-demo .rs__s0,
.rs.is-demo .rs__s1,
.rs.is-demo .rs__s2,
.rs.is-demo .rs__s3,
.rs.is-demo .rs__toast,
.rs.is-demo .rs__toast-in {
animation: none !important;
}
.rs.is-demo .rs__box {
opacity: 1;
transform: translate(0, 26px) scale(3, 3);
}
.rs.is-demo .rs__s0 {
opacity: 0;
}
.rs.is-demo .rs__s3 {
opacity: 1;
}
.rs__row > * {
transition: none;
}
}// 실제 동작: 셀에서 셀로 끌면 두 지점이 만드는 네모가 선택 범위다. 놓으면 탭으로 이어 붙여 복사한다.
var rs = document.getElementById('rs');
var rsGrid = document.getElementById('rs-grid');
var rsBox = document.getElementById('rs-box');
var rsFoot = document.getElementById('rs-foot');
var RS_W = 66, RS_H = 26;
var rsAnchor = null, rsOn = false;
function rsCells() { return [].slice.call(rsGrid.querySelectorAll('[data-r]')); }
function rsPaint(to) {
var r0 = Math.min(rsAnchor.r, to.r), r1 = Math.max(rsAnchor.r, to.r);
var c0 = Math.min(rsAnchor.c, to.c), c1 = Math.max(rsAnchor.c, to.c);
var sum = 0, n = 0, text = [];
rsCells().forEach(function (el) {
var r = +el.dataset.r, c = +el.dataset.c;
var inside = r >= r0 && r <= r1 && c >= c0 && c <= c1;
el.classList.toggle('is-sel', inside);
if (inside) { sum += +el.textContent; n++; }
});
rsBox.style.opacity = '1';
rsBox.style.transform = 'translate(' + (c0 * RS_W) + 'px, ' + ((r0 + 1) * RS_H) + 'px) scale(' + (c1 - c0 + 1) + ', ' + (r1 - r0 + 1) + ')';
rsFoot.querySelector('.rs__s0').textContent = n + '칸 · 합계 ' + sum;
return text;
}
rsGrid.addEventListener('pointerdown', function (e) {
var el = e.target.closest('[data-r]');
if (!el) return;
rs.classList.remove('is-demo');
rsAnchor = { r: +el.dataset.r, c: +el.dataset.c };
rsOn = true; rsPaint(rsAnchor);
});
rsGrid.addEventListener('pointermove', function (e) {
if (!rsOn) return;
var el = document.elementFromPoint(e.clientX, e.clientY);
el = el && el.closest ? el.closest('[data-r]') : null;
if (el) rsPaint({ r: +el.dataset.r, c: +el.dataset.c });
});
window.addEventListener('pointerup', function () {
if (!rsOn) return;
rsOn = false;
var rows = {};
rsCells().forEach(function (el) {
if (!el.classList.contains('is-sel')) return;
(rows[el.dataset.r] = rows[el.dataset.r] || []).push(el.textContent);
});
var text = Object.keys(rows).map(function (k) { return rows[k].join('\t'); }).join('\n');
if (navigator.clipboard) { navigator.clipboard.writeText(text).catch(function () {}); }
rs.classList.add('is-copied');
setTimeout(function () { rs.classList.remove('is-copied'); }, 1200);
});import { useEffect, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./CellRangeSelect.module.scss";
export type CellRangeSelectProps = {
tableLabel?: string;
/** 머리글 — 열 개수와 데이터 폭을 맞춘다 */
head?: string[];
/** 본문 숫자 */
data?: number[][];
hint?: string;
cellWord?: string;
cellsWord?: string;
sumWord?: string;
copiedLabel?: string;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type PtrEvt = { clientX: number; clientY: number; target: any };
type Cell = { r: number; c: number };
type Rect = { r0: number; r1: number; c0: number; c1: number };
// 셀 한 칸의 크기 — 범위 네모를 scale 로 늘릴 때의 기준
const CELL_W = 66;
const CELL_H = 26;
function CopyIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M216 40v128h-48V88H88V40Z" opacity=".2" />
<path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z" />
</g>
</svg>
);
}
// 이벤트가 떨어진 자리의 셀 좌표 — data-r · data-c 를 읽는다
function cellOf(node: any): Cell | null {
const el = node && node.closest ? node.closest("[data-r]") : null;
if (!el) return null;
return { r: Number(el.getAttribute("data-r")), c: Number(el.getAttribute("data-c")) };
}
export default function CellRangeSelect({
tableLabel = "Weekly sales",
head = ["Mon", "Tue", "Wed", "Thu"],
data = [
[12, 18, 9, 24],
[7, 15, 21, 6],
[30, 11, 3, 17],
],
hint = "Drag to select",
cellWord = "cell",
cellsWord = "cells",
sumWord = "sum",
copiedLabel = "Copied",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#6ec8ff",
}: CellRangeSelectProps) {
const [anchor, setAnchor] = useState<Cell | null>(null);
const [sel, setSel] = useState<Rect | null>(null);
const [dragging, setDragging] = useState(false);
const [copied, setCopied] = useState(false);
const [live, setLive] = useState(demo);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [styles.root, live ? styles.isDemo : "", copied ? styles.isCopied : ""].join(" ");
const inside = (r: number, c: number) =>
!!sel && r >= sel.r0 && r <= sel.r1 && c >= sel.c0 && c <= sel.c1;
// 놓는 순간 탭·줄바꿈으로 이어 붙여 클립보드에 넣는다
useEffect(() => {
if (!dragging) return;
const up = () => {
setDragging(false);
if (!sel) return;
const lines: string[] = [];
for (let r = sel.r0; r <= sel.r1; r++) {
lines.push(data[r].slice(sel.c0, sel.c1 + 1).map((n) => String(n)).join("\t"));
}
if (navigator.clipboard) navigator.clipboard.writeText(lines.join("\n")).catch(() => {});
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
};
window.addEventListener("pointerup", up);
return () => window.removeEventListener("pointerup", up);
}, [dragging, sel, data]);
const paint = (from: Cell, to: Cell) => {
setSel({
r0: Math.min(from.r, to.r),
r1: Math.max(from.r, to.r),
c0: Math.min(from.c, to.c),
c1: Math.max(from.c, to.c),
});
};
const onDown = (e: PtrEvt) => {
const cell = cellOf(e.target);
if (!cell) return;
setLive(false);
setAnchor(cell);
setDragging(true);
paint(cell, cell);
};
const onMove = (e: PtrEvt) => {
if (!dragging || !anchor) return;
const cell = cellOf(document.elementFromPoint(e.clientX, e.clientY));
if (cell) paint(anchor, cell);
};
let count = 0;
let sum = 0;
data.forEach((row, r) =>
row.forEach((n, c) => {
if (inside(r, c)) {
count += 1;
sum += n;
}
})
);
const fmt = (n: number, s: number) => `${n} ${n === 1 ? cellWord : cellsWord} · ${sumWord} ${s}`;
const boxStyle: CSSProperties = sel
? {
opacity: 1,
transform: `translate(${sel.c0 * CELL_W}px, ${(sel.r0 + 1) * CELL_H}px) scale(${
sel.c1 - sel.c0 + 1
}, ${sel.r1 - sel.r0 + 1})`,
}
: {};
return (
<div className={cls} style={vars}>
<div
className={styles.grid}
role="grid"
aria-label={tableLabel}
onPointerDown={onDown}
onPointerMove={onMove}
>
<div className={[styles.row, styles.rowHead].join(" ")} role="row">
{head.map((h) => (
<span key={h} role="columnheader">
{h}
</span>
))}
</div>
{data.map((row, r) => (
<div className={styles.row} role="row" key={r}>
{row.map((n, c) => (
<b
key={c}
data-r={r}
data-c={c}
role="gridcell"
className={inside(r, c) ? styles.sel : ""}
>
{n}
</b>
))}
</div>
))}
<div className={styles.box} style={boxStyle} aria-hidden="true" />
</div>
<p className={styles.foot} role="status">
<span className={styles.s0}>{sel ? fmt(count, sum) : hint}</span>
<span className={styles.s1}>{fmt(1, 12)}</span>
<span className={styles.s2}>{fmt(4, 52)}</span>
<span className={styles.s3}>{fmt(9, 126)}</span>
</p>
<span className={styles.toast} aria-hidden="true">
<span className={styles.toastIn}>
<span className={styles.ic}>
<CopyIcon />
</span>
{copiedLabel}
</span>
</span>
</div>
);
}05 · 셀을 끌어 네모로 묶고 복사하기 — 측정 누적면적 13.154% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
손을 놓으면 행은 줄바꿈, 열은 탭으로 이어 붙여 클립보드로 넘깁니다.
function rsPaint(to) {
var r0 = Math.min(rsAnchor.r, to.r), r1 = Math.max(rsAnchor.r, to.r);
var c0 = Math.min(rsAnchor.c, to.c), c1 = Math.max(rsAnchor.c, to.c);
var sum = 0, n = 0;
rsCells().forEach(function (el) {
var r = +el.dataset.r, c = +el.dataset.c;
var inside = r >= r0 && r <= r1 && c >= c0 && c <= c1;
el.classList.toggle('is-sel', inside);
if (inside) { sum += +el.textContent; n++; }
});
rsBox.style.opacity = '1';
rsBox.style.transform = 'translate(' + (c0 * RS_W) + 'px, ' + ((r0 + 1) * RS_H) + 'px) scale(' + (c1 - c0 + 1) + ', ' + (r1 - r0 + 1) + ')';
rsFoot.querySelector('.rs__s0').textContent = n + '칸 · 합계 ' + sum;
}
06열마다 따로 거르는 필터 알약
어떤 움직임인가
조건이 두 개 이상 걸리면 사람은 곧 왜 줄이 줄었는지 잊습니다. 그래서 지금 걸린 조건을 표 위에 알약으로 쌓아 두고, 걸러진 줄은 지우지 않고 높이만 0 으로 접습니다.

<main class="stage">
<div class="mf is-demo" id="mf">
<div class="mf__chips" id="mf-chips">
<span class="mf__chip mf__chip--1">
<span class="mf__fic" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M221.9 61.38L152 136v58.65a8 8 0 0 1-3.56 6.66l-32 21.33A8 8 0 0 1 104 216v-80L34.1 61.38A8 8 0 0 1 40 48h176a8 8 0 0 1 5.9 13.38" opacity=".2"/><path d="M230.6 49.53A15.81 15.81 0 0 0 216 40H40a16 16 0 0 0-11.81 26.76l.08.09L96 139.17V216a16 16 0 0 0 24.87 13.32l32-21.34a16 16 0 0 0 7.13-13.32v-55.49l67.74-72.32l.08-.09a15.8 15.8 0 0 0 2.78-17.23m-84.42 81.05A8 8 0 0 0 144 136v58.66L112 216v-80a8 8 0 0 0-2.16-5.47L40 56h176Z"/></g></svg></span>이름 민</span>
<span class="mf__chip mf__chip--2">
<span class="mf__fic" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M221.9 61.38L152 136v58.65a8 8 0 0 1-3.56 6.66l-32 21.33A8 8 0 0 1 104 216v-80L34.1 61.38A8 8 0 0 1 40 48h176a8 8 0 0 1 5.9 13.38" opacity=".2"/><path d="M230.6 49.53A15.81 15.81 0 0 0 216 40H40a16 16 0 0 0-11.81 26.76l.08.09L96 139.17V216a16 16 0 0 0 24.87 13.32l32-21.34a16 16 0 0 0 7.13-13.32v-55.49l67.74-72.32l.08-.09a15.8 15.8 0 0 0 2.78-17.23m-84.42 81.05A8 8 0 0 0 144 136v58.66L112 216v-80a8 8 0 0 0-2.16-5.47L40 56h176Z"/></g></svg></span>상태 완료</span>
</div>
<div class="mf__grid" role="grid" aria-label="주문 목록">
<div class="mf__row mf__row--head" role="row">
<span role="columnheader">이름</span><span role="columnheader">상태</span><span role="columnheader">수량</span>
</div>
<div class="mf__row mf__row--filter" role="row">
<span class="mf__f"><input class="mf__in" id="mf-name" data-col="0" aria-label="이름 거르기"><span class="mf__ghost mf__ghost--1" aria-hidden="true">민</span></span>
<span class="mf__f"><input class="mf__in" id="mf-state" data-col="1" aria-label="상태 거르기"><span class="mf__ghost mf__ghost--2" aria-hidden="true">완료</span></span>
<span class="mf__f"></span>
</div>
<div class="mf__body" id="mf-body" role="rowgroup">
<div class="mf__row" role="row"><span role="gridcell">민들레</span><span role="gridcell">완료</span><span role="gridcell">12</span></div>
<div class="mf__row" role="row"><span role="gridcell">민트</span><span role="gridcell">대기</span><span role="gridcell">7</span></div>
<div class="mf__row" role="row"><span role="gridcell">하늘</span><span role="gridcell">완료</span><span role="gridcell">24</span></div>
<div class="mf__row" role="row"><span role="gridcell">가온</span><span role="gridcell">대기</span><span role="gridcell">5</span></div>
</div>
</div>
</div>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-yellow);
$row-h: 22px;
$ink: $subject-ink;
$line: color.mix($ink, $stage-paper, 14%);
.mf {
box-sizing: border-box;
width: min(292px, 92vw);
color: $ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
// ── 걸린 조건 알약 ────────────────────────────────────
.mf__chips {
display: flex;
gap: $sp-1;
height: 22px;
margin-bottom: $sp-1;
overflow: hidden;
}
.mf__chip {
display: inline-flex;
align-items: center;
gap: $sp-1;
height: 18px;
padding: 0 $sp-2;
border-radius: $r-pill;
background: $ink;
color: $stage-yellow;
font-size: 10px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
opacity: 0;
transform: translateY(-14px);
}
.mf__chip.is-live { opacity: 1; transform: translateY(0); }
.mf__fic { display: inline-flex; width: 11px; height: 11px; }
// ── 표 ───────────────────────────────────────────────
.mf__grid {
border: 2px solid $ink;
border-radius: $r-control;
background: $stage-paper;
overflow: hidden;
}
.mf__row {
display: grid;
grid-template-columns: 1fr 68px 56px;
align-items: center;
height: $row-h;
overflow: hidden;
box-shadow: inset 0 -1px 0 $line;
}
.mf__row > * {
padding: 0 $sp-2;
font-size: 11px;
line-height: $row-h;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
}
.mf__row--head {
background: color.mix($ink, $stage-paper, 8%);
}
.mf__row--head > * { font-weight: 700; }
.mf__row--filter { height: 24px; background: color.mix($ink, $stage-paper, 3%); }
.mf__f { position: relative; display: block; padding: 0 $sp-1; }
.mf__in {
box-sizing: border-box;
width: 100%;
height: 18px;
padding: 0 $sp-1;
border: 1px solid $line;
border-radius: $r-xs;
background: $stage-paper;
color: $ink;
font-family: inherit;
font-size: 10px;
line-height: 16px;
font-weight: 600;
}
.mf__in:focus-visible { outline: 2px solid $color; outline-offset: -1px; }
// 자동 루프에서만 보이는 "쳐 넣는 글자". 사람이 치면 진짜 input 값이 대신 보인다
.mf__ghost {
position: absolute;
left: 8px; top: 0;
color: $ink;
font-size: 10px;
line-height: 18px;
font-weight: 600;
pointer-events: none;
opacity: 0;
}
.mf__row--filter .mf__f:last-child { padding: 0; }
.mf__body .mf__row.is-out { height: 0; opacity: 0; }
// ── 자동 루프 (2s) ───────────────────────────────────
// 8~24% 「민」 을 한 자씩 친다 → 26% 에 안 맞는 두 줄이 높이 0 으로 컷 접힘(페이드 교차가
// 없어야 poster 에 반투명 유령이 안 남는다) → 44~56% 「완료」 를 친다 → 58% 에 한 줄 더 접힘
// → 74% 에 전부 되돌아오고 88% 까지 알약이 위로 빠진다.
.mf.is-demo .mf__ghost {
opacity: 1;
animation-duration: $dur-loop;
animation-iteration-count: infinite;
}
.mf.is-demo .mf__ghost--1 {
animation-name: mf-type1;
animation-timing-function: steps(1, end);
}
.mf.is-demo .mf__ghost--2 {
animation-name: mf-type2;
animation-timing-function: steps(2, end);
}
.mf.is-demo .mf__body .mf__row {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mf.is-demo .mf__body .mf__row:nth-child(2) { animation-name: mf-out2; }
.mf.is-demo .mf__body .mf__row:nth-child(3) { animation-name: mf-out3; }
.mf.is-demo .mf__body .mf__row:nth-child(4) { animation-name: mf-out4; }
.mf.is-demo .mf__chip {
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.mf.is-demo .mf__chip--1 { animation-name: mf-chip1; }
.mf.is-demo .mf__chip--2 { animation-name: mf-chip2; }
// 되돌림을 한 프레임에 몰면 「거른 글자는 그대로인데 네 줄이 다 보이는」 장이 생기고
// 그 장이 제일 크게 변해 poster 로 뽑힌다(실측). 그래서 글자를 먼저 지우고(72%)
// 줄은 한 줄씩(76·80·84%) 되돌린다 — 한 장에서 되살아나는 줄이 언제나 하나뿐이다.
@keyframes mf-type1 {
0% { clip-path: inset(0 100% 0 0); }
8% { clip-path: inset(0 100% 0 0); }
24% { clip-path: inset(0 0 0 0); }
72% { clip-path: inset(0 0 0 0); }
74% { clip-path: inset(0 100% 0 0); }
100% { clip-path: inset(0 100% 0 0); }
}
@keyframes mf-type2 {
0% { clip-path: inset(0 100% 0 0); }
44% { clip-path: inset(0 100% 0 0); }
56% { clip-path: inset(0 0 0 0); }
72% { clip-path: inset(0 0 0 0); }
74% { clip-path: inset(0 100% 0 0); }
100% { clip-path: inset(0 100% 0 0); }
}
@keyframes mf-out2 {
0% { height: $row-h; opacity: 1; }
58% { height: 0; opacity: 0; }
76% { height: $row-h; opacity: 1; }
100% { height: $row-h; opacity: 1; }
}
@keyframes mf-out3 {
0% { height: $row-h; opacity: 1; }
26% { height: 0; opacity: 0; }
80% { height: $row-h; opacity: 1; }
100% { height: $row-h; opacity: 1; }
}
@keyframes mf-out4 {
0% { height: $row-h; opacity: 1; }
26% { height: 0; opacity: 0; }
84% { height: $row-h; opacity: 1; }
100% { height: $row-h; opacity: 1; }
}
@keyframes mf-chip1 {
0% { opacity: 0; transform: translateY(-14px); }
22% { opacity: 0; transform: translateY(-14px); }
32% { opacity: 1; transform: translateY(0); }
72% { opacity: 1; transform: translateY(0); }
82% { opacity: 0; transform: translateY(-14px); }
100% { opacity: 0; transform: translateY(-14px); }
}
@keyframes mf-chip2 {
0% { opacity: 0; transform: translateY(-14px); }
54% { opacity: 0; transform: translateY(-14px); }
64% { opacity: 1; transform: translateY(0); }
70% { opacity: 1; transform: translateY(0); }
78% { opacity: 0; transform: translateY(-14px); }
100% { opacity: 0; transform: translateY(-14px); }
}
// 320×200 — 열 폭과 셀 여백만 줄인다. 세로는 이미 154px 로 꽉 차 있다
@media (max-width: 340px) {
.mf__row { grid-template-columns: 1fr 56px 44px; }
.mf__row > * { padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.mf.is-demo .mf__ghost,
.mf.is-demo .mf__body .mf__row,
.mf.is-demo .mf__chip { animation: none !important; }
.mf.is-demo .mf__ghost { opacity: 1; clip-path: inset(0 0 0 0); }
.mf.is-demo .mf__body .mf__row:nth-child(2),
.mf.is-demo .mf__body .mf__row:nth-child(3),
.mf.is-demo .mf__body .mf__row:nth-child(4) { height: 0; opacity: 0; }
.mf.is-demo .mf__chip { opacity: 1; transform: translateY(0); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.mf {
box-sizing: border-box;
width: min(292px, 92vw);
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.mf__chips {
display: flex;
gap: 4px;
height: 22px;
margin-bottom: 4px;
overflow: hidden;
}
.mf__chip {
display: inline-flex;
align-items: center;
gap: 4px;
height: 18px;
padding: 0 8px;
border-radius: 999px;
background: #17141a;
color: #ffd23f;
font-size: 10px;
line-height: 18px;
font-weight: 700;
white-space: nowrap;
opacity: 0;
transform: translateY(-14px);
}
.mf__chip.is-live {
opacity: 1;
transform: translateY(0);
}
.mf__fic {
display: inline-flex;
width: 11px;
height: 11px;
}
.mf__grid {
border: 2px solid #17141a;
border-radius: 12px;
background: #ffffff;
overflow: hidden;
}
.mf__row {
display: grid;
grid-template-columns: 1fr 68px 56px;
align-items: center;
height: 22px;
overflow: hidden;
box-shadow: inset 0 -1px 0 rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
}
.mf__row > * {
padding: 0 8px;
font-size: 11px;
line-height: 22px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
}
.mf__row--head {
background: rgb(92.7215686275%, 92.6274509804%, 92.8156862745%);
}
.mf__row--head > * {
font-weight: 700;
}
.mf__row--filter {
height: 24px;
background: rgb(97.2705882353%, 97.2352941176%, 97.3058823529%);
}
.mf__f {
position: relative;
display: block;
padding: 0 4px;
}
.mf__in {
box-sizing: border-box;
width: 100%;
height: 18px;
padding: 0 4px;
border: 1px solid rgb(87.262745098%, 87.0980392157%, 87.4274509804%);
border-radius: 4px;
background: #ffffff;
color: #17141a;
font-family: inherit;
font-size: 10px;
line-height: 16px;
font-weight: 600;
}
.mf__in:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -1px;
}
.mf__ghost {
position: absolute;
left: 8px;
top: 0;
color: #17141a;
font-size: 10px;
line-height: 18px;
font-weight: 600;
pointer-events: none;
opacity: 0;
}
.mf__row--filter .mf__f:last-child {
padding: 0;
}
.mf__body .mf__row.is-out {
height: 0;
opacity: 0;
}
.mf.is-demo .mf__ghost {
opacity: 1;
animation-duration: 2s;
animation-iteration-count: infinite;
}
.mf.is-demo .mf__ghost--1 {
animation-name: mf-type1;
animation-timing-function: steps(1, end);
}
.mf.is-demo .mf__ghost--2 {
animation-name: mf-type2;
animation-timing-function: steps(2, end);
}
.mf.is-demo .mf__body .mf__row {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.mf.is-demo .mf__body .mf__row:nth-child(2) {
animation-name: mf-out2;
}
.mf.is-demo .mf__body .mf__row:nth-child(3) {
animation-name: mf-out3;
}
.mf.is-demo .mf__body .mf__row:nth-child(4) {
animation-name: mf-out4;
}
.mf.is-demo .mf__chip {
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.mf.is-demo .mf__chip--1 {
animation-name: mf-chip1;
}
.mf.is-demo .mf__chip--2 {
animation-name: mf-chip2;
}
@keyframes mf-type1 {
0% {
clip-path: inset(0 100% 0 0);
}
8% {
clip-path: inset(0 100% 0 0);
}
24% {
clip-path: inset(0 0 0 0);
}
72% {
clip-path: inset(0 0 0 0);
}
74% {
clip-path: inset(0 100% 0 0);
}
100% {
clip-path: inset(0 100% 0 0);
}
}
@keyframes mf-type2 {
0% {
clip-path: inset(0 100% 0 0);
}
44% {
clip-path: inset(0 100% 0 0);
}
56% {
clip-path: inset(0 0 0 0);
}
72% {
clip-path: inset(0 0 0 0);
}
74% {
clip-path: inset(0 100% 0 0);
}
100% {
clip-path: inset(0 100% 0 0);
}
}
@keyframes mf-out2 {
0% {
height: 22px;
opacity: 1;
}
58% {
height: 0;
opacity: 0;
}
76% {
height: 22px;
opacity: 1;
}
100% {
height: 22px;
opacity: 1;
}
}
@keyframes mf-out3 {
0% {
height: 22px;
opacity: 1;
}
26% {
height: 0;
opacity: 0;
}
80% {
height: 22px;
opacity: 1;
}
100% {
height: 22px;
opacity: 1;
}
}
@keyframes mf-out4 {
0% {
height: 22px;
opacity: 1;
}
26% {
height: 0;
opacity: 0;
}
84% {
height: 22px;
opacity: 1;
}
100% {
height: 22px;
opacity: 1;
}
}
@keyframes mf-chip1 {
0% {
opacity: 0;
transform: translateY(-14px);
}
22% {
opacity: 0;
transform: translateY(-14px);
}
32% {
opacity: 1;
transform: translateY(0);
}
72% {
opacity: 1;
transform: translateY(0);
}
82% {
opacity: 0;
transform: translateY(-14px);
}
100% {
opacity: 0;
transform: translateY(-14px);
}
}
@keyframes mf-chip2 {
0% {
opacity: 0;
transform: translateY(-14px);
}
54% {
opacity: 0;
transform: translateY(-14px);
}
64% {
opacity: 1;
transform: translateY(0);
}
70% {
opacity: 1;
transform: translateY(0);
}
78% {
opacity: 0;
transform: translateY(-14px);
}
100% {
opacity: 0;
transform: translateY(-14px);
}
}
@media (max-width: 340px) {
.mf__row {
grid-template-columns: 1fr 56px 44px;
}
.mf__row > * {
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.mf.is-demo .mf__ghost,
.mf.is-demo .mf__body .mf__row,
.mf.is-demo .mf__chip {
animation: none !important;
}
.mf.is-demo .mf__ghost {
opacity: 1;
clip-path: inset(0 0 0 0);
}
.mf.is-demo .mf__body .mf__row:nth-child(2),
.mf.is-demo .mf__body .mf__row:nth-child(3),
.mf.is-demo .mf__body .mf__row:nth-child(4) {
height: 0;
opacity: 0;
}
.mf.is-demo .mf__chip {
opacity: 1;
transform: translateY(0);
}
}// 실제 동작: 열마다 따로 친 글자를 모아 한 번에 걸러 낸다. 맞지 않는 행은 높이를 0 으로 접는다.
var mf = document.getElementById('mf');
var mfBody = document.getElementById('mf-body');
var mfChips = document.getElementById('mf-chips');
var MF_H = ['이름', '상태'];
function mfApply() {
mf.classList.remove('is-demo');
var q = [].slice.call(mf.querySelectorAll('.mf__in')).map(function (i) { return i.value.trim().toLowerCase(); });
[].forEach.call(mfBody.children, function (row) {
var keep = q.every(function (v, c) {
return !v || row.children[c].textContent.toLowerCase().indexOf(v) >= 0;
});
row.classList.toggle('is-out', !keep);
});
mfChips.innerHTML = q.map(function (v, c) {
return v ? '<span class="mf__chip is-live">' + MF_H[c] + ' ' + v + '</span>' : '';
}).join('');
}
[].forEach.call(mf.querySelectorAll('.mf__in'), function (i) {
i.addEventListener('input', mfApply);
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ColumnFilterChips.module.scss";
export type ColumnFilterChipsProps = {
tableLabel?: string;
/** 머리글 — 앞의 두 열에만 거르기 칸이 붙는다 */
head?: string[];
rows?: string[][];
/** 거르기 칸의 읽어 주는 이름 */
filterLabels?: string[];
/** 자동 루프에서 한 자씩 쳐 넣는 글자 */
ghosts?: string[];
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type ChangeEvt = { target: { value: string } };
function FilterIcon() {
return (
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M221.9 61.38L152 136v58.65a8 8 0 0 1-3.56 6.66l-32 21.33A8 8 0 0 1 104 216v-80L34.1 61.38A8 8 0 0 1 40 48h176a8 8 0 0 1 5.9 13.38" opacity=".2" />
<path d="M230.6 49.53A15.81 15.81 0 0 0 216 40H40a16 16 0 0 0-11.81 26.76l.08.09L96 139.17V216a16 16 0 0 0 24.87 13.32l32-21.34a16 16 0 0 0 7.13-13.32v-55.49l67.74-72.32l.08-.09a15.8 15.8 0 0 0 2.78-17.23m-84.42 81.05A8 8 0 0 0 144 136v58.66L112 216v-80a8 8 0 0 0-2.16-5.47L40 56h176Z" />
</g>
</svg>
);
}
export default function ColumnFilterChips({
tableLabel = "Order list",
head = ["Name", "Status", "Qty"],
rows = [
["Mina", "Done", "12"],
["Mint", "Waiting", "7"],
["Sky", "Done", "24"],
["Gaon", "Waiting", "5"],
],
filterLabels = ["Filter by name", "Filter by status"],
ghosts = ["mi", "done"],
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ColumnFilterChipsProps) {
const [q, setQ] = useState<string[]>(["", ""]);
const [live, setLive] = useState(demo);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const cls = [styles.root, live ? styles.isDemo : ""].join(" ");
// 열마다 따로 친 글자를 모아 한 번에 걸러 낸다
const keeps = (row: string[]) =>
q.every((v, c) => {
const t = v.trim().toLowerCase();
return !t || row[c].toLowerCase().indexOf(t) >= 0;
});
const onType = (c: number, value: string) => {
setLive(false);
setQ(q.map((v, k) => (k === c ? value : v)));
};
const chipText = (c: number, v: string) => `${head[c]} ${v}`;
return (
<div className={cls} style={vars}>
<div className={styles.chips}>
{live
? ghosts.map((g, c) => (
<span key={c} className={[styles.chip, c === 0 ? styles.chip1 : styles.chip2].join(" ")}>
<span className={styles.fic} aria-hidden="true">
<FilterIcon />
</span>
{chipText(c, g)}
</span>
))
: q.map((v, c) =>
v.trim() ? (
<span key={c} className={[styles.chip, styles.isLive].join(" ")}>
{chipText(c, v.trim().toLowerCase())}
</span>
) : null
)}
</div>
<div className={styles.grid} role="grid" aria-label={tableLabel}>
<div className={[styles.row, styles.rowHead].join(" ")} role="row">
{head.map((h) => (
<span key={h} role="columnheader">
{h}
</span>
))}
</div>
<div className={[styles.row, styles.rowFilter].join(" ")} role="row">
{head.map((h, c) => (
<span className={styles.f} key={h}>
{c < q.length ? (
<>
<input
className={styles.input}
data-col={c}
aria-label={filterLabels[c]}
value={q[c]}
onChange={(e: ChangeEvt) => onType(c, e.target.value)}
/>
<span
className={[styles.ghost, c === 0 ? styles.ghost1 : styles.ghost2].join(" ")}
aria-hidden="true"
>
{ghosts[c]}
</span>
</>
) : null}
</span>
))}
</div>
<div className={styles.body} role="rowgroup">
{rows.map((row, r) => (
<div
className={[styles.row, keeps(row) ? "" : styles.isOut].join(" ")}
role="row"
key={r}
>
{row.map((cell, c) => (
<span key={c} role="gridcell">
{cell}
</span>
))}
</div>
))}
</div>
</div>
</div>
);
}06 · 열마다 따로 거르는 필터 알약 — 측정 누적면적 21.649% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
행 사이 선을 border 대신 안쪽 그림자로 그린 것도 그래서입니다.
function mfApply() {
var q = [].slice.call(mf.querySelectorAll('.mf__in')).map(function (i) { return i.value.trim().toLowerCase(); });
[].forEach.call(mfBody.children, function (row) {
var keep = q.every(function (v, c) {
return !v || row.children[c].textContent.toLowerCase().indexOf(v) >= 0;
});
row.classList.toggle('is-out', !keep);
});
mfChips.innerHTML = q.map(function (v, c) {
return v ? '<span class="mf__chip is-live">' + MF_H[c] + ' ' + v + '</span>' : '';
}).join('');
}
07숫자 크기대로 칠해지는 셀 색과 막대
무엇이 보이나
배경색과 막대 길이와 낱말 배지를 각각 따로 판단하면 셋이 반드시 어긋납니다. 여기서는 구간표를 배열로 한 번만 적고, 값을 넣으면 등급을 돌려주는 함수 하나가 세 가지를 모두 정합니다.

<main class="stage">
<div class="cf is-demo" id="cf">
<div class="cf__grid" id="cf-grid" role="grid" aria-label="달성률">
<div class="cf__row cf__row--head" role="row">
<span role="columnheader">지점</span><span role="columnheader">달성</span><span role="columnheader">상태</span>
</div>
<div class="cf__row" role="row">
<span role="gridcell">서초</span>
<span class="cf__num" role="gridcell" data-level="2"><span class="cf__bar" style="--v: .72" aria-hidden="true"></span><i>72</i></span>
<span role="gridcell"><em class="cf__badge" data-level="2">좋음</em></span>
</div>
<div class="cf__row" role="row">
<span role="gridcell">마포</span>
<span class="cf__num" role="gridcell" data-level="1"><span class="cf__bar" style="--v: .45" aria-hidden="true"></span><i>45</i></span>
<span role="gridcell"><em class="cf__badge" data-level="1">주의</em></span>
</div>
<div class="cf__row" role="row">
<span role="gridcell">노원</span>
<span class="cf__num" role="gridcell" data-level="3"><span class="cf__bar" style="--v: .91" aria-hidden="true"></span><i>91</i></span>
<span role="gridcell"><em class="cf__badge" data-level="3">최고</em></span>
</div>
</div>
</div>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-orange);
$row-h: 26px;
$panel: color.mix($subject-ink, $stage-orange, 86%);
$line: rgba(255, 247, 230, .14);
.cf {
box-sizing: border-box;
width: min(292px, 92vw);
color: $color;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cf__grid {
border: 1px solid $line;
border-radius: $r-card;
background: $panel;
overflow: hidden;
}
.cf__row {
display: grid;
grid-template-columns: 1fr 92px 68px;
height: $row-h;
}
.cf__row + .cf__row { border-top: 1px solid rgba(255, 247, 230, .08); }
.cf__row > * {
padding: 0 $sp-2;
font-size: 12px;
line-height: $row-h;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
}
.cf__row--head { background: rgba(255, 247, 230, .07); }
.cf__row--head > * {
color: rgba(255, 247, 230, .6);
font-size: 11px;
font-weight: 700;
}
// ── 숫자 칸 — 구간 색 + 값만큼 자란 막대 ──────────────
.cf__num {
position: relative;
text-align: right;
}
.cf__num i { position: relative; font-style: normal; }
.cf__num[data-level="1"] { background: rgba(194, 54, 26, .30); }
.cf__num[data-level="2"] { background: rgba(76, 212, 166, .18); }
.cf__num[data-level="3"] { background: rgba(76, 212, 166, .38); color: $subject-ink; }
.cf__bar {
position: absolute;
left: 0; top: 0;
width: 100%;
height: 100%;
transform-origin: left center; // 규칙8: 값 막대 왼쪽 게이지
transform: scaleX(var(--v, 0));
}
.cf__num[data-level="1"] .cf__bar { background: rgba(194, 54, 26, .55); }
.cf__num[data-level="2"] .cf__bar { background: rgba(76, 212, 166, .34); }
.cf__num[data-level="3"] .cf__bar { background: rgba(76, 212, 166, .62); }
// ── 낱말 배지 — 색만으로 알리지 않는다 ────────────────
.cf__badge {
display: inline-flex;
align-items: center;
height: 16px;
padding: 0 $sp-2;
border-radius: $r-pill;
font-size: 10px;
line-height: 16px;
font-style: normal;
font-weight: 700;
}
.cf__badge[data-level="1"] { background: $stage-paper; color: $error; }
.cf__badge[data-level="2"] { background: rgba(76, 212, 166, .26); color: $subject-mint; }
.cf__badge[data-level="3"] { background: $subject-mint; color: $subject-ink; }
// ── 자동 루프 (2s) ───────────────────────────────────
// 8~40% 막대가 0 에서 제 값까지 자란다(가로만 늘리므로 중간 프레임이 유령이 되지 않는다).
// 구간 색과 배지는 막대가 도착하는 순간마다 줄마다 다른 시각에 컷으로 켜져 프레임을 채운다.
// 64~86% 에 0 으로 되돌아오고 그 뒤로는 아무것도 움직이지 않는다.
.cf.is-demo .cf__bar {
animation-name: cf-fill;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cf.is-demo .cf__row:nth-child(3) .cf__bar { animation-delay: 60ms; }
.cf.is-demo .cf__row:nth-child(4) .cf__bar { animation-delay: 120ms; }
.cf.is-demo .cf__num {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cf.is-demo .cf__row:nth-child(2) .cf__num { animation-name: cf-tint-a; }
.cf.is-demo .cf__row:nth-child(3) .cf__num { animation-name: cf-tint-b; }
.cf.is-demo .cf__row:nth-child(4) .cf__num { animation-name: cf-tint-c; }
.cf.is-demo .cf__badge {
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cf.is-demo .cf__row:nth-child(2) .cf__badge { animation-name: cf-badge-a; }
.cf.is-demo .cf__row:nth-child(3) .cf__badge { animation-name: cf-badge-b; }
.cf.is-demo .cf__row:nth-child(4) .cf__badge { animation-name: cf-badge-c; }
// 등급은 막대가 다 자란 뒤(44%) 세 줄이 **한꺼번에** 켜진다 — 이 장이 한 판에서 제일 크게
// 변하므로 poster 가 「막대·구간색·배지가 다 있는 완성 화면」이 된다. 끄는 쪽은 72·76·80% 로
// 흩어 한 장에서 꺼지는 칸을 하나로 줄인다 (248-09 와 같은 계열의 함정).
@keyframes cf-fill {
0% { transform: scaleX(0); }
8% { transform: scaleX(0); }
38% { transform: scaleX(var(--v, 0)); }
70% { transform: scaleX(var(--v, 0)); }
82% { transform: scaleX(0); }
100% { transform: scaleX(0); }
}
@keyframes cf-tint-a {
0% { background: none; }
44% { background: rgba(76, 212, 166, .18); }
72% { background: none; }
100% { background: none; }
}
@keyframes cf-tint-b {
0% { background: none; }
44% { background: rgba(194, 54, 26, .30); }
76% { background: none; }
100% { background: none; }
}
@keyframes cf-tint-c {
0% { background: none; }
44% { background: rgba(76, 212, 166, .38); }
80% { background: none; }
100% { background: none; }
}
@keyframes cf-badge-a {
0% { opacity: 0; }
44% { opacity: 1; }
72% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes cf-badge-b {
0% { opacity: 0; }
44% { opacity: 1; }
76% { opacity: 0; }
100% { opacity: 0; }
}
@keyframes cf-badge-c {
0% { opacity: 0; }
44% { opacity: 1; }
80% { opacity: 0; }
100% { opacity: 0; }
}
// 320×200 — 숫자·상태 열 폭과 셀 여백만 줄인다
@media (max-width: 340px) {
.cf__row { grid-template-columns: 1fr 76px 56px; }
.cf__row > * { padding: 0 $sp-1; }
.cf__badge { padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.cf.is-demo .cf__bar,
.cf.is-demo .cf__num,
.cf.is-demo .cf__badge { animation: none !important; }
.cf.is-demo .cf__bar { transform: scaleX(var(--v, 0)); }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cf {
box-sizing: border-box;
width: min(292px, 92vw);
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.cf__grid {
border: 1px solid rgba(255, 247, 230, 0.14);
border-radius: 16px;
background: rgb(21.7568627451%, 10.9725490196%, 10.4705882353%);
overflow: hidden;
}
.cf__row {
display: grid;
grid-template-columns: 1fr 92px 68px;
height: 26px;
}
.cf__row + .cf__row {
border-top: 1px solid rgba(255, 247, 230, 0.08);
}
.cf__row > * {
padding: 0 8px;
font-size: 12px;
line-height: 26px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
}
.cf__row--head {
background: rgba(255, 247, 230, 0.07);
}
.cf__row--head > * {
color: rgba(255, 247, 230, 0.6);
font-size: 11px;
font-weight: 700;
}
.cf__num {
position: relative;
text-align: right;
}
.cf__num i {
position: relative;
font-style: normal;
}
.cf__num[data-level="1"] {
background: rgba(194, 54, 26, 0.3);
}
.cf__num[data-level="2"] {
background: rgba(76, 212, 166, 0.18);
}
.cf__num[data-level="3"] {
background: rgba(76, 212, 166, 0.38);
color: #17141a;
}
.cf__bar {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
transform-origin: left center;
transform: scaleX(var(--v, 0));
}
.cf__num[data-level="1"] .cf__bar {
background: rgba(194, 54, 26, 0.55);
}
.cf__num[data-level="2"] .cf__bar {
background: rgba(76, 212, 166, 0.34);
}
.cf__num[data-level="3"] .cf__bar {
background: rgba(76, 212, 166, 0.62);
}
.cf__badge {
display: inline-flex;
align-items: center;
height: 16px;
padding: 0 8px;
border-radius: 999px;
font-size: 10px;
line-height: 16px;
font-style: normal;
font-weight: 700;
}
.cf__badge[data-level="1"] {
background: #ffffff;
color: #c2361a;
}
.cf__badge[data-level="2"] {
background: rgba(76, 212, 166, 0.26);
color: #4cd4a6;
}
.cf__badge[data-level="3"] {
background: #4cd4a6;
color: #17141a;
}
.cf.is-demo .cf__bar {
animation-name: cf-fill;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-fill-mode: backwards;
}
.cf.is-demo .cf__row:nth-child(3) .cf__bar {
animation-delay: 60ms;
}
.cf.is-demo .cf__row:nth-child(4) .cf__bar {
animation-delay: 120ms;
}
.cf.is-demo .cf__num {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cf.is-demo .cf__row:nth-child(2) .cf__num {
animation-name: cf-tint-a;
}
.cf.is-demo .cf__row:nth-child(3) .cf__num {
animation-name: cf-tint-b;
}
.cf.is-demo .cf__row:nth-child(4) .cf__num {
animation-name: cf-tint-c;
}
.cf.is-demo .cf__badge {
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.cf.is-demo .cf__row:nth-child(2) .cf__badge {
animation-name: cf-badge-a;
}
.cf.is-demo .cf__row:nth-child(3) .cf__badge {
animation-name: cf-badge-b;
}
.cf.is-demo .cf__row:nth-child(4) .cf__badge {
animation-name: cf-badge-c;
}
@keyframes cf-fill {
0% {
transform: scaleX(0);
}
8% {
transform: scaleX(0);
}
38% {
transform: scaleX(var(--v, 0));
}
70% {
transform: scaleX(var(--v, 0));
}
82% {
transform: scaleX(0);
}
100% {
transform: scaleX(0);
}
}
@keyframes cf-tint-a {
0% {
background: none;
}
44% {
background: rgba(76, 212, 166, 0.18);
}
72% {
background: none;
}
100% {
background: none;
}
}
@keyframes cf-tint-b {
0% {
background: none;
}
44% {
background: rgba(194, 54, 26, 0.3);
}
76% {
background: none;
}
100% {
background: none;
}
}
@keyframes cf-tint-c {
0% {
background: none;
}
44% {
background: rgba(76, 212, 166, 0.38);
}
80% {
background: none;
}
100% {
background: none;
}
}
@keyframes cf-badge-a {
0% {
opacity: 0;
}
44% {
opacity: 1;
}
72% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes cf-badge-b {
0% {
opacity: 0;
}
44% {
opacity: 1;
}
76% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@keyframes cf-badge-c {
0% {
opacity: 0;
}
44% {
opacity: 1;
}
80% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.cf__row {
grid-template-columns: 1fr 76px 56px;
}
.cf__row > * {
padding: 0 4px;
}
.cf__badge {
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.cf.is-demo .cf__bar,
.cf.is-demo .cf__num,
.cf.is-demo .cf__badge {
animation: none !important;
}
.cf.is-demo .cf__bar {
transform: scaleX(var(--v, 0));
}
}// 실제 동작: 값 하나가 구간표를 지나 등급이 되고, 등급이 배경색·막대 길이·배지를 한꺼번에 정한다.
var cf = document.getElementById('cf');
var CF_BAND = [{ min: 80, level: 3, word: '최고' }, { min: 60, level: 2, word: '좋음' }, { min: 0, level: 1, word: '주의' }];
function cfGrade(v) {
for (var i = 0; i < CF_BAND.length; i++) { if (v >= CF_BAND[i].min) return CF_BAND[i]; }
return CF_BAND[CF_BAND.length - 1];
}
function cfPut(row, v) {
cf.classList.remove('is-demo');
var cell = row.querySelector('.cf__num');
var badge = row.querySelector('.cf__badge');
var g = cfGrade(v);
cell.querySelector('i').textContent = String(v);
cell.querySelector('.cf__bar').style.setProperty('--v', String(v / 100));
cell.setAttribute('data-level', String(g.level));
badge.setAttribute('data-level', String(g.level));
badge.textContent = g.word;
}
// 셀을 누르면 그 줄의 값이 한 단계 올라가며 등급 전체가 다시 계산된다
[].forEach.call(cf.querySelectorAll('.cf__num'), function (cell) {
cell.addEventListener('click', function () {
var now = +cell.querySelector('i').textContent;
cfPut(cell.closest('.cf__row'), now >= 95 ? 20 : now + 15);
});
});import { useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ConditionalCellFormat.module.scss";
export type CellRow = { name: string; value: number };
export type ConditionalCellFormatProps = {
gridLabel?: string;
branchLabel?: string;
rateLabel?: string;
statusLabel?: string;
bestWord?: string;
goodWord?: string;
watchWord?: string;
rows?: CellRow[];
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type BarVars = CSSProperties & { "--v"?: string };
const ROWS: CellRow[] = [
{ name: "Seocho", value: 72 },
{ name: "Mapo", value: 45 },
{ name: "Nowon", value: 91 },
];
export default function ConditionalCellFormat({
gridLabel = "Attainment",
branchLabel = "Branch",
rateLabel = "Rate",
statusLabel = "Status",
bestWord = "Best",
goodWord = "Good",
watchWord = "Watch",
rows = ROWS,
demo = true,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#fff7e6",
}: ConditionalCellFormatProps) {
const [data, setData] = useState<CellRow[]>(rows);
const [live, setLive] = useState(demo);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 값 하나가 구간표를 지나 등급이 되고, 등급이 배경색·막대 길이·배지를 한꺼번에 정한다
const grade = (v: number) => (v >= 80 ? 3 : v >= 60 ? 2 : 1);
const word = (level: number) => (level === 3 ? bestWord : level === 2 ? goodWord : watchWord);
const barVars = (v: number): BarVars => ({ "--v": String(v / 100) });
// 셀을 누르면 그 줄의 값이 한 단계 올라가며 등급 전체가 다시 계산된다
const bump = (i: number) => {
setLive(false);
setData((prev) =>
prev.map((r, n) => (n === i ? { name: r.name, value: r.value >= 95 ? 20 : r.value + 15 } : r))
);
};
return (
<div className={[styles.root, live ? styles.isDemo : ""].join(" ")} style={vars}>
<div className={styles.grid} role="grid" aria-label={gridLabel}>
<div className={[styles.row, styles.rowHead].join(" ")} role="row">
<span role="columnheader">{branchLabel}</span>
<span role="columnheader">{rateLabel}</span>
<span role="columnheader">{statusLabel}</span>
</div>
{data.map((r, i) => {
const level = grade(r.value);
return (
<div className={styles.row} role="row" key={r.name}>
<span role="gridcell">{r.name}</span>
<span
className={styles.num}
role="gridcell"
data-level={String(level)}
onClick={() => bump(i)}
>
<span className={styles.bar} style={barVars(r.value)} aria-hidden="true" />
<i>{r.value}</i>
</span>
<span role="gridcell">
<em className={styles.badge} data-level={String(level)}>{word(level)}</em>
</span>
</div>
);
})}
</div>
</div>
);
}07 · 숫자 크기대로 칠해지는 셀 색과 막대 — 측정 누적면적 5.962% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드에서 볼 곳
등급은 data-level 속성에 적히고 색은 그 속성 선택자만 봅니다.
var CF_BAND = [{ min: 80, level: 3, word: '최고' }, { min: 60, level: 2, word: '좋음' }, { min: 0, level: 1, word: '주의' }];
function cfGrade(v) {
for (var i = 0; i < CF_BAND.length; i++) { if (v >= CF_BAND[i].min) return CF_BAND[i]; }
return CF_BAND[CF_BAND.length - 1];
}
function cfPut(row, v) {
var cell = row.querySelector('.cf__num');
var badge = row.querySelector('.cf__badge');
var g = cfGrade(v);
cell.querySelector('i').textContent = String(v);
cell.querySelector('.cf__bar').style.setProperty('--v', String(v / 100));
cell.setAttribute('data-level', String(g.level));
badge.setAttribute('data-level', String(g.level));
badge.textContent = g.word;
}
08바닥에 붙어 다시 계산되는 합계 줄
화면에서 일어나는 일
합계 줄은 스크롤 상자 안쪽에 두고 position: sticky 로 바닥에 붙입니다. 상자 밖에 두면 그냥 아래에 놓인 줄일 뿐이라 스크롤과 아무 관계가 없습니다.

<main class="stage">
<div class="sm is-demo" id="sm">
<div class="sm__head" role="row">
<span role="columnheader">주차</span><span role="columnheader">금액</span>
</div>
<div class="sm__view" id="sm-view" role="grid" aria-label="정산 목록" tabindex="0">
<div class="sm__track" id="sm-track" role="rowgroup">
<button class="sm__row" type="button" role="row" data-v="42"><span role="gridcell">3월 1주</span><span role="gridcell">42</span></button>
<button class="sm__row" type="button" role="row" data-v="38"><span role="gridcell">3월 2주</span><span role="gridcell">38</span></button>
<button class="sm__row" type="button" role="row" data-v="51"><span role="gridcell">3월 3주</span><span role="gridcell">51</span></button>
<button class="sm__row" type="button" role="row" data-v="27"><span role="gridcell">3월 4주</span><span role="gridcell">27</span></button>
<button class="sm__row" type="button" role="row" data-v="60"><span role="gridcell">4월 1주</span><span role="gridcell">60</span></button>
</div>
<div class="sm__sum" id="sm-sum" role="row">
<span class="sm__sic" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M192 48v160H64l64-80l-64-80Z" opacity=".2"/><path d="M184 72V56H80.65l53.6 67a8 8 0 0 1 0 10l-53.6 67H184v-16a8 8 0 0 1 16 0v24a8 8 0 0 1-8 8H64a8 8 0 0 1-6.25-13l60-75l-60-75A8 8 0 0 1 64 40h128a8 8 0 0 1 8 8v24a8 8 0 0 1-16 0"/></g></svg></span>
<span class="sm__label" role="gridcell">합계</span>
<span class="sm__v" role="gridcell"><b class="sm__all" id="sm-all">218</b><b class="sm__sel">38</b></span>
</div>
</div>
</div>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
$row-h: 26px;
$ink: $subject-ink;
$line: color.mix($ink, $stage-paper, 12%);
.sm {
box-sizing: border-box;
width: min(276px, 92vw);
border: 2px solid $ink;
border-radius: $r-card;
background: $stage-paper;
overflow: hidden;
color: $ink;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sm__head {
display: grid;
grid-template-columns: 1fr 72px;
height: 24px;
border-bottom: 2px solid $ink;
background: color.mix($ink, $stage-paper, 8%);
}
.sm__head > * {
padding: 0 $sp-2;
font-size: 11px;
line-height: 24px;
font-weight: 700;
}
.sm__head > :last-child { text-align: right; }
// ── 스크롤 상자 — 여기에는 transform 을 걸지 않는다 ────
.sm__view {
position: relative;
height: 78px;
overflow-y: auto;
overscroll-behavior: contain;
}
.sm__view:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
.sm__track { display: block; }
.sm__row {
display: grid;
grid-template-columns: 1fr 72px;
width: 100%;
height: $row-h;
padding: 0;
border: 0;
box-shadow: inset 0 -1px 0 $line;
background: $stage-paper;
color: $ink;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.sm__row > * {
padding: 0 $sp-2;
font-size: 12px;
line-height: $row-h;
font-weight: 500;
}
.sm__row > :last-child { text-align: right; font-weight: 700; }
.sm__row.is-pick { background: rgba(47, 109, 246, .14); }
.sm__row:focus-visible { outline: 2px solid $color; outline-offset: -2px; }
// ── 바닥에 붙는 합계 줄 ───────────────────────────────
.sm__sum {
position: sticky;
bottom: 0;
z-index: 2;
display: grid;
grid-template-columns: 16px 1fr 72px;
align-items: center;
height: 24px;
border-top: 2px solid $ink;
background: $stage-yellow;
box-shadow: none;
}
.sm.is-more .sm__sum { box-shadow: 0 -6px 10px rgba(23, 20, 26, .22); }
.sm__sic { display: inline-flex; width: 12px; height: 12px; margin-left: $sp-1; }
.sm__label {
padding: 0 $sp-1;
font-size: 11px;
line-height: 24px;
font-weight: 700;
}
.sm__v {
position: relative;
padding: 0 $sp-2;
font-size: 12px;
line-height: 24px;
text-align: right;
}
.sm__all, .sm__sel { font-weight: 800; }
.sm__sel { position: absolute; right: 8px; top: 0; color: $color; opacity: 0; }
.sm__sum.is-part .sm__label { color: $color; }
// ── 자동 루프 (2s) ───────────────────────────────────
// 스크롤 상자(.sm__view) 자체가 아니라 안쪽 줄묶음(.sm__track)만 옮긴다 — 상자에 transform 을
// 걸면 100% 가 상자 자기 폭 기준이라 내용이 통째로 날아간다(051 실측과 같은 계열).
// 10~44% 내려가고 44% 에 끝에 닿아 그림자를 끈다. 56% 에 한 줄을 골라 합계가 부분합으로 컷 교체.
.sm.is-demo .sm__track {
animation-name: sm-scroll;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.sm.is-demo .sm__sum {
animation-name: sm-shade;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sm.is-demo .sm__row:nth-child(2) {
animation-name: sm-pick;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sm.is-demo .sm__all {
animation-name: sm-off;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sm.is-demo .sm__sel {
animation-name: sm-on;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sm-scroll {
0% { transform: translateY(0); }
10% { transform: translateY(0); }
44% { transform: translateY(-52px); }
70% { transform: translateY(-52px); }
86% { transform: translateY(0); }
100% { transform: translateY(0); }
}
@keyframes sm-shade {
0% { box-shadow: 0 -6px 10px rgba(23, 20, 26, .22); }
44% { box-shadow: none; }
78% { box-shadow: 0 -6px 10px rgba(23, 20, 26, .22); }
100% { box-shadow: 0 -6px 10px rgba(23, 20, 26, .22); }
}
@keyframes sm-pick {
0% { background: $stage-paper; }
56% { background: rgba(47, 109, 246, .14); }
76% { background: $stage-paper; }
100% { background: $stage-paper; }
}
@keyframes sm-off {
0% { opacity: 1; }
56% { opacity: 0; }
76% { opacity: 1; }
100% { opacity: 1; }
}
@keyframes sm-on {
0% { opacity: 0; }
56% { opacity: 1; }
76% { opacity: 0; }
100% { opacity: 0; }
}
// 320×200 — 금액 열과 셀 여백만 줄인다. 스크롤 상자 높이는 그대로 둔다
@media (max-width: 340px) {
.sm__head, .sm__row { grid-template-columns: 1fr 60px; }
.sm__sum { grid-template-columns: 16px 1fr 60px; }
.sm__head > *, .sm__row > * { padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.sm.is-demo .sm__track,
.sm.is-demo .sm__sum,
.sm.is-demo .sm__row:nth-child(2),
.sm.is-demo .sm__all,
.sm.is-demo .sm__sel { animation: none !important; }
.sm.is-demo .sm__sum { box-shadow: 0 -6px 10px rgba(23, 20, 26, .22); }
}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);
}
.sm {
box-sizing: border-box;
width: min(276px, 92vw);
border: 2px solid #17141a;
border-radius: 16px;
background: #ffffff;
overflow: hidden;
color: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.sm__head {
display: grid;
grid-template-columns: 1fr 72px;
height: 24px;
border-bottom: 2px solid #17141a;
background: rgb(92.7215686275%, 92.6274509804%, 92.8156862745%);
}
.sm__head > * {
padding: 0 8px;
font-size: 11px;
line-height: 24px;
font-weight: 700;
}
.sm__head > :last-child {
text-align: right;
}
.sm__view {
position: relative;
height: 78px;
overflow-y: auto;
overscroll-behavior: contain;
}
.sm__view:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.sm__track {
display: block;
}
.sm__row {
display: grid;
grid-template-columns: 1fr 72px;
width: 100%;
height: 26px;
padding: 0;
border: 0;
box-shadow: inset 0 -1px 0 rgb(89.0823529412%, 88.9411764706%, 89.2235294118%);
background: #ffffff;
color: #17141a;
font-family: inherit;
text-align: left;
cursor: pointer;
}
.sm__row > * {
padding: 0 8px;
font-size: 12px;
line-height: 26px;
font-weight: 500;
}
.sm__row > :last-child {
text-align: right;
font-weight: 700;
}
.sm__row.is-pick {
background: rgba(47, 109, 246, 0.14);
}
.sm__row:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.sm__sum {
position: sticky;
bottom: 0;
z-index: 2;
display: grid;
grid-template-columns: 16px 1fr 72px;
align-items: center;
height: 24px;
border-top: 2px solid #17141a;
background: #ffd23f;
box-shadow: none;
}
.sm.is-more .sm__sum {
box-shadow: 0 -6px 10px rgba(23, 20, 26, 0.22);
}
.sm__sic {
display: inline-flex;
width: 12px;
height: 12px;
margin-left: 4px;
}
.sm__label {
padding: 0 4px;
font-size: 11px;
line-height: 24px;
font-weight: 700;
}
.sm__v {
position: relative;
padding: 0 8px;
font-size: 12px;
line-height: 24px;
text-align: right;
}
.sm__all, .sm__sel {
font-weight: 800;
}
.sm__sel {
position: absolute;
right: 8px;
top: 0;
color: #2f6df6;
opacity: 0;
}
.sm__sum.is-part .sm__label {
color: #2f6df6;
}
.sm.is-demo .sm__track {
animation-name: sm-scroll;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.sm.is-demo .sm__sum {
animation-name: sm-shade;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sm.is-demo .sm__row:nth-child(2) {
animation-name: sm-pick;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sm.is-demo .sm__all {
animation-name: sm-off;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.sm.is-demo .sm__sel {
animation-name: sm-on;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sm-scroll {
0% {
transform: translateY(0);
}
10% {
transform: translateY(0);
}
44% {
transform: translateY(-52px);
}
70% {
transform: translateY(-52px);
}
86% {
transform: translateY(0);
}
100% {
transform: translateY(0);
}
}
@keyframes sm-shade {
0% {
box-shadow: 0 -6px 10px rgba(23, 20, 26, 0.22);
}
44% {
box-shadow: none;
}
78% {
box-shadow: 0 -6px 10px rgba(23, 20, 26, 0.22);
}
100% {
box-shadow: 0 -6px 10px rgba(23, 20, 26, 0.22);
}
}
@keyframes sm-pick {
0% {
background: #ffffff;
}
56% {
background: rgba(47, 109, 246, 0.14);
}
76% {
background: #ffffff;
}
100% {
background: #ffffff;
}
}
@keyframes sm-off {
0% {
opacity: 1;
}
56% {
opacity: 0;
}
76% {
opacity: 1;
}
100% {
opacity: 1;
}
}
@keyframes sm-on {
0% {
opacity: 0;
}
56% {
opacity: 1;
}
76% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.sm__head, .sm__row {
grid-template-columns: 1fr 60px;
}
.sm__sum {
grid-template-columns: 16px 1fr 60px;
}
.sm__head > *, .sm__row > * {
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.sm.is-demo .sm__track,
.sm.is-demo .sm__sum,
.sm.is-demo .sm__row:nth-child(2),
.sm.is-demo .sm__all,
.sm.is-demo .sm__sel {
animation: none !important;
}
.sm.is-demo .sm__sum {
box-shadow: 0 -6px 10px rgba(23, 20, 26, 0.22);
}
}// 실제 동작: 합계 줄은 스크롤 상자 안에서 bottom:0 에 붙어 있고, 끝까지 내려가면 그림자를 끈다.
var sm = document.getElementById('sm');
var smView = document.getElementById('sm-view');
var smAll = document.getElementById('sm-all');
function smRows() { return [].slice.call(document.querySelectorAll('.sm__row')); }
function smSum() {
var picked = smRows().filter(function (r) { return r.classList.contains('is-pick'); });
var list = picked.length ? picked : smRows();
smAll.textContent = String(list.reduce(function (n, r) { return n + (+r.dataset.v); }, 0));
document.getElementById('sm-sum').classList.toggle('is-part', picked.length > 0);
}
function smShade() {
var left = smView.scrollHeight - smView.scrollTop - smView.clientHeight;
sm.classList.toggle('is-more', left > 2);
}
smView.addEventListener('scroll', function () { sm.classList.remove('is-demo'); smShade(); });
smRows().forEach(function (row) {
row.addEventListener('click', function () {
sm.classList.remove('is-demo');
row.classList.toggle('is-pick');
row.setAttribute('aria-selected', row.classList.contains('is-pick') ? 'true' : 'false');
smSum();
});
});
smSum(); smShade();import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./StickySummaryRow.module.scss";
export type SummaryRow = { name: string; value: number };
export type StickySummaryRowProps = {
gridLabel?: string;
periodLabel?: string;
amountLabel?: string;
totalLabel?: string;
rows?: SummaryRow[];
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
const ROWS: SummaryRow[] = [
{ name: "Mar wk 1", value: 42 },
{ name: "Mar wk 2", value: 38 },
{ name: "Mar wk 3", value: 51 },
{ name: "Mar wk 4", value: 27 },
{ name: "Apr wk 1", value: 60 },
];
export default function StickySummaryRow({
gridLabel = "Settlement list",
periodLabel = "Period",
amountLabel = "Amount",
totalLabel = "Total",
rows = ROWS,
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: StickySummaryRowProps) {
const [picked, setPicked] = useState<number[]>([]);
const [more, setMore] = useState(true);
const [live, setLive] = useState(demo);
const viewRef = useRef<HTMLDivElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 고른 줄이 있으면 그것만, 없으면 전부 더한다
const list = picked.length ? rows.filter((_r, i) => picked.indexOf(i) >= 0) : rows;
const sum = list.reduce((n, r) => n + r.value, 0);
// 루프에서 두 번째 줄만 골랐을 때 컷으로 갈아 끼우는 부분합
const sample = rows.length > 1 ? rows[1].value : sum;
// 끝까지 내려가면 그림자를 끈다
const shade = () => {
const el = viewRef.current;
if (!el) return;
setMore(el.scrollHeight - el.scrollTop - el.clientHeight > 2);
};
useEffect(() => { shade(); }, []);
const toggle = (i: number) => {
setLive(false);
setPicked((prev) => (prev.indexOf(i) >= 0 ? prev.filter((n) => n !== i) : prev.concat([i])));
};
const cls = [styles.root, live ? styles.isDemo : "", more ? styles.isMore : ""].join(" ");
return (
<div className={cls} style={vars}>
<div className={styles.head} role="row">
<span role="columnheader">{periodLabel}</span>
<span role="columnheader">{amountLabel}</span>
</div>
<div
className={styles.view}
role="grid"
aria-label={gridLabel}
tabIndex={0}
ref={viewRef}
onScroll={() => { setLive(false); shade(); }}
>
<div className={styles.track} role="rowgroup">
{rows.map((r, i) => (
<button
key={r.name}
className={[styles.row, picked.indexOf(i) >= 0 ? styles.isPick : ""].join(" ")}
type="button"
role="row"
aria-selected={picked.indexOf(i) >= 0 ? "true" : "false"}
onClick={() => toggle(i)}
>
<span role="gridcell">{r.name}</span>
<span role="gridcell">{r.value}</span>
</button>
))}
</div>
<div className={[styles.sum, picked.length ? styles.isPart : ""].join(" ")} role="row">
<span className={styles.sic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M192 48v160H64l64-80l-64-80Z" opacity=".2" />
<path d="M184 72V56H80.65l53.6 67a8 8 0 0 1 0 10l-53.6 67H184v-16a8 8 0 0 1 16 0v24a8 8 0 0 1-8 8H64a8 8 0 0 1-6.25-13l60-75l-60-75A8 8 0 0 1 64 40h128a8 8 0 0 1 8 8v24a8 8 0 0 1-16 0" />
</g>
</svg>
</span>
<span className={styles.label} role="gridcell">{totalLabel}</span>
<span className={styles.v} role="gridcell">
<b className={styles.all}>{sum}</b>
<b className={styles.sel}>{sample}</b>
</span>
</div>
</div>
</div>
);
}08 · 바닥에 붙어 다시 계산되는 합계 줄 — 측정 누적면적 9.225% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
코드 읽는 법
남은 스크롤 길이를 재서 2px보다 크면 그 줄 위에 위로 향하는 그림자를 켜고, 끝에 닿으면 끕니다.
function smSum() {
var picked = smRows().filter(function (r) { return r.classList.contains('is-pick'); });
var list = picked.length ? picked : smRows();
smAll.textContent = String(list.reduce(function (n, r) { return n + (+r.dataset.v); }, 0));
document.getElementById('sm-sum').classList.toggle('is-part', picked.length > 0);
}
function smShade() {
var left = smView.scrollHeight - smView.scrollTop - smView.clientHeight;
sm.classList.toggle('is-more', left > 2);
}
smView.addEventListener('scroll', smShade);
09화살표로 칸을 옮겨 다니는 포커스 테두리
어떤 움직임인가
아홉 칸을 전부 탭으로 훑게 만들면 표 하나가 키보드 사용자의 발목을 아홉 번 잡습니다. 그래서 지금 있는 칸만 탭 정지점이 되고 나머지는 전부 빠지며, 칸 사이 이동은 화살표 키가 맡습니다.

<main class="stage">
<div class="kg is-demo" id="kg">
<div class="kg__grid" id="kg-grid" role="grid" aria-label="재고 표">
<div class="kg__row" role="row">
<span class="kg__h" role="columnheader">품목</span><span class="kg__h" role="columnheader">입고</span><span class="kg__h" role="columnheader">남음</span>
</div>
<div class="kg__body" role="rowgroup">
<div class="kg__row" role="row">
<span class="kg__c" role="gridcell" tabindex="0">앵두잼</span><span class="kg__c" role="gridcell" tabindex="-1">40</span><span class="kg__c" role="gridcell" tabindex="-1">12</span>
</div>
<div class="kg__row" role="row">
<span class="kg__c" role="gridcell" tabindex="-1">민트비누</span><span class="kg__c" role="gridcell" tabindex="-1">25</span><span class="kg__c" role="gridcell" tabindex="-1">7</span>
</div>
<div class="kg__row" role="row">
<span class="kg__c" role="gridcell" tabindex="-1">라일락지</span><span class="kg__c" role="gridcell" tabindex="-1">60</span><span class="kg__c" role="gridcell" tabindex="-1">24</span>
</div>
</div>
<span class="kg__ring" id="kg-ring" aria-hidden="true"></span>
</div>
<p class="kg__hint">
<span class="kg__kic" aria-hidden="true"><svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor"><g><path d="M232 128L128 232L24 128L128 24Z" opacity=".2"/><path d="M90.34 61.66a8 8 0 0 1 0-11.32l32-32a8 8 0 0 1 11.32 0l32 32a8 8 0 0 1-11.32 11.32L136 43.31V96a8 8 0 0 1-16 0V43.31l-18.34 18.35a8 8 0 0 1-11.32 0m64 132.68L136 212.69V160a8 8 0 0 0-16 0v52.69l-18.34-18.35a8 8 0 0 0-11.32 11.32l32 32a8 8 0 0 0 11.32 0l32-32a8 8 0 0 0-11.32-11.32m83.32-72l-32-32a8 8 0 0 0-11.32 11.32L212.69 120H160a8 8 0 0 0 0 16h52.69l-18.35 18.34a8 8 0 0 0 11.32 11.32l32-32a8 8 0 0 0 0-11.32M43.31 136H96a8 8 0 0 0 0-16H43.31l18.35-18.34a8 8 0 0 0-11.32-11.32l-32 32a8 8 0 0 0 0 11.32l32 32a8 8 0 0 0 11.32-11.32Z"/></g></svg></span>화살표로 칸 이동
</p>
</div>
</main>@use "sass:color";
@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
$cell-w: 88px;
$cell-h: 26px;
$line: color.mix($subject-cream, $stage-ink, 13%);
.kg {
box-sizing: border-box;
width: 264px;
color: $subject-cream;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.kg__grid {
position: relative;
border: 1px solid $line;
border-radius: $r-control;
background: $stage-ink;
overflow: hidden;
}
.kg__row {
display: grid;
grid-template-columns: repeat(3, $cell-w);
height: $cell-h;
}
.kg__body .kg__row + .kg__row { box-shadow: inset 0 1px 0 rgba(255, 247, 230, .07); }
.kg__h, .kg__c {
padding: 0 $sp-2;
font-size: 12px;
line-height: $cell-h;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
}
.kg__h {
background: color.mix($subject-cream, $stage-ink, 8%);
color: rgba(255, 247, 230, .58);
font-size: 11px;
font-weight: 700;
}
.kg__c { outline: none; cursor: pointer; }
// ── 옮겨 다니는 테두리 — 칸 하나 크기를 그대로 옮긴다 ──
.kg__ring {
position: absolute;
left: 0; top: 0;
z-index: 2;
width: $cell-w;
height: $cell-h;
border: 3px solid $color;
border-radius: $r-xs;
opacity: 0;
transform: translate(0, $cell-h);
pointer-events: none;
}
.kg__hint {
display: flex;
align-items: center;
gap: $sp-1;
margin: $sp-2 0 0;
color: rgba(255, 247, 230, .5);
font-size: 11px;
line-height: 16px;
font-weight: 600;
}
.kg__kic { display: inline-flex; width: 12px; height: 12px; }
// ── 자동 루프 (2s) ───────────────────────────────────
// 오른쪽 두 번 → 아래 → Home(그 줄 맨 앞) → 아래 → 처음 칸으로. 칸과 칸 사이를 끊지 않고
// 이어서 지나가므로 순간이동처럼 보이지 않는다. 복귀는 88% 에 끝난다.
.kg.is-demo .kg__ring {
animation-name: kg-walk;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
.kg.is-demo .kg__hint {
animation-name: kg-hint;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes kg-walk {
0% { opacity: 1; transform: translate(0, $cell-h); }
8% { opacity: 1; transform: translate(0, $cell-h); }
22% { opacity: 1; transform: translate($cell-w, $cell-h); }
36% { opacity: 1; transform: translate($cell-w * 2, $cell-h); }
50% { opacity: 1; transform: translate($cell-w * 2, $cell-h * 2); }
64% { opacity: 1; transform: translate(0, $cell-h * 2); }
76% { opacity: 1; transform: translate(0, $cell-h * 3); }
88% { opacity: 1; transform: translate(0, $cell-h); }
100% { opacity: 1; transform: translate(0, $cell-h); }
}
@keyframes kg-hint {
0% { color: rgba(255, 247, 230, .5); }
50% { color: $color; }
88% { color: rgba(255, 247, 230, .5); }
100% { color: rgba(255, 247, 230, .5); }
}
// 320×200 — 264px 격자가 그대로 들어간다. 안내 줄 여백만 줄인다
@media (max-width: 340px) {
.kg__hint { margin-top: $sp-1; }
.kg__h, .kg__c { padding: 0 $sp-1; }
}
@media (prefers-reduced-motion: reduce) {
.kg.is-demo .kg__ring,
.kg.is-demo .kg__hint { animation: none !important; }
.kg.is-demo .kg__ring { opacity: 1; transform: translate(0, $cell-h); }
}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);
}
.kg {
box-sizing: border-box;
width: 264px;
color: #fff7e6;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
}
.kg__grid {
position: relative;
border: 1px solid rgb(20.8470588235%, 19.4156862745%, 20.5960784314%);
border-radius: 12px;
background: #17141a;
overflow: hidden;
}
.kg__row {
display: grid;
grid-template-columns: repeat(3, 88px);
height: 26px;
}
.kg__body .kg__row + .kg__row {
box-shadow: inset 0 1px 0 rgba(255, 247, 230, 0.07);
}
.kg__h, .kg__c {
padding: 0 8px;
font-size: 12px;
line-height: 26px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
}
.kg__h {
background: rgb(16.2980392157%, 14.9647058824%, 16.5960784314%);
color: rgba(255, 247, 230, 0.58);
font-size: 11px;
font-weight: 700;
}
.kg__c {
outline: none;
cursor: pointer;
}
.kg__ring {
position: absolute;
left: 0;
top: 0;
z-index: 2;
width: 88px;
height: 26px;
border: 3px solid #b9a0ff;
border-radius: 4px;
opacity: 0;
transform: translate(0, 26px);
pointer-events: none;
}
.kg__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 8px 0 0;
color: rgba(255, 247, 230, 0.5);
font-size: 11px;
line-height: 16px;
font-weight: 600;
}
.kg__kic {
display: inline-flex;
width: 12px;
height: 12px;
}
.kg.is-demo .kg__ring {
animation-name: kg-walk;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
.kg.is-demo .kg__hint {
animation-name: kg-hint;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes kg-walk {
0% {
opacity: 1;
transform: translate(0, 26px);
}
8% {
opacity: 1;
transform: translate(0, 26px);
}
22% {
opacity: 1;
transform: translate(88px, 26px);
}
36% {
opacity: 1;
transform: translate(176px, 26px);
}
50% {
opacity: 1;
transform: translate(176px, 52px);
}
64% {
opacity: 1;
transform: translate(0, 52px);
}
76% {
opacity: 1;
transform: translate(0, 78px);
}
88% {
opacity: 1;
transform: translate(0, 26px);
}
100% {
opacity: 1;
transform: translate(0, 26px);
}
}
@keyframes kg-hint {
0% {
color: rgba(255, 247, 230, 0.5);
}
50% {
color: #b9a0ff;
}
88% {
color: rgba(255, 247, 230, 0.5);
}
100% {
color: rgba(255, 247, 230, 0.5);
}
}
@media (max-width: 340px) {
.kg__hint {
margin-top: 4px;
}
.kg__h, .kg__c {
padding: 0 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.kg.is-demo .kg__ring,
.kg.is-demo .kg__hint {
animation: none !important;
}
.kg.is-demo .kg__ring {
opacity: 1;
transform: translate(0, 26px);
}
}// 실제 동작: 표 안에서 탭 정지점은 늘 하나뿐이고(roving tabindex) 나머지 칸은 화살표로 옮겨 다닌다.
var kg = document.getElementById('kg');
var kgGrid = document.getElementById('kg-grid');
var kgRing = document.getElementById('kg-ring');
var KG_W = 88, KG_H = 26;
var kgAt = { r: 0, c: 0 };
function kgCells() { return [].slice.call(kgGrid.querySelectorAll('.kg__c')); }
function kgGo(r, c) {
kg.classList.remove('is-demo');
kgAt = { r: Math.max(0, Math.min(2, r)), c: Math.max(0, Math.min(2, c)) };
var i = kgAt.r * 3 + kgAt.c;
kgCells().forEach(function (el, n) { el.tabIndex = n === i ? 0 : -1; });
kgRing.style.transform = 'translate(' + (kgAt.c * KG_W) + 'px, ' + ((kgAt.r + 1) * KG_H) + 'px)';
kgRing.style.opacity = '1';
kgCells()[i].focus({ preventScroll: true }); // iframe 안이라 스크롤을 일으키면 부모가 끌려간다
}
kgGrid.addEventListener('keydown', function (e) {
var K = { ArrowRight: [0, 1], ArrowLeft: [0, -1], ArrowDown: [1, 0], ArrowUp: [-1, 0] }[e.key];
if (K) { e.preventDefault(); kgGo(kgAt.r + K[0], kgAt.c + K[1]); return; }
if (e.key === 'Home') { e.preventDefault(); kgGo(kgAt.r, 0); }
if (e.key === 'End') { e.preventDefault(); kgGo(kgAt.r, 2); }
});
kgCells().forEach(function (el, n) {
el.addEventListener('click', function () { kgGo(Math.floor(n / 3), n % 3); });
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./KeyboardGridNav.module.scss";
export type KeyboardGridNavProps = {
gridLabel?: string;
headers?: string[];
rows?: string[][];
hint?: string;
/** 자동 재생 루프 (갤러리용). 사람이 손대면 꺼진다 */
demo?: boolean;
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
const HEADERS = ["Item", "In", "Left"];
const ROWS: string[][] = [
["Cherry jam", "40", "12"],
["Mint soap", "25", "7"],
["Lilac paper", "60", "24"],
];
const CELL_W = 88;
const CELL_H = 26;
export default function KeyboardGridNav({
gridLabel = "Stock table",
headers = HEADERS,
rows = ROWS,
hint = "Arrow keys move cells",
demo = true,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#b9a0ff",
}: KeyboardGridNavProps) {
const [at, setAt] = useState({ r: 0, c: 0 });
const [live, setLive] = useState(demo);
const cells = useRef<(HTMLSpanElement | null)[]>([]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const last = rows.length - 1;
const lastCol = headers.length - 1;
// 표 안에서 탭 정지점은 늘 하나뿐이고(roving tabindex) 나머지 칸은 화살표로 옮겨 다닌다
const go = (r: number, c: number) => {
const rr = Math.max(0, Math.min(last, r));
const cc = Math.max(0, Math.min(lastCol, c));
setLive(false);
setAt({ r: rr, c: cc });
const el = cells.current ? cells.current[rr * headers.length + cc] : null;
if (el) el.focus({ preventScroll: true }); // iframe 안이라 스크롤을 일으키면 부모가 끌려간다
};
const onKey = (e: KeyEvt) => {
const map: { [k: string]: number[] } = {
ArrowRight: [0, 1], ArrowLeft: [0, -1], ArrowDown: [1, 0], ArrowUp: [-1, 0],
};
const step = map[e.key];
if (step) { e.preventDefault(); go(at.r + step[0], at.c + step[1]); return; }
if (e.key === "Home") { e.preventDefault(); go(at.r, 0); }
if (e.key === "End") { e.preventDefault(); go(at.r, lastCol); }
};
const active = at.r * headers.length + at.c;
const ringStyle: CSSProperties | undefined = live
? undefined
: { transform: `translate(${at.c * CELL_W}px, ${(at.r + 1) * CELL_H}px)`, opacity: 1 };
return (
<div className={[styles.root, live ? styles.isDemo : ""].join(" ")} style={vars}>
<div className={styles.grid} role="grid" aria-label={gridLabel} onKeyDown={onKey}>
<div className={styles.row} role="row">
{headers.map((h) => (
<span className={styles.h} role="columnheader" key={h}>{h}</span>
))}
</div>
<div className={styles.body} role="rowgroup">
{rows.map((row, r) => (
<div className={styles.row} role="row" key={row[0]}>
{row.map((cell, c) => {
const n = r * headers.length + c;
return (
<span
className={styles.c}
role="gridcell"
key={cell + String(c)}
tabIndex={n === active ? 0 : -1}
ref={(el: HTMLSpanElement | null) => { if (cells.current) cells.current[n] = el; }}
onClick={() => go(r, c)}
>
{cell}
</span>
);
})}
</div>
))}
</div>
<span className={styles.ring} style={ringStyle} aria-hidden="true" />
</div>
<p className={styles.hint}>
<span className={styles.kic} aria-hidden="true">
<svg viewBox="0 0 256 256" width="1em" height="1em" fill="currentColor">
<g>
<path d="M232 128L128 232L24 128L128 24Z" opacity=".2" />
<path d="M90.34 61.66a8 8 0 0 1 0-11.32l32-32a8 8 0 0 1 11.32 0l32 32a8 8 0 0 1-11.32 11.32L136 43.31V96a8 8 0 0 1-16 0V43.31l-18.34 18.35a8 8 0 0 1-11.32 0m64 132.68L136 212.69V160a8 8 0 0 0-16 0v52.69l-18.34-18.35a8 8 0 0 0-11.32 11.32l32 32a8 8 0 0 0 11.32 0l32-32a8 8 0 0 0-11.32-11.32m83.32-72l-32-32a8 8 0 0 0-11.32 11.32L212.69 120H160a8 8 0 0 0 0 16h52.69l-18.35 18.34a8 8 0 0 0 11.32 11.32l32-32a8 8 0 0 0 0-11.32M43.31 136H96a8 8 0 0 0 0-16H43.31l18.35-18.34a8 8 0 0 0-11.32-11.32l-32 32a8 8 0 0 0 0 11.32l32 32a8 8 0 0 0 11.32-11.32Z" />
</g>
</svg>
</span>
{hint}
</p>
</div>
);
}09 · 화살표로 칸을 옮겨 다니는 포커스 테두리 — 측정 누적면적 5.625% · 움직인 프레임 21/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
어디를 바꾸면 되나
보이는 테두리는 칸마다 켜고 끄지 않고 요소 하나를 옮겨 미끄러지게 했습니다.
function kgGo(r, c) {
kgAt = { r: Math.max(0, Math.min(2, r)), c: Math.max(0, Math.min(2, c)) };
var i = kgAt.r * 3 + kgAt.c;
kgCells().forEach(function (el, n) { el.tabIndex = n === i ? 0 : -1; });
kgRing.style.transform = 'translate(' + (kgAt.c * KG_W) + 'px, ' + ((kgAt.r + 1) * KG_H) + 'px)';
kgRing.style.opacity = '1';
kgCells()[i].focus({ preventScroll: true });
}
kgGrid.addEventListener('keydown', function (e) {
var K = { ArrowRight: [0, 1], ArrowLeft: [0, -1], ArrowDown: [1, 0], ArrowUp: [-1, 0] }[e.key];
if (K) { e.preventDefault(); kgGo(kgAt.r + K[0], kgAt.c + K[1]); return; }
if (e.key === 'Home') { e.preventDefault(); kgGo(kgAt.r, 0); }
if (e.key === 'End') { e.preventDefault(); kgGo(kgAt.r, 2); }
});
어디서 깨지는가 — 함정
표에서 가장 자주 깨지는 곳은 자리를 바꾸는 두 줄이 서로를 지나가는 순간입니다. 01 처럼 동점 두 쌍이 동시에 자리를 맞바꾸면 그 중간에 네 줄이 두 줄로 보이는 프레임이 생깁니다. 두 줄의 배경이 둘 다 불투명하니 위에 그려진 쪽이 아래를 완전히 덮기 때문입니다. 24장으로 추리는 미리보기에서는 하필 그 장이 가장 크게 변한 장이라 대표 그림으로 뽑혔습니다. 그래서 두 쌍의 이동 시각을 20% 떼어 놓고, 위로 가는 줄에만 그림자와 쌓임 순서를 줘서 어느 쪽이 지나가는지 남겼습니다.
두 번째는 반대쪽입니다. 켜는 것과 끄는 것을 똑같은 세기로 만들면 끄는 쪽이 더 크게 변합니다. 04 에서 목록을 열 때는 그 목록만 나타나지만 닫을 때는 목록이 사라지면서 가려져 있던 표까지 한꺼번에 다시 드러나기 때문입니다. 05 도 같았습니다. 아홉 칸을 한 번에 끄니 그 장이 제일 크게 변해서 아무것도 안 골라진 빈 표가 대표 그림이 됐습니다. 고친 방법은 같습니다. 켜는 건 그대로 두고 끄는 쪽만 흩어 놓는 것입니다. 04 는 목록 안쪽을 먼저 흐리게 줄인 다음 바깥 통을 껐고, 05 는 네모가 3×3 에서 2×2, 1×1 로 줄어드는 동안 아래 띠 문구도 같은 순간에 한 단계씩 내려가게 했습니다. 문구만 늦게 끄면 2×2 네모 옆에 아홉 칸의 합계가 적힌 거짓 장면이 남습니다.
세 번째 함정은 열 폭을 두 군데에 적어 두는 것입니다. 02 를 처음 만들 때는 칸 폭과 안내선 위치를 각각의 값으로 두었는데, 빠르게 끌 때마다 선이 경계보다 몇 px 앞서거나 뒤처졌습니다. 둘 다 같은 프레임에 그려지는데도 그랬습니다. 값을 --w1 하나로 합치고 안내선은 그 값을 그대로 translateX 로 받게 하자 어긋남이 사라졌습니다. 덧붙여 이 커스텀 속성은 @property 로 길이형이라고 등록해 두어야 합니다. 등록하지 않으면 브라우저가 그 값을 길이가 아니라 그냥 글자로 보기 때문에 중간값 없이 한 번에 튑니다.
마지막은 좁은 화면입니다. 이 부품들은 글 안에 480×300 크기로 들어가고 폰에서는 320×200 으로 줄어듭니다. 무대 여백을 빼면 세로로 쓸 수 있는 건 174px 뿐인데, 320px 폭에서 아홉 부품의 높이를 재어 보니 108px 에서 164px 사이였습니다. 헤더 아래 필터 줄과 알약 줄이 함께 들어간 06 이 164px 로 가장 컸고 남은 여유는 10px 이었습니다. 그래서 좁은 화면에서는 간격을 올리지 않고 내립니다. 열 폭을 줄이고 셀 좌우 여백을 8px 에서 4px 로 내린 것이 전부이고, 세로 간격은 건드리지 않았습니다. 머리글을 끌어다 놓는 감각은 포트폴리오 갤러리 UI의 드래그 정렬에서도 같은 손으로 쓰입니다. 아홉 벌의 규칙을 화면마다 따로 적어 두면 관리가 안 되므로 한 폴더에 모아 두었고, 그 압축 파일의 비밀번호는 g445uude 이며 안에는 지금 보고 있는 바닐라 판과 React 판이 같은 값으로 들어 있습니다.
접근성
prefers-reduced-motion: reduce 를 켠 화면에서는 아홉 벌 전부 반복 루프가 멈추고 결과 상태만 남습니다. 정렬 방향 표시는 회전 없이 그 방향으로 서 있고, 막대는 자라는 과정 없이 제 길이로 있으며, 옮겨 다니는 테두리는 미끄러지지 않되 어느 칸에 있는지는 그대로 보입니다. 움직임이 사라져도 "이 열로 정렬돼 있다" 는 사실은 사라지지 않아야 하기 때문입니다.
표는 뼈대를 제대로 짜 두는 것이 곧 접근성입니다. 아홉 개 전부 바깥이 role="grid" 이고, 줄과 칸에는 role="row" 와 role="gridcell" 을, 머리글에는 role="columnheader" 를 붙였습니다. 헤더가 버튼인 01 과 열 단위로 세운 03 만 구조에 맞게 역할을 달리 답니다. 색만으로 알리지 않는 것도 같은 이유입니다. 07 은 배경색 옆에 등급 낱말을 따로 붙였고, 08 은 그림자 대신 합계 숫자 자체가 바뀌어 상태를 알립니다.
| 항목 | 눈으로 | 낭독기로 | 조작 |
|---|---|---|---|
| 01 우선순위 정렬 | 삼각형 방향 + 순번 배지 | aria-sort 를 ascending·descending 으로 토글 |
클릭 · Shift 클릭 |
| 02 열 너비 | 안내선과 칸 폭 | role="separator" 손잡이 이름 |
드래그 · 좌우 화살표 8px |
| 03 열 순서 | 들린 열 + 점선 홈 | 열 머리글 이름 순서가 그대로 바뀜 | 포인터 드래그 |
| 04 열 숨김 | 표에서 빠지는 칸 | aria-expanded · role="checkbox" |
클릭 |
| 05 범위 선택 | 파란 네모 + 칸 색 | 아래 띠가 칸 수와 합계를 문장으로 | 드래그 후 놓기 |
| 06 열 필터 | 접히는 줄 + 알약 | 입력칸마다 aria-label |
한 글자마다 |
| 07 조건부 서식 | 배경 · 막대 · 배지 | 등급 낱말이 셀 옆에 글자로 | 셀 클릭 |
| 08 합계 줄 | 바닥 고정 + 그림자 | 합계 숫자가 부분합으로 바뀜 | 스크롤 · 행 클릭 |
| 09 키보드 이동 | 두꺼운 테두리 | 옮긴 칸에 실제 초점이 간다 | 화살표 · Home · End |
격자 형태의 표에 어떤 역할과 상태를 붙여야 하는지는 MDN 의 grid 역할 문서에 정리되어 있습니다. 같은 계열의 목록·표 부품은 대시보드 카테고리에, 자바스크립트가 실제로 도는 부품은 JS 카테고리에 모여 있습니다.
FAQ
데이터 테이블 예시의 정렬을 CSS 만으로 할 수는 없나요?
행의 순서를 바꾸는 것까지는 됩니다. 각 행에 order 값을 주고 부모를 flex 로 두면 그리는 순서가 바뀝니다. 다만 그 값을 누가 정할지가 문제입니다. 값을 비교해서 1등부터 4등까지 번호를 매기는 일은 CSS 가 못 하므로 결국 그 계산은 자바스크립트가 합니다. 게다가 order 로 옮긴 순서는 화면에서만 바뀌고 낭독기가 읽는 순서는 원래 문서 순서 그대로라, 눈으로 본 1등과 귀로 들은 1등이 달라집니다. 01 이 실제 배열을 정렬하고 다시 그리는 이유가 그것입니다. 표의 뷰를 몇 가지로 바꿔 담는 스위치는 세그먼트 컨트롤 선택 UI가 맡습니다.
열 폭을 픽셀로 저장하면 화면 크기가 바뀔 때 깨지지 않나요?
깨집니다. 그래서 02 는 폭에 88px 하한과 200px 상한을 두고 그 사이로만 잘라 넣습니다. 저장까지 할 생각이라면 픽셀 대신 표 전체 폭에 대한 비율로 바꿔 두는 쪽이 안전합니다. 화면이 좁아져도 비율은 그대로 쓸 수 있기 때문입니다. 다만 비율로 두면 아주 좁은 화면에서 한 칸이 글자 한 자보다 작아질 수 있으니, 최소 폭만은 픽셀로 따로 잡아 두는 편이 낫습니다.
행이 1,000 줄이 넘어도 이대로 써도 되나요?
06 의 필터처럼 모든 행을 훑는 방식은 줄이 늘어난 만큼 그대로 느려집니다. 수백 줄까지는 문제가 없지만 그 위로 가면 화면에 보이는 줄만 그리는 방식으로 바꿔야 합니다. 08 이 스크롤 상자를 따로 둔 구조가 그 출발점입니다. 상자 높이와 행 높이를 알면 지금 보이는 첫 줄 번호를 계산할 수 있고, 그 범위만 그리면 됩니다. 이때 합계는 그린 줄이 아니라 원본 배열 전체로 더해야 한다는 점만 조심하면 됩니다.









