05Justified Rows Scaled to Fill Each Line Exactly
It adds aspect ratios until a line fills at the target height, then breaks and applies one scale factor to that line's height, so every row ends flush on the same right edge.
Best forPhoto albums and result lists
TARGET_HGAPMAX_STRETCHLERP

<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="less" aria-label="Shorter rows">−</button><button class="mr__btn" type="button" data-r="more" aria-label="Taller rows">+</button></div>
<p class="mr__live" data-r="live" aria-live="off" aria-atomic="true"><span class="mr__k">Rows</span> <b data-r="rows">3</b></p>
</div>
<div class="mr__wall" data-r="wall"><i class="mr__edge"></i></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__edge {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 2px;
background: rgba($subject-blue-deep, .45);
z-index: 5;
pointer-events: none;
}
.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__edge {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 2px;
background: rgba(10, 79, 232, 0.45);
z-index: 5;
pointer-events: none;
}
.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);
// 기법: 목표 높이에서 가로세로 비율을 더해 가다가 Rows 너비를 넘으면 끊고, 그 줄을 (남는 너비 ÷ 비율 합)배 해서 오른쪽 끝을 맞춘다.
const TARGET_H = 44;
const GAP = 4;
const MAX_STRETCH = 0.35;
const LERP = 0.2;
const SEED = 17;
const COUNT = 14;
const cards = makeCards(COUNT, SEED, 0.7, 1.6);
const rowsEl = q("[data-r=rows]");
let th = TARGET_H;
let want = TARGET_H;
let laidH = -1;
let rowsNow = 0;
function layout() {
const width = wall.clientWidth;
let i = 0;
let y = 0;
let rows = 0;
while (i < cards.length) {
const from = i;
let sum = 0;
while (i < cards.length) {
sum += cards[i].ratio;
i += 1;
if (sum * th + GAP * (i - from - 1) >= width) break;
}
const n = i - from;
let s = (width - GAP * (n - 1)) / (sum * th);
// 마지막 줄이 너무 짧으면 늘리지 않고 목표 높이를 지킨다.
if (s > 1 + MAX_STRETCH) s = 1;
const rowH = th * s;
let x = 0;
for (let j = from; j < i; j += 1) {
const c = cards[j];
c.w = cards[j].ratio * rowH;
c.h = rowH;
c.x = x;
c.y = y;
x += c.w + GAP;
}
y += rowH + GAP;
rows += 1;
}
laidH = th;
if (rows !== rowsNow) {
rowsNow = rows;
rowsEl.textContent = String(rows);
}
}
function relayout() {
layout();
ready = true;
if (reduce) render();
}
function script(ts) {
th = 44 + 22 * (0.5 - 0.5 * Math.cos((ts % 2000) / 2000 * Math.PI * 2));
want = th;
}
function step(dt, ts) {
if (!demo) th += (want - th) * (1 - Math.pow(1 - LERP, dt * 60));
if (Math.abs(th - laidH) > 0.01) layout();
}
function render() {
for (let i = 0; i < cards.length; i += 1) put(cards[i], cards[i].x, cards[i].y, 1, 1);
}
function nudge(d) {
stopDemo();
want = clamp(want + d, 32, 72);
if (reduce) {
th = want;
relayout();
}
}
on(q("[data-r=less]"), "click", function () {
nudge(-8);
});
on(q("[data-r=more]"), "click", function () {
nudge(8);
});
relayout();
if (reduce) {
render();
} else {
raf = requestAnimationFrame(frame);
}
})();// JustifiedRows - vanilla 판(05-mr-justified-rows) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./JustifiedRows.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 JustifiedRows() {
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 TARGET_H = 44;
const GAP = 4;
const MAX_STRETCH = 0.35;
const LERP = 0.2;
const SEED = 17;
const COUNT = 14;
const cards = makeCards(COUNT, SEED, 0.7, 1.6);
const rowsEl = q("[data-r=rows]");
let th = TARGET_H;
let want = TARGET_H;
let laidH = -1;
let rowsNow = 0;
function layout(): void {
const width = wall.clientWidth;
let i = 0;
let y = 0;
let rows = 0;
while (i < cards.length) {
const from = i;
let sum = 0;
while (i < cards.length) {
sum += cards[i].ratio;
i += 1;
if (sum * th + GAP * (i - from - 1) >= width) break;
}
const n = i - from;
let s = (width - GAP * (n - 1)) / (sum * th);
// 마지막 줄이 너무 짧으면 늘리지 않고 목표 높이를 지킨다.
if (s > 1 + MAX_STRETCH) s = 1;
const rowH = th * s;
let x = 0;
for (let j = from; j < i; j += 1) {
const c = cards[j];
c.w = cards[j].ratio * rowH;
c.h = rowH;
c.x = x;
c.y = y;
x += c.w + GAP;
}
y += rowH + GAP;
rows += 1;
}
laidH = th;
if (rows !== rowsNow) {
rowsNow = rows;
rowsEl.textContent = String(rows);
}
}
function relayout(): void {
layout();
ready = true;
if (reduce) render();
}
function script(ts: number): void {
th = 44 + 22 * (0.5 - 0.5 * Math.cos((ts % 2000) / 2000 * Math.PI * 2));
want = th;
}
function step(dt: number, ts: number): void {
if (!demo) th += (want - th) * (1 - Math.pow(1 - LERP, dt * 60));
if (Math.abs(th - laidH) > 0.01) layout();
}
function render(): void {
for (let i = 0; i < cards.length; i += 1) put(cards[i], cards[i].x, cards[i].y, 1, 1);
}
function nudge(d: number): void {
stopDemo();
want = clamp(want + d, 32, 72);
if (reduce) {
th = want;
relayout();
}
}
on(q("[data-r=less]"), "click", function (): void {
nudge(-8);
});
on(q("[data-r=more]"), "click", function (): void {
nudge(8);
});
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="less" aria-label="줄 높이 낮추기">−</button><button className={styles["mr__btn"]} type="button" data-r="more" aria-label="줄 높이 높이기">+</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="rows">3</b></p>
</div>
<div className={styles["mr__wall"]} data-r="wall"><i className={styles["mr__edge"]}></i></div>
</div>
</main>
);
}05 Justified Rows Scaled to Fill Each Line Exactly — Measured area 44.764% · 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
If you like this effect - similar demos
Galleries05 Grid-row-span waterfall (keeps reading order)9 CSS Image Gallery Grid Effects
Galleries08 Layered Smoke Where Each Layer Moves at a Different SpeedCSS Smoke Effect: 9 Canvas Noise Patterns
Galleries03 Window Droplets Trickling Down and Merging Into Each Other9 CSS Rain Animation Layers for a Storm Window