Magnetic table columns: values glide to new anchors
Recreates the 'tables that split, stretch and snap into place' interaction (Operate, Alvish Baldha).
<main class="demo">
<header class="intro">
<h1>Magnetic columns</h1>
<p>
Drag the grip (or use the arrow keys) and let go: the table snaps to a stop, and every value pulls
toward its new column. The grid underneath is a set of invisible slots that reflow <em>instantly</em> at each
width, changing which column a value lives in and how it's aligned. Each visible value is
<code>data-⚓️-fill</code> on its slot with <code>data-⚓️-transition="glide"</code>, so it travels
there instead of teleporting (the same trick as the masonry demo), with a springy ease and a small per-column stagger for the magnet feel.
The panel itself is <code>data-⚓️-to</code> the stage and the grip rides it with <code>data-⚓️-side="after"</code>.
The script only writes one number, the panel width.
</p>
</header>
<div class="controls">
<output class="readout" id="readout" aria-live="polite"></output>
<label class="xray-toggle"><input type="checkbox" id="xray"> X-ray (show the slots)</label>
</div>
<div class="window">
<div class="viewport" data-⚓️-root>
<div class="stage" data-⚓️-name="--stage" aria-hidden="true"></div>
<div class="stops" aria-hidden="true"></div>
<section class="panel" aria-label="Deals"
data-⚓️-name="--panel"
data-⚓️-to="--stage"
data-⚓️-align-leading
data-⚓️-stretch-y>
<header class="panel-head"><b>Deals</b><span class="count">11</span></header>
<div class="panel-body">
<div class="row labels" aria-hidden="true"></div>
<div class="rows"></div>
</div>
</section>
<div class="grip" role="separator" aria-orientation="vertical" aria-label="Resize deals table" tabindex="0"
data-⚓️-to="--panel"
data-⚓️-side="after"
data-⚓️-stretch-y></div>
</div>
</div>
</main>/* Demo styling only. The panel, grip and ghost stops are placed by data-⚓️-* attributes, and so is every
value (each one is data-⚓️-fill on a slot). This file sets looks, and which grid area + alignment a
slot gets at each width. The slots reflow instantly; the values glide. */
:root {
color-scheme: dark;
--bg: oklch(16% 0.012 260);
--ink: oklch(90% 0.01 260);
--muted: oklch(68% 0.015 260);
--line: oklch(32% 0.02 260);
--accent: oklch(78% 0.14 200);
--ease: cubic-bezier(0.32, 0.72, 0, 1);
--magnet: cubic-bezier(0.34, 1.3, 0.64, 1); /* slight overshoot: the snap */
background: var(--bg);
color: var(--ink);
font: 400 1rem/1.5 system-ui, sans-serif;
}
body { margin: 0; }
.demo { max-width: 64rem; margin-inline: auto; padding: clamp(1rem, 4vw, 2.5rem); display: grid; gap: 1.25rem; }
.intro h1 { margin: 0 0 0.35rem; font-size: 1.5rem; font-weight: 650; letter-spacing: -0.01em; }
.intro p { margin: 0; color: var(--muted); max-width: 70ch; }
code { font: 0.88em ui-monospace, monospace; color: var(--accent); }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 0.75rem 1.25rem; }
.readout { font: 500 0.85rem ui-monospace, monospace; color: var(--muted); padding: 0.4rem 0.7rem; border: 1px solid var(--line); border-radius: 0.6rem; background: oklch(20% 0.014 260); }
.xray-toggle { display: flex; gap: 0.4rem; align-items: center; color: var(--muted); font-size: 0.9rem; cursor: pointer; }
.xray-toggle input { accent-color: var(--accent); }
/* ---------- window ---------- */
.window { border: 1px solid var(--line); border-radius: 0.9rem; overflow: hidden; box-shadow: 0 1.5rem 3rem -1rem oklch(0% 0 0 / 0.6); }
.viewport {
--panel-w: 340px;
height: clamp(26rem, 68vh, 36rem);
display: grid;
background:
radial-gradient(oklch(0% 0 0 / 0.09) 1px, transparent 1px) 0 0 / 16px 16px,
oklch(95.5% 0.003 100);
color: oklch(25% 0.01 260);
}
.stage { pointer-events: none; }
/* ---------- panel: the width is the only number; transitions only after release ---------- */
.panel {
width: var(--panel-w);
container-type: inline-size;
display: flex;
flex-direction: column;
margin: 0.6rem;
border-radius: 0.8rem;
background: oklch(100% 0 0);
box-shadow: 0 0 0 1px oklch(0% 0 0 / 0.07), 0 6px 20px -8px oklch(0% 0 0 / 0.25);
font-size: 0.78rem;
overflow: hidden;
}
.viewport.snapping .panel { transition: width 0.3s var(--ease); }
.panel-head { display: flex; align-items: center; gap: 0.4rem; padding: 0.7rem 0.9rem; }
.panel-head .count { color: oklch(60% 0.01 260); }
.panel-body {
position: relative; flex: 1; min-height: 0; padding-inline: 0.5rem; padding-block-end: 0.3rem;
overflow-x: hidden; overflow-y: auto;
/* thin, themed, and reserved up front so it never appears mid-glide or nudges the slots */
scrollbar-width: thin;
scrollbar-color: oklch(0% 0 0 / 0.22) transparent;
scrollbar-gutter: stable;
}
.panel-body:hover { scrollbar-color: oklch(0% 0 0 / 0.38) transparent; }
/* values overshoot their slot while they glide; clip them to the rows so a transform can't grow the
scroll area. The rows are the values' containing block (labels keep .panel-body's). */
.rows { position: relative; overflow: clip; }
/* ---------- rows: a grid of invisible slots whose area + alignment depend on the width ---------- */
.row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto 1.5rem;
grid-template-areas: "name date who" "amt . .";
align-items: center;
column-gap: 0.6rem;
padding: 0.4rem 0.5rem;
border-radius: 0.4rem;
}
.row:not(.group):not(.labels):hover { background: oklch(97% 0.003 100); }
.s-name { grid-area: name; justify-self: stretch; }
.s-amt { grid-area: amt; justify-self: start; margin-inline-start: 1.55rem; }
.s-owners{ grid-area: who; justify-self: end; }
.s-date { grid-area: date; justify-self: end; }
.s-who { grid-area: who; justify-self: end; }
/* a slot is an invisible box sized by a hidden copy of its value; the real value is filled to it */
.slot { min-width: 0; } /* NOT position:relative: an anchor must be a descendant of its target's containing block (here .panel-body) */
.ghost, .node { display: flex; align-items: center; gap: 0.5rem; white-space: nowrap; }
.ghost { visibility: hidden; }
.node {
overflow: hidden;
/* Stagger by duration, not delay: while the panel is animating its width the slots move every frame,
and each move restarts the node's transition, so a delay would starve it until the panel settled. */
--d: calc(0.45s + var(--i) * 70ms);
transition:
inset var(--d) var(--magnet),
width var(--d) var(--magnet),
height var(--d) var(--magnet),
translate var(--d) var(--magnet),
opacity 0.35s ease;
}
.s-amt .node { color: oklch(40% 0.01 260); font-variant-numeric: tabular-nums; }
.s-date .node { color: oklch(45% 0.01 260); }
.s-owners .node { opacity: 0; }
.group { grid-template-columns: 1fr; grid-template-areas: none; background: oklch(96.5% 0.003 100); font-weight: 560; }
.group .n { margin-inline-start: 0.4rem; padding-inline: 0.3rem; border-radius: 99px; background: oklch(92% 0.004 100); color: oklch(50% 0.01 260); font-size: 0.68rem; }
.labels { max-block-size: 0; padding-block: 0; overflow: hidden; opacity: 0; transition: max-block-size 0.4s var(--ease), opacity 0.3s; position: sticky; inset-block-start: 0; z-index: 1; background: oklch(100% 0 0); }
.labels :is(.ghost, .node) { color: oklch(58% 0.01 260); font-size: 0.7rem; letter-spacing: 0.02em; text-transform: uppercase; }
.labels .s-owners .node { opacity: 0; }
/* stop 2 (>= 400): amount gets its own column, left aligned */
@container (width >= 400px) {
.row { grid-template-columns: minmax(0, 1fr) 4.5rem auto 1.5rem; grid-template-areas: "name amt date who"; }
.s-amt { margin-inline-start: 0; justify-self: start; }
}
/* stop 3 (>= 520): owners column appears; amounts pull to the trailing edge of their column */
@container (width >= 520px) {
.row { grid-template-columns: minmax(0, 1fr) 5rem 5rem 4.5rem 1.5rem; grid-template-areas: "name amt owners date who"; }
.s-amt { justify-self: end; }
.s-owners { grid-area: owners; justify-self: start; }
.s-owners .node, .labels .s-owners .node { opacity: 1; }
.labels { max-block-size: 2.2rem; padding-block: 0.4rem; opacity: 1; }
}
/* stop 4 (>= 680): roomy; amount leads, owners centre, dates trail */
@container (width >= 680px) {
.row { grid-template-columns: minmax(9rem, 1.3fr) 1fr 1fr 1fr 1.5rem; }
.s-amt { justify-self: start; }
.s-owners { justify-self: center; }
.s-date { justify-self: center; }
}
/* ---------- little bits ---------- */
.logo { flex: none; display: grid; place-items: center; width: 1.05rem; aspect-ratio: 1; border-radius: 0.28rem; background: oklch(55% 0.17 var(--h)); color: white; font: 700 0.6rem/1 system-ui; }
.avatar { flex: none; display: inline-block; width: 1.2rem; aspect-ratio: 1; border-radius: 50%; background: linear-gradient(135deg, oklch(70% 0.1 var(--h)), oklch(40% 0.1 var(--h))); box-shadow: 0 0 0 2px oklch(100% 0 0); }
.stack { display: flex; }
.stack .avatar + .avatar { margin-inline-start: -0.4rem; }
.none { color: oklch(70% 0.005 260); }
.s-name .node b { font-weight: 500; overflow: hidden; text-overflow: ellipsis; }
/* ---------- grip: sits on the panel's trailing edge, straddling the gap ---------- */
.grip {
inline-size: 1.1rem;
margin-inline-start: -0.45rem;
cursor: ew-resize;
touch-action: none;
z-index: 3;
display: grid;
place-items: center;
}
.grip::before { content: ""; inline-size: 0.2rem; block-size: 2.2rem; border-radius: 99px; background: oklch(0% 0 0 / 0.25); transition: background 0.2s, block-size 0.2s var(--ease); }
.grip:hover::before, .viewport[data-dragging] .grip::before { background: oklch(55% 0.17 250); block-size: 3.2rem; }
.grip:focus-visible { outline: 2px solid oklch(55% 0.17 250); outline-offset: -2px; border-radius: 0.4rem; }
/* ---------- ghost stops: anchored to the stage, shown only while dragging ---------- */
.stops { display: contents; }
.stop {
width: calc(var(--w) + 0.6rem);
border-inline-end: 1px dashed oklch(55% 0.17 250 / 0.55);
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.stop span { position: absolute; inset-block-end: 0.4rem; inset-inline-end: 0.4rem; font: 600 0.65rem ui-monospace, monospace; color: oklch(55% 0.17 250); }
.viewport[data-dragging] .stop { opacity: 1; }
.stop.near { background: oklch(55% 0.17 250 / 0.07); }
/* ---------- X-ray: outline the invisible slots (dashed) and the values gliding to them (solid) ---------- */
.demo:has(#xray:checked) .slot { outline: 1.5px dashed oklch(55% 0.17 250); outline-offset: -1px; }
.demo:has(#xray:checked) .node { outline: 1.5px solid oklch(65% 0.2 330); outline-offset: -1px; }
.demo:has(#xray:checked) .stop { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.viewport.snapping .panel, .grip::before, .stop, .node, .labels { transition: none; }
}// The only state is --panel-w. Everything else is attributes + container queries.
// Drag: width follows the pointer. Release (or arrow key): snap to the nearest stop.
// Each row is a grid of invisible slots; every visible value is data-⚓️-fill on its slot with glide,
// so when the grid reflows at a new width the values travel to their new column and alignment.
const STOPS = [340, 460, 600, 780];
const view = document.querySelector(".viewport");
const panel = document.querySelector(".panel");
const grip = document.querySelector(".grip");
const readout = document.getElementById("readout");
const DEALS = [
["Proposal", [["Tideline", "USD 450", "27 Aug", 2], ["Bramble", "USD 24k", "15 Oct", 1], ["Ashford", "USD 52k", "29 Oct", 1]]],
["Demo", [["Fielded", "USD 320", "12 Sept", 2], ["alvi.sh", "USD 10k", "3 Sept", 1], ["Vesper", "USD 18k", "22 Oct", 1]]],
["Leads", [["Holloway", "USD 180", "27 Sept", 3], ["Northlane", "USD 36k", "5 Nov", 1], ["Quietwork", "USD 12k", "12 Nov", 0]]],
["Closed Won", [["Wovenpath", "USD 125", "10 Aug", 1], ["Halden", "USD 28k", "21 Sept", 1]]],
];
const hue = (s) => [...s].reduce((a, c) => (a * 31 + c.charCodeAt(0)) % 360, 7);
const avatars = (name, n) => n
? `<span class="stack">${Array.from({ length: n }, (_, i) => `<i class="avatar" style="--h:${hue(name + i)}"></i>`).join("")}</span>`
: '<span class="none">–</span>';
// ghost = invisible copy that gives the slot its intrinsic size; node = the real value, anchored to the slot
const slot = (kind, i, html) =>
`<div class="slot s-${kind}" style="--i:${i}" data-⚓️-name="--v" data-⚓️-scope-name="--v">
<span class="ghost" aria-hidden="true">${html}</span>
<div class="node" data-⚓️-to="--v" data-⚓️-fill data-⚓️-transition="glide">${html}</div>
</div>`;
document.querySelector(".labels").innerHTML =
slot("name", 0, "<b>Company</b>") + slot("amt", 1, "Value") + slot("owners", 2, "Owners") + slot("date", 3, "Close") + slot("who", 4, " ");
document.querySelector(".rows").innerHTML = DEALS.map(([group, rows]) =>
`<div class="row group"><span>${group}<span class="n">${rows.length}</span></span></div>` +
rows.map(([name, value, date, owners]) => `
<div class="row">
${slot("name", 0, `<i class="logo" style="--h:${hue(name)}">${name[0]}</i><b>${name}</b>`)}
${slot("amt", 1, value)}
${slot("owners", 2, avatars(name, owners))}
${slot("date", 3, date)}
${slot("who", 4, `<i class="avatar" style="--h:${hue(name + "x")}"></i>`)}
</div>`).join("")
).join("");
// ghost stops: anchored to the stage, same edge as the panel, just different widths
document.querySelector(".stops").innerHTML = STOPS.map((w) =>
`<div class="stop" data-w="${w}" style="--w:${w}px"
data-⚓️-to="--stage" data-⚓️-align-leading data-⚓️-stretch-y><span>${w}</span></div>`).join("");
const stopEls = [...document.querySelectorAll(".stop")];
const max = () => Math.max(STOPS[0], view.clientWidth - 40);
const fits = () => STOPS.filter((w) => w <= max());
const nearest = (w) => fits().reduce((a, b) => (Math.abs(b - w) < Math.abs(a - w) ? b : a));
const LAYOUTS = ["compact: value under name", "value column, left", "owners join, values pull right", "roomy: values spread"];
const layoutFor = (w) => (w >= 680 ? 3 : w >= 520 ? 2 : w >= 400 ? 1 : 0);
function set(w) {
view.style.setProperty("--panel-w", `${w}px`);
readout.textContent = `${Math.round(w)}px · ${LAYOUTS[layoutFor(w)]}`;
const n = nearest(w);
stopEls.forEach((s) => s.classList.toggle("near", +s.dataset.w === n));
}
function snap(w) {
view.classList.add("snapping");
set(nearest(w));
}
let dragging = false;
grip.addEventListener("pointerdown", (e) => {
dragging = true;
grip.setPointerCapture(e.pointerId);
view.classList.remove("snapping");
view.toggleAttribute("data-dragging", true);
});
grip.addEventListener("pointermove", (e) => {
if (!dragging) return;
const left = panel.getBoundingClientRect().left;
set(Math.min(max(), Math.max(STOPS[0] - 40, e.clientX - left)));
});
const end = () => {
if (!dragging) return;
dragging = false;
view.toggleAttribute("data-dragging", false);
snap(panel.getBoundingClientRect().width);
};
grip.addEventListener("pointerup", end);
grip.addEventListener("pointercancel", end);
grip.addEventListener("keydown", (e) => {
const list = fits();
const i = list.indexOf(nearest(panel.getBoundingClientRect().width));
const next = { ArrowRight: i + 1, ArrowUp: i + 1, ArrowLeft: i - 1, ArrowDown: i - 1, Home: 0, End: list.length - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
snap(list[Math.min(list.length - 1, Math.max(0, next))]);
});
addEventListener("resize", () => snap(panel.getBoundingClientRect().width));
set(STOPS[0]);data-⚓️-align-leadingdata-⚓️-filldata-⚓️-namedata-⚓️-rootdata-⚓️-scope-namedata-⚓️-sidedata-⚓️-stretch-ydata-⚓️-todata-⚓️-transition
Barrel roll number counter: digits glide between anchored slots →