03경계선을 끌면 이웃 열이 남은 폭을 나눠 받는 표
열 경계를 옮긴 만큼 오른쪽 열이 줄고 최소 폭에서 멈추며, 잘린 글자는 말줄임표가 폭에 맞춰 즉시 다시 계산된다.
쓰임데이터 표·목록
COL_MINFOLLOWmoveBordertext-overflow

<main class="stage">
<div class="rh is-demo" id="rh" data-demo="1">
<div class="rh__tbl" data-r="tbl">
<span class="rh__th">NAME</span><span class="rh__th">SIZE</span><span class="rh__th">MODIFIED</span>
<span class="rh__td">quarterly-report-final.pdf</span><span class="rh__td">2.4 MB</span><span class="rh__td">2026-09-30 18:22</span>
<span class="rh__td">invoice-0412-signed.png</span><span class="rh__td">812 KB</span><span class="rh__td">2026-09-28 09:05</span>
<span class="rh__td">team-photo-offsite.jpg</span><span class="rh__td">5.1 MB</span><span class="rh__td">2026-09-21 14:40</span>
<span class="rh__td">roadmap-draft-v3.md</span><span class="rh__td">18 KB</span><span class="rh__td">2026-09-19 11:12</span>
</div>
<button class="rh__bar" data-r="bar0" type="button" aria-label="크기 조절 손잡이"></button>
<button class="rh__bar" data-r="bar1" type="button" aria-label="크기 조절 손잡이"></button>
<p class="rh__meta"><span class="rh__tag">A</span> <b data-r="a">0</b> <span class="rh__tag">B</span> <b data-r="b">0</b> <span class="rh__tag">C</span> <b data-r="c">0</b></p>
<i class="rh__cursor" data-r="cursor"></i>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 열 너비는 JS 가 세 숫자(합 = 표 너비)로 들고 grid-template-columns 에 쓴다.
@include stage($stage-yellow);
.rh {
position: relative;
box-sizing: border-box;
width: min(100%, 440px);
height: min(252px, calc(100vh - 2 * clamp(8px, 4vw, 24px)));
color: $subject-ink;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.rh b { font-weight: 800; font-variant-numeric: tabular-nums; }
.rh__tag { color: rgba($subject-ink, .72); letter-spacing: .5px; }
.rh__cursor {
position: absolute;
left: -10px;
top: -10px;
width: 20px;
height: 20px;
border-radius: 50%;
background: $subject-cream;
box-shadow: 0 0 0 3px rgba($subject-ink, .55), $shadow-press;
opacity: 0;
pointer-events: none;
z-index: 6;
}
.rh[data-demo] .rh__cursor { opacity: 1; }
.rh button:focus-visible { outline: 3px solid $subject-ink; outline-offset: 2px; }
@media (prefers-reduced-motion: reduce) {
.rh__cursor { display: none; }
}
// 이웃이 최소 폭에 닿으면 경계가 멈추고, 넘치는 글자는 CSS 말줄임이 자른다.
.rh { color: $subject-ink; }
.rh__tbl {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: calc(100% - 24px);
display: grid;
grid-template-rows: 28px repeat(4, 1fr);
overflow: hidden;
border-radius: $r-card;
background: $subject-cream;
box-shadow: 0 8px 16px rgba($subject-ink, .2);
}
.rh__th,
.rh__td {
box-sizing: border-box;
min-width: 0;
padding: 0 $sp-3;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
line-height: 28px;
border-bottom: 1px solid rgba($subject-ink, .12);
}
.rh__td { align-self: center; line-height: 1.6; font-weight: 500; }
.rh__th { background: $subject-sky; font-weight: 800; letter-spacing: 1px; }
.rh__bar {
position: absolute;
left: 0;
top: 0;
width: 16px;
height: calc(100% - 24px);
padding: 0;
border: 0;
background: transparent;
cursor: col-resize;
touch-action: none;
}
.rh__bar::before {
content: "";
position: absolute;
left: 6px;
top: 50%;
width: 4px;
height: 32px;
border-radius: $r-pill;
background: $subject-ink;
transform: translateY(-50%);
}
.rh__bar:focus-visible::before { box-shadow: 0 0 0 3px $subject-lilac; }
.rh__meta { position: absolute; left: 0; bottom: 0; margin: 0; }
.rh__cursor { z-index: 6; }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);
}
.rh {
position: relative;
box-sizing: border-box;
width: min(100%, 440px);
height: min(252px, 100vh - 2 * clamp(8px, 4vw, 24px));
color: #17141a;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.rh b {
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.rh__tag {
color: rgba(23, 20, 26, 0.72);
letter-spacing: 0.5px;
}
.rh__cursor {
position: absolute;
left: -10px;
top: -10px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 0 0 3px rgba(23, 20, 26, 0.55), 0 6px 12px rgba(23, 20, 26, 0.32);
opacity: 0;
pointer-events: none;
z-index: 6;
}
.rh[data-demo] .rh__cursor {
opacity: 1;
}
.rh button:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.rh__cursor {
display: none;
}
}
.rh {
color: #17141a;
}
.rh__tbl {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: calc(100% - 24px);
display: grid;
grid-template-rows: 28px repeat(4, 1fr);
overflow: hidden;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 8px 16px rgba(23, 20, 26, 0.2);
}
.rh__th,
.rh__td {
box-sizing: border-box;
min-width: 0;
padding: 0 12px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
line-height: 28px;
border-bottom: 1px solid rgba(23, 20, 26, 0.12);
}
.rh__td {
align-self: center;
line-height: 1.6;
font-weight: 500;
}
.rh__th {
background: #6ec8ff;
font-weight: 800;
letter-spacing: 1px;
}
.rh__bar {
position: absolute;
left: 0;
top: 0;
width: 16px;
height: calc(100% - 24px);
padding: 0;
border: 0;
background: transparent;
cursor: col-resize;
touch-action: none;
}
.rh__bar::before {
content: "";
position: absolute;
left: 6px;
top: 50%;
width: 4px;
height: 32px;
border-radius: 999px;
background: #17141a;
transform: translateY(-50%);
}
.rh__bar:focus-visible::before {
box-shadow: 0 0 0 3px #b9a0ff;
}
.rh__meta {
position: absolute;
left: 0;
bottom: 0;
margin: 0;
}
.rh__cursor {
z-index: 6;
}(function () {
const root = document.getElementById("rh");
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
const offs = [];
function q(sel) {
return root.querySelector(sel);
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
function seg(t, a, b) {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function stopDemo() {
demo = false;
root.removeAttribute("data-demo");
root.classList.remove("is-demo");
}
function pos(e) {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function spring(x, v, to, k, c, dt) {
const n = Math.max(1, Math.ceil(dt / 0.008));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
v += (-k * (x - to) - c * v) * h;
x += v * h;
}
return [x, v];
}
function sync() {
if (reduce) {
snap();
render();
}
}
// 기법: 경계를 옮긴 만큼 이웃 열이 같은 양을 잃고, 이웃이 최소 폭(COL_MIN)에 닿으면 경계가 거기서 멈춘다.
// 세 열의 합은 항상 표 너비와 같고, 잘린 글자는 CSS text-overflow 가 말줄임으로 처리한다.
const COL_MIN = 56;
const FOLLOW = 18;
const tbl = q("[data-r=tbl]");
const bar0 = q("[data-r=bar0]");
const bar1 = q("[data-r=bar1]");
const cursor = q("[data-r=cursor]");
const aEl = q("[data-r=a]");
const bEl = q("[data-r=b]");
const cEl = q("[data-r=c]");
let ready = false;
let t0w = 0;
let t1w = 0;
let d0 = 0;
let d1 = 0;
let px = 0;
let py = 0;
let gx = 0;
let drag = -1;
function total() { return root.clientWidth; }
function init() {
if (ready)
return;
ready = true;
t0w = d0 = total() * 0.34;
t1w = d1 = total() * 0.67;
}
function userTakes() {
stopDemo();
drag = -1;
init();
}
function moveBorder(i, want) {
const T = total();
if (i === 0)
t0w = clamp(want, COL_MIN, t1w - COL_MIN);
else
t1w = clamp(want, t0w + COL_MIN, T - COL_MIN);
}
function script(now) {
const T = total();
const ph = now % 2000;
const s0 = T * 0.34;
const s1 = T * 0.67;
px = s0;
drag = -1;
if (ph >= 200 && ph < 700) {
px = s0 + (T * 0.76 - s0) * seg(ph, 200, 700);
drag = 0;
}
else if (ph >= 700 && ph < 800) {
px = T * 0.76;
drag = 0;
}
else if (ph >= 800 && ph < 1250) {
px = s1 + (T * 0.94 - s1) * seg(ph, 800, 1250);
drag = 1;
}
else if (ph >= 1250 && ph < 1350) {
px = T * 0.94;
drag = 1;
}
else if (ph >= 1350) {
px = T * 0.94 + (s1 - T * 0.94) * seg(ph, 1350, 1700);
drag = 1;
}
if (ph >= 1700) {
px = s1;
drag = ph < 1760 ? 1 : -1;
}
gx = 0;
py = root.clientHeight * 0.55;
if (ph >= 1760) {
t0w = s0;
t1w = s1;
}
if (drag >= 0)
moveBorder(drag, px);
}
function snap() {
init();
d0 = t0w;
d1 = t1w;
}
function step(dt, now) {
init();
if (demo)
script(now);
else if (drag >= 0)
moveBorder(drag, px + gx);
const k = 1 - Math.exp(-FOLLOW * dt);
d0 += (t0w - d0) * k;
d1 += (t1w - d1) * k;
}
function render() {
const T = total();
tbl.style.gridTemplateColumns = d0.toFixed(1) + "px " + (d1 - d0).toFixed(1) + "px " + (T - d1).toFixed(1) + "px";
bar0.style.transform = "translateX(" + (d0 - 8).toFixed(1) + "px)";
bar1.style.transform = "translateX(" + (d1 - 8).toFixed(1) + "px)";
aEl.textContent = String(Math.round(d0));
bEl.textContent = String(Math.round(d1 - d0));
cEl.textContent = String(Math.round(T - d1));
cursor.style.transform = "translate(" + px.toFixed(1) + "px," + py.toFixed(1) + "px)";
}
function bind(el, i) {
on(el, "pointerdown", (e) => {
userTakes();
el.setPointerCapture(e.pointerId);
const p = pos(e);
px = p.x;
py = p.y;
gx = (i === 0 ? d0 : d1) - p.x;
drag = i;
});
on(el, "pointermove", (e) => {
if (drag !== i)
return;
px = pos(e).x;
moveBorder(i, px + gx);
sync();
});
on(el, "pointerup", () => { drag = -1; });
on(el, "pointercancel", () => { drag = -1; });
on(el, "keydown", (e) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight")
return;
e.preventDefault();
userTakes();
moveBorder(i, (i === 0 ? t0w : t1w) + (e.key === "ArrowRight" ? 12 : -12));
sync();
});
}
bind(bar0, 0);
bind(bar1, 1);
function frame(ts) {
if (t0 < 0) {
t0 = ts;
last = ts;
}
const dt = Math.min(0.05, (ts - last) / 1000);
last = ts;
step(dt, ts - t0);
render();
raf = requestAnimationFrame(frame);
}
if (reduce) {
stopDemo();
snap();
render();
}
else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
})();// ColumnBorderTable - vanilla 판(03-rh-column-border-redistribute) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./ColumnBorderTable.module.scss";
export default function ColumnBorderTable() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
const offs: Array<() => void> = [];
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function seg(t: number, a: number, b: number): number {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el: EventTarget, type: string, fn: (e: any) => void): void {
el.addEventListener(type, fn);
offs.push(() => el.removeEventListener(type, fn));
}
function stopDemo(): void {
demo = false;
root.removeAttribute("data-demo");
root.classList.remove("is-demo");
}
function pos(e: PointerEvent): { x: number; y: number } {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function spring(x: number, v: number, to: number, k: number, c: number, dt: number): number[] {
const n = Math.max(1, Math.ceil(dt / 0.008));
const h = dt / n;
for (let i = 0; i < n; i += 1) {
v += (-k * (x - to) - c * v) * h;
x += v * h;
}
return [x, v];
}
function sync(): void {
if (reduce) {
snap();
render();
}
}
// 기법: 경계를 옮긴 만큼 이웃 열이 같은 양을 잃고, 이웃이 최소 폭(COL_MIN)에 닿으면 경계가 거기서 멈춘다.
// 세 열의 합은 항상 표 너비와 같고, 잘린 글자는 CSS text-overflow 가 말줄임으로 처리한다.
const COL_MIN = 56;
const FOLLOW = 18;
const tbl = q<HTMLElement>("[data-r=tbl]");
const bar0 = q<HTMLElement>("[data-r=bar0]");
const bar1 = q<HTMLElement>("[data-r=bar1]");
const cursor = q<HTMLElement>("[data-r=cursor]");
const aEl = q<HTMLElement>("[data-r=a]");
const bEl = q<HTMLElement>("[data-r=b]");
const cEl = q<HTMLElement>("[data-r=c]");
let ready = false;
let t0w = 0;
let t1w = 0;
let d0 = 0;
let d1 = 0;
let px = 0;
let py = 0;
let gx = 0;
let drag = -1;
function total(): number { return root.clientWidth; }
function init(): void {
if (ready) return;
ready = true;
t0w = d0 = total() * 0.34;
t1w = d1 = total() * 0.67;
}
function userTakes(): void {
stopDemo();
drag = -1;
init();
}
function moveBorder(i: number, want: number): void {
const T = total();
if (i === 0) t0w = clamp(want, COL_MIN, t1w - COL_MIN);
else t1w = clamp(want, t0w + COL_MIN, T - COL_MIN);
}
function script(now: number): void {
const T = total();
const ph = now % 2000;
const s0 = T * 0.34;
const s1 = T * 0.67;
px = s0;
drag = -1;
if (ph >= 200 && ph < 700) { px = s0 + (T * 0.76 - s0) * seg(ph, 200, 700); drag = 0; }
else if (ph >= 700 && ph < 800) { px = T * 0.76; drag = 0; }
else if (ph >= 800 && ph < 1250) { px = s1 + (T * 0.94 - s1) * seg(ph, 800, 1250); drag = 1; }
else if (ph >= 1250 && ph < 1350) { px = T * 0.94; drag = 1; }
else if (ph >= 1350) { px = T * 0.94 + (s1 - T * 0.94) * seg(ph, 1350, 1700); drag = 1; }
if (ph >= 1700) { px = s1; drag = ph < 1760 ? 1 : -1; }
gx = 0;
py = root.clientHeight * 0.55;
if (ph >= 1760) { t0w = s0; t1w = s1; }
if (drag >= 0) moveBorder(drag, px);
}
function snap(): void {
init();
d0 = t0w;
d1 = t1w;
}
function step(dt: number, now: number): void {
init();
if (demo) script(now);
else if (drag >= 0) moveBorder(drag, px + gx);
const k = 1 - Math.exp(-FOLLOW * dt);
d0 += (t0w - d0) * k;
d1 += (t1w - d1) * k;
}
function render(): void {
const T = total();
tbl.style.gridTemplateColumns = d0.toFixed(1) + "px " + (d1 - d0).toFixed(1) + "px " + (T - d1).toFixed(1) + "px";
bar0.style.transform = "translateX(" + (d0 - 8).toFixed(1) + "px)";
bar1.style.transform = "translateX(" + (d1 - 8).toFixed(1) + "px)";
aEl.textContent = String(Math.round(d0));
bEl.textContent = String(Math.round(d1 - d0));
cEl.textContent = String(Math.round(T - d1));
cursor.style.transform = "translate(" + px.toFixed(1) + "px," + py.toFixed(1) + "px)";
}
function bind(el: HTMLElement, i: number): void {
on(el, "pointerdown", (e: PointerEvent) => {
userTakes();
el.setPointerCapture(e.pointerId);
const p = pos(e);
px = p.x;
py = p.y;
gx = (i === 0 ? d0 : d1) - p.x;
drag = i;
});
on(el, "pointermove", (e: PointerEvent) => {
if (drag !== i) return;
px = pos(e).x;
moveBorder(i, px + gx);
sync();
});
on(el, "pointerup", () => { drag = -1; });
on(el, "pointercancel", () => { drag = -1; });
on(el, "keydown", (e: KeyboardEvent) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
userTakes();
moveBorder(i, (i === 0 ? t0w : t1w) + (e.key === "ArrowRight" ? 12 : -12));
sync();
});
}
bind(bar0, 0);
bind(bar1, 1);
function frame(ts: number): void {
if (t0 < 0) {
t0 = ts;
last = ts;
}
const dt = Math.min(0.05, (ts - last) / 1000);
last = ts;
step(dt, ts - t0);
render();
raf = requestAnimationFrame(frame);
}
if (reduce) {
stopDemo();
snap();
render();
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
};
}, []);
return (
<main className={styles["stage"]}>
<div ref={rootRef} className={styles["rh"] + " is-demo"} data-demo="1">
<div className={styles["rh__tbl"]} data-r="tbl">
<span className={styles["rh__th"]}>NAME</span><span className={styles["rh__th"]}>SIZE</span><span className={styles["rh__th"]}>MODIFIED</span>
<span className={styles["rh__td"]}>quarterly-report-final.pdf</span><span className={styles["rh__td"]}>2.4 MB</span><span className={styles["rh__td"]}>2026-09-30 18:22</span>
<span className={styles["rh__td"]}>invoice-0412-signed.png</span><span className={styles["rh__td"]}>812 KB</span><span className={styles["rh__td"]}>2026-09-28 09:05</span>
<span className={styles["rh__td"]}>team-photo-offsite.jpg</span><span className={styles["rh__td"]}>5.1 MB</span><span className={styles["rh__td"]}>2026-09-21 14:40</span>
<span className={styles["rh__td"]}>roadmap-draft-v3.md</span><span className={styles["rh__td"]}>18 KB</span><span className={styles["rh__td"]}>2026-09-19 11:12</span>
</div>
<button className={styles["rh__bar"]} data-r="bar0" type="button" aria-label="크기 조절 손잡이"></button>
<button className={styles["rh__bar"]} data-r="bar1" type="button" aria-label="크기 조절 손잡이"></button>
<p className={styles["rh__meta"]}><span className={styles["rh__tag"]}>A</span> <b data-r="a">0</b> <span className={styles["rh__tag"]}>B</span> <b data-r="b">0</b> <span className={styles["rh__tag"]}>C</span> <b data-r="c">0</b></p>
<i className={styles["rh__cursor"]} data-r="cursor"></i>
</div>
</main>
);
}03 경계선을 끌면 이웃 열이 남은 폭을 나눠 받는 표 — 측정 누적면적 11.608% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · css resize handle 9가지 — 모서리를 끌면 상자가 손에 붙어 따라온다
이 데모가 속한 글
css resize handle 9가지 — 모서리를 끌면 상자가 손에 붙어 따라온다css resize handle 을 resize 속성 하나로만 두면 손잡이 모양은 못 바꾸고, 줄 높이에 안 맞고, 한계를 넘기면 그냥 막힌다 — 09번·02번·06번이 계산으로 푼다. 아래 아홉 장면은 상자·표 열 이 글의 데모 전부 보기


