03A Table Whose Neighbor Column Absorbs the Width You Drag
Moving a column border shrinks the right column by the same amount until its minimum, and truncated text re-ellipsizes to the new width at once.
Best forData tables and lists
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="Resize handle"></button>
<button class="rh__bar" data-r="bar1" type="button" aria-label="Resize handle"></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 A Table Whose Neighbor Column Absorbs the Width You Drag — Measured area 11.608% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Resize Handle: 9 Demos That Follow Your Grip
The post this demo lives in
CSS Resize Handle: 9 Demos That Follow Your GripA css resize handle left to the resize property alone cannot change its shape, ignores line height and just stops at the See all demos in this post
If you like this effect - similar demos
drag01 FLIP list — computing the before/after offset and releasing it into a transitionDrag to Reorder UI: 9 Lists, One Drop Index
drag03 Rubber Boundary That Stretches and Snaps Back Past the EdgeDrag and Drop Animation CSS: 9 Physics Patterns
drag06 An Auto Solver That Starts from the BorderJigsaw Puzzle JavaScript: 9 Snapping Tab Boards