02A Column Layout Reordered to Read Left to Right
CSS columns fill vertically and scramble order, so the column comes from the card index modulo the column count to keep row-wise reading order, and the balanced mode sends a card to the lowest column when its column grows too tall.
Best forCard lists where reading order matters
COLUMNSBALANCEGAPFOLLOW

<main class="stage">
<div class="mr is-demo" id="mr" data-demo="1">
<div class="mr__bar">
<div class="mr__btns"><button class="mr__btn" type="button" data-r="m0" aria-pressed="true">Fill down</button><button class="mr__btn" type="button" data-r="m1" aria-pressed="false">Row order</button><button class="mr__btn" type="button" data-r="m2" aria-pressed="false">Balanced</button></div>
<p class="mr__live" data-r="live" aria-live="off" aria-atomic="true"><span class="mr__k">Bottom gap</span> <b data-r="spread">0</b>px</p>
</div>
<div class="mr__wall" data-r="wall"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 열을 정하는 규칙만 바꾼다: 번호 ÷ 열당 장수, 번호 % 열 수, 균형. 배치는 JS 가 계산하고 CSS 는 모양만 맡는다.
@include stage($stage-paper);
$fg: $subject-ink;
$mute: rgba($subject-ink, .72);
$btn-bg: rgba($subject-ink, .08);
$wall-bg: rgba($subject-ink, .05);
$focus: $subject-blue-deep;
$on: $stage-paper;
.mr {
position: relative;
box-sizing: border-box;
width: min(100%, 440px);
height: min(252px, calc(100vh - 2 * clamp(8px, 4vw, 24px)));
display: flex;
flex-direction: column;
gap: $sp-2;
color: $fg;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.mr b { font-weight: 800; font-variant-numeric: tabular-nums; }
.mr__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: $sp-2;
min-height: 28px;
}
.mr__btns {
display: flex;
flex-wrap: wrap;
gap: $sp-1;
}
.mr__btn {
box-sizing: border-box;
min-width: 28px;
height: 28px;
padding: 0 $sp-3;
border: 0;
border-radius: $r-pill;
background: $btn-bg;
color: $fg;
font-size: 12px;
line-height: 16px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
}
.mr__btn[aria-pressed="true"] { background: $fg; color: $on; }
.mr__btn:focus-visible { outline: 3px solid $focus; outline-offset: 2px; }
.mr__live { margin: 0; color: $mute; white-space: nowrap; }
.mr__live b { color: $fg; }
.mr__wall {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
border-radius: $r-card;
background: $wall-bg;
}
.mr__card {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
overflow: hidden;
border-radius: $r-sm;
background: linear-gradient(150deg, $subject-lilac, $subject-pink);
color: $subject-ink;
will-change: transform;
box-shadow: 0 calc(2px + var(--lift, 0) * 10px) calc(6px + var(--lift, 0) * 14px) rgba($subject-ink, .22);
}
.mr__card::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba($stage-paper, .45), rgba($stage-paper, 0) 55%);
pointer-events: none;
}
.mr__card[data-c="1"] { background: linear-gradient(150deg, $subject-sky, $subject-mint); }
.mr__card[data-c="2"] { background: linear-gradient(150deg, $subject-mint, $subject-lilac); }
.mr__card[data-c="3"] { background: linear-gradient(150deg, $subject-sky, $subject-lilac); }
.mr__card[data-c="4"] { background: linear-gradient(150deg, $subject-pink, $subject-lilac); }
.mr__card[data-c="5"] { background: linear-gradient(150deg, $subject-mint, $subject-sky); }
.mr__card i {
position: absolute;
left: $sp-2;
top: $sp-1;
font-style: normal;
font-size: 12px;
line-height: 16px;
font-weight: 800;
z-index: 1;
opacity: .85;
}
.mr__card i { font-size: 16px; line-height: 20px; }
.mr__mark {
position: absolute;
left: 0;
top: 0;
height: $sp-1;
border-radius: $r-pill;
background: rgba($fg, .3);
z-index: 3;
pointer-events: none;
}
.mr__mark[data-low] { background: $subject-mint; }
@media (prefers-reduced-motion: reduce) {
.mr__cursor, .mr__ring { display: none; }
.mr__card { will-change: auto; }
}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);
}
.mr {
position: relative;
box-sizing: border-box;
width: min(100%, 440px);
height: min(252px, 100vh - 2 * clamp(8px, 4vw, 24px));
display: flex;
flex-direction: column;
gap: 8px;
color: #17141a;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.mr b {
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.mr__bar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 28px;
}
.mr__btns {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.mr__btn {
box-sizing: border-box;
min-width: 28px;
height: 28px;
padding: 0 12px;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
color: #17141a;
font-size: 12px;
line-height: 16px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
}
.mr__btn[aria-pressed=true] {
background: #17141a;
color: #ffffff;
}
.mr__btn:focus-visible {
outline: 3px solid #0a4fe8;
outline-offset: 2px;
}
.mr__live {
margin: 0;
color: rgba(23, 20, 26, 0.72);
white-space: nowrap;
}
.mr__live b {
color: #17141a;
}
.mr__wall {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
border-radius: 16px;
background: rgba(23, 20, 26, 0.05);
}
.mr__card {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
overflow: hidden;
border-radius: 8px;
background: linear-gradient(150deg, #b9a0ff, #ff9ebb);
color: #17141a;
will-change: transform;
box-shadow: 0 calc(2px + var(--lift, 0) * 10px) calc(6px + var(--lift, 0) * 14px) rgba(23, 20, 26, 0.22);
}
.mr__card::after {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.45), rgba(255, 255, 255, 0) 55%);
pointer-events: none;
}
.mr__card[data-c="1"] {
background: linear-gradient(150deg, #6ec8ff, #4cd4a6);
}
.mr__card[data-c="2"] {
background: linear-gradient(150deg, #4cd4a6, #b9a0ff);
}
.mr__card[data-c="3"] {
background: linear-gradient(150deg, #6ec8ff, #b9a0ff);
}
.mr__card[data-c="4"] {
background: linear-gradient(150deg, #ff9ebb, #b9a0ff);
}
.mr__card[data-c="5"] {
background: linear-gradient(150deg, #4cd4a6, #6ec8ff);
}
.mr__card i {
position: absolute;
left: 8px;
top: 4px;
font-style: normal;
font-size: 12px;
line-height: 16px;
font-weight: 800;
z-index: 1;
opacity: 0.85;
}
.mr__card i {
font-size: 16px;
line-height: 20px;
}
.mr__mark {
position: absolute;
left: 0;
top: 0;
height: 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.3);
z-index: 3;
pointer-events: none;
}
.mr__mark[data-low] {
background: #4cd4a6;
}
@media (prefers-reduced-motion: reduce) {
.mr__cursor, .mr__ring {
display: none;
}
.mr__card {
will-change: auto;
}
}(function () {
const root = document.getElementById("mr");
const CLS = { card: "mr__card" };
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let last = -1;
let ready = false;
const offs = [];
function q(sel) {
return root.querySelector(sel);
}
const wall = q("[data-r=wall]");
const live = q("[data-r=live]");
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(function () {
el.removeEventListener(type, fn);
});
}
function stopDemo() {
if (!demo) return;
demo = false;
root.removeAttribute("data-demo");
root.classList.remove("is-demo");
live.setAttribute("aria-live", "polite");
}
if (reduce) live.setAttribute("aria-live", "polite");
// Cards: 시드 LCG 로 비율을 뽑는다. 이미지 파일은 없고 그라디언트와 번호만 있다.
function makeCards(n, seed, lo, hi) {
const rng = makeRng(seed);
const list = [];
for (let i = 0; i < n; i += 1) {
const el = document.createElement("div");
el.className = CLS.card;
el.setAttribute("data-c", String(i % 6));
el.innerHTML = "<i>" + (i + 1) + "</i>";
wall.appendChild(el);
list.push({
el: el, ratio: lo + rng() * (hi - lo), cat: Math.floor(rng() * 3),
x: 0, y: 0, w: 0, h: 0, tx: 0, ty: 0, tw: 0, th: 0,
s: 1, ds: 1, o: 1, dop: 1, vx: 0, vy: 0,
ox: 0, oy: 0, osx: 0, osy: 0, ovx: 0, ovy: 0, ovsx: 0, ovsy: 0,
fx: 0, fy: 0, fs: 1, fo: 1, t0: 0, sq: 0, rest: 0, wait: 0
});
}
return list;
}
function colW(cols, gap) {
return (wall.clientWidth - gap * (cols - 1)) / cols;
}
// 최단 Columns 배치: 카드를 순서대로 읽으며 지금 가장 낮은 Columns 끝에 얹고 그 Columns 높이에 Cards 높이와 간격을 더한다.
function pack(hs, cols, cw, gap) {
const colH = [];
for (let k = 0; k < cols; k += 1) colH.push(0);
const out = [];
for (let i = 0; i < hs.length; i += 1) {
let best = 0;
for (let k = 1; k < cols; k += 1) {
if (colH[k] < colH[best] - 0.5) best = k;
}
out.push({ x: best * (cw + gap), y: colH[best] });
colH[best] += hs[i] + gap;
}
return out;
}
function put(c, x, y, sx, sy) {
const st = c.el.style;
st.width = c.w.toFixed(1) + "px";
st.height = c.h.toFixed(1) + "px";
st.transform = "translate3d(" + x.toFixed(2) + "px," + y.toFixed(2) + "px,0) scale(" + sx.toFixed(3) + "," + sy.toFixed(3) + ")";
}
function frame(ts) {
const dt = last < 0 ? 0.016 : Math.min(0.05, (ts - last) / 1000);
last = ts;
if (demo) script(ts);
step(dt, ts);
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(function () {
relayout();
});
ro.observe(root);
// 기법: 열을 정하는 규칙만 바꾼다. 세로 채움은 번호 ÷ 열당 장수, 가로 순서는 번호 % Columns 수, 균형은 가로 순서로 가다가 열이 너무 높으면 가장 낮은 열로 보낸다.
const COLUMNS = 3;
const GAP = 8;
const BALANCE = 0.3;
const FOLLOW = 10;
const SEED = 16;
const COUNT = 9;
const cards = makeCards(COUNT, SEED, 0.22, 0.5);
const modeBtns = [q("[data-r=m0]"), q("[data-r=m1]"), q("[data-r=m2]")];
const spreadEl = q("[data-r=spread]");
let mode = 0;
function setMode(m) {
if (m === mode && ready) return;
mode = m;
for (let i = 0; i < 3; i += 1) modeBtns[i].setAttribute("aria-pressed", i === m ? "true" : "false");
relayout();
}
function relayout() {
const cw = colW(COLUMNS, GAP);
const colH = [];
for (let k = 0; k < COLUMNS; k += 1) colH.push(0);
const perCol = Math.ceil(cards.length / COLUMNS);
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
const h = c.ratio * cw;
let col = i % COLUMNS;
if (mode === 0) {
col = Math.min(COLUMNS - 1, Math.floor(i / perCol));
} else if (mode === 2) {
let low = 0;
for (let k = 1; k < COLUMNS; k += 1) {
if (colH[k] < colH[low]) low = k;
}
if (colH[col] - colH[low] > BALANCE * h) col = low;
}
c.w = cw;
c.h = h;
c.tx = col * (cw + GAP);
c.ty = colH[col];
colH[col] += h + GAP;
if (!ready || reduce) {
c.x = c.tx;
c.y = c.ty;
}
}
spreadEl.textContent = String(Math.round(Math.max.apply(null, colH) - Math.min.apply(null, colH)));
ready = true;
if (reduce) render();
}
function script(ts) {
setMode(Math.floor((ts % 2100) / 700));
}
function step(dt, ts) {
const k = 1 - Math.exp(-FOLLOW * dt);
for (let i = 0; i < cards.length; i += 1) {
cards[i].x += (cards[i].tx - cards[i].x) * k;
cards[i].y += (cards[i].ty - cards[i].y) * k;
}
}
function render() {
for (let i = 0; i < cards.length; i += 1) put(cards[i], cards[i].x, cards[i].y, 1, 1);
}
for (let i = 0; i < 3; i += 1) {
on(modeBtns[i], "click", function () {
stopDemo();
setMode(i);
});
}
modeBtns[0].setAttribute("aria-pressed", "true");
relayout();
if (reduce) {
render();
} else {
raf = requestAnimationFrame(frame);
}
})();// RowOrderColumns - vanilla 판(02-mr-row-order-columns) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./RowOrderColumns.module.scss";
type Card = {
el: HTMLElement; ratio: number; cat: number;
x: number; y: number; w: number; h: number; tx: number; ty: number; tw: number; th: number;
s: number; ds: number; o: number; dop: number; vx: number; vy: number;
ox: number; oy: number; osx: number; osy: number; ovx: number; ovy: number; ovsx: number; ovsy: number;
fx: number; fy: number; fs: number; fo: number; t0: number; sq: number; rest: number; wait: number;
};
export default function RowOrderColumns() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const CLS = { card: styles["mr__card"] };
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf: number = 0;
let last: number = -1;
let ready = false;
const offs: Array<() => void> = [];
function q(sel: string): HTMLElement {
return root.querySelector(sel) as HTMLElement;
}
const wall = q("[data-r=wall]");
const live = q("[data-r=live]");
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function on(el: EventTarget, type: string, fn: (e: any) => void): void {
el.addEventListener(type, fn);
offs.push(function (): void {
el.removeEventListener(type, fn);
});
}
function stopDemo(): void {
if (!demo) return;
demo = false;
root.removeAttribute("data-demo");
root.classList.remove("is-demo");
live.setAttribute("aria-live", "polite");
}
if (reduce) live.setAttribute("aria-live", "polite");
// 카드: 시드 LCG 로 비율을 뽑는다. 이미지 파일은 없고 그라디언트와 번호만 있다.
function makeCards(n: number, seed: number, lo: number, hi: number): Card[] {
const rng = makeRng(seed);
const list: Card[] = [];
for (let i = 0; i < n; i += 1) {
const el = document.createElement("div");
el.className = CLS.card;
el.setAttribute("data-c", String(i % 6));
el.innerHTML = "<i>" + (i + 1) + "</i>";
wall.appendChild(el);
list.push({
el: el, ratio: lo + rng() * (hi - lo), cat: Math.floor(rng() * 3),
x: 0, y: 0, w: 0, h: 0, tx: 0, ty: 0, tw: 0, th: 0,
s: 1, ds: 1, o: 1, dop: 1, vx: 0, vy: 0,
ox: 0, oy: 0, osx: 0, osy: 0, ovx: 0, ovy: 0, ovsx: 0, ovsy: 0,
fx: 0, fy: 0, fs: 1, fo: 1, t0: 0, sq: 0, rest: 0, wait: 0
});
}
return list;
}
function colW(cols: number, gap: number): number {
return (wall.clientWidth - gap * (cols - 1)) / cols;
}
// 최단 열 배치: 카드를 순서대로 읽으며 지금 가장 낮은 열 끝에 얹고 그 열 높이에 카드 높이와 간격을 더한다.
function pack(hs: number[], cols: number, cw: number, gap: number): Array<{ x: number; y: number }> {
const colH: number[] = [];
for (let k = 0; k < cols; k += 1) colH.push(0);
const out: Array<{ x: number; y: number }> = [];
for (let i = 0; i < hs.length; i += 1) {
let best = 0;
for (let k = 1; k < cols; k += 1) {
if (colH[k] < colH[best] - 0.5) best = k;
}
out.push({ x: best * (cw + gap), y: colH[best] });
colH[best] += hs[i] + gap;
}
return out;
}
function put(c: Card, x: number, y: number, sx: number, sy: number): void {
const st = c.el.style;
st.width = c.w.toFixed(1) + "px";
st.height = c.h.toFixed(1) + "px";
st.transform = "translate3d(" + x.toFixed(2) + "px," + y.toFixed(2) + "px,0) scale(" + sx.toFixed(3) + "," + sy.toFixed(3) + ")";
}
function frame(ts: number): void {
const dt = last < 0 ? 0.016 : Math.min(0.05, (ts - last) / 1000);
last = ts;
if (demo) script(ts);
step(dt, ts);
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(function (): void {
relayout();
});
ro.observe(root);
// 기법: 열을 정하는 규칙만 바꾼다. 세로 채움은 번호 ÷ 열당 장수, 가로 순서는 번호 % 열 수, 균형은 가로 순서로 가다가 열이 너무 높으면 가장 낮은 열로 보낸다.
const COLUMNS = 3;
const GAP = 8;
const BALANCE = 0.3;
const FOLLOW = 10;
const SEED = 16;
const COUNT = 9;
const cards = makeCards(COUNT, SEED, 0.22, 0.5);
const modeBtns = [q("[data-r=m0]"), q("[data-r=m1]"), q("[data-r=m2]")];
const spreadEl = q("[data-r=spread]");
let mode = 0;
function setMode(m: number): void {
if (m === mode && ready) return;
mode = m;
for (let i = 0; i < 3; i += 1) modeBtns[i].setAttribute("aria-pressed", i === m ? "true" : "false");
relayout();
}
function relayout(): void {
const cw = colW(COLUMNS, GAP);
const colH: number[] = [];
for (let k = 0; k < COLUMNS; k += 1) colH.push(0);
const perCol = Math.ceil(cards.length / COLUMNS);
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
const h = c.ratio * cw;
let col = i % COLUMNS;
if (mode === 0) {
col = Math.min(COLUMNS - 1, Math.floor(i / perCol));
} else if (mode === 2) {
let low = 0;
for (let k = 1; k < COLUMNS; k += 1) {
if (colH[k] < colH[low]) low = k;
}
if (colH[col] - colH[low] > BALANCE * h) col = low;
}
c.w = cw;
c.h = h;
c.tx = col * (cw + GAP);
c.ty = colH[col];
colH[col] += h + GAP;
if (!ready || reduce) {
c.x = c.tx;
c.y = c.ty;
}
}
spreadEl.textContent = String(Math.round(Math.max.apply(null, colH) - Math.min.apply(null, colH)));
ready = true;
if (reduce) render();
}
function script(ts: number): void {
setMode(Math.floor((ts % 2100) / 700));
}
function step(dt: number, ts: number): void {
const k = 1 - Math.exp(-FOLLOW * dt);
for (let i = 0; i < cards.length; i += 1) {
cards[i].x += (cards[i].tx - cards[i].x) * k;
cards[i].y += (cards[i].ty - cards[i].y) * k;
}
}
function render(): void {
for (let i = 0; i < cards.length; i += 1) put(cards[i], cards[i].x, cards[i].y, 1, 1);
}
for (let i = 0; i < 3; i += 1) {
on(modeBtns[i], "click", function (): void {
stopDemo();
setMode(i);
});
}
modeBtns[0].setAttribute("aria-pressed", "true");
relayout();
if (reduce) {
render();
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
ro.disconnect();
};
}, []);
return (
<main className={styles["stage"]}>
<div ref={rootRef} className={styles["mr"] + " is-demo"} data-demo="1">
<div className={styles["mr__bar"]}>
<div className={styles["mr__btns"]}><button className={styles["mr__btn"]} type="button" data-r="m0" aria-pressed="true">세로 채움</button><button className={styles["mr__btn"]} type="button" data-r="m1" aria-pressed="false">가로 순서</button><button className={styles["mr__btn"]} type="button" data-r="m2" aria-pressed="false">균형</button></div>
<p className={styles["mr__live"]} data-r="live" aria-live="off" aria-atomic="true"><span className={styles["mr__k"]}>끝 차이</span> <b data-r="spread">0</b>px</p>
</div>
<div className={styles["mr__wall"]} data-r="wall"></div>
</div>
</main>
);
}02 A Column Layout Reordered to Read Left to Right — Measured area 53.068% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · CSS Masonry Layout: 9 Ways to Pack Uneven Cards
The post this demo lives in
CSS Masonry Layout: 9 Ways to Pack Uneven CardsA css masonry layout of one columns or grid line scrambles order and teleports cards on resize — scenes 01, 02 and 04 fi See all demos in this post


