css masonry layout 9パターン — 高さの違うカードが隙間なく収まる配置
css masonry layout を columns や grid の一行だけで組むと読む順が縦にねじれ、ウィンドウ幅が変わるとカードが瞬間移動する — 01番・02番・04番が最短の列への配置・行順の割り当て・FLIP のばねで解く。
九つのレンガ配置を画面でそのまま動かしてみる
01 mr-shortest-column-pack
02 mr-row-order-columns
03 mr-grid-row-span
04 mr-flip-reflow-spring
05 mr-justified-rows
06 mr-pointer-lens-field
07 mr-column-drift-loop
08 mr-filter-shuffle-reflow
09 mr-gravity-drop-stack
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 一番低い列を探して一枚ずつ載せる最短列の配置
- 02 左から右へ読めるように順を直した列配置
- 03 画像の比率から行の数を計算するグリッドスパン
- 04 列の数が変わるときばねで定位置を探す FLIP 再配置
- 05 一行の幅にぴったり合わせて高さを揃える均等な行
- 06 ポインターの近くで隣が道をあける、レンズ状の配置
- 07 列ごとに逆向きへ流れる無限のレンガ
- 08 フィルターを押すと順番に位置が移るシャッフル
- 09 上から落ちて弾み、積み上がるレンガ
css masonry layout 9パターン — 列の高さ・行の単位・ばねを計算して隙間を閉じる
一つの場面は、幅が最大 440px・高さ 216px の壁に、番号だけを書いたグラデーションのカードを積みます。カードの高さは種を固定した乱数列が出した比率をもとに決めるので毎回同じ並びになり、壁の上にあるバーのボタンで列の数・モード・フィルターをその場で変えられます。
配置は二つの層でできています。一つは目標の位置を求める計算、もう一つはその位置へ追いかける動きで、規則は場面ごとに違います。カードの高さがあらかじめ分かっていれば位置は決まりますが、画面はその位置へすぐ移るのではなく、別の仕組みで追いかけます。速度を持つばねは 04 だけ、09 は重力を積分し、01・02・06 は目標との差を一定の割合ずつ縮める指数接近、07 は速度に時間を掛けて足し、08 は時間に沿ったイージングです。03 と 05 は追いかけずに、直接計算した値をそのまま使います。九つのファイルとも CSS の transition と @keyframes は一行もなく、ライブラリも外部へのリクエストも Math.random もありません。
画像の比率がばらばらなギャラリーが目的なら 03 から、絞り込みボタンの付いた一覧なら 08 から、ウィンドウ幅が変わるレスポンシブな画面なら 04 から開くのが近道です。スライドや拡大やライトボックスといったギャラリーのほかの効果は CSS フォト ギャラリー 9選 — メイソンリーからライトボックスまで と 一行でコピペOK CSS アニメーション ギャラリー 9選 が受け持ち、この記事はカードがどの位置に座るかだけを扱います。
css masonry grid を技法別のコードで見る
ここに並べる断片は index.html の <script> の中で途切れずに続く行だけを抜いたもので、長さは25行までです。css masonry effect と呼ばれる動きの多くは、ここにある位置の計算の上に「追いかけ」を一枚重ねたものなので、計算から読んでおくと値を変えたときに何が変わるか見当がつきます。
01一番低い列を探して一枚ずつ載せる最短列の配置
カードを順に読み、その時点でいちばん低い列(同じ高さなら左)に置いて、その列の高さにカードの高さと間隔 8px を足します。高さがばらばらのカードを隙間少なく積みたいピンボードやポートフォリオの格子に向き、列の下の細い棒が各列の下端の高さを見せます。累積面積は47.7%で、動きが捉えられたフレームは23/23でした。

<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="列を減らす">−</button><button class="mr__btn" type="button" data-r="more" aria-label="列を増やす">+</button><button class="mr__btn" type="button" data-r="again">積み直す</button></div>
<p class="mr__live" data-r="live" aria-live="off" aria-atomic="true"><span class="mr__k">列</span> <b data-r="cols">3</b></p>
</div>
<div class="mr__wall" data-r="wall"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 카드를 한 장 놓을 때마다 가장 낮은 열 끝을 읽어 그 아래에 얹는다. 배치는 JS 가 계산하고 CSS 는 모양만 맡는다.
@include stage($stage-ink);
$fg: $subject-cream;
$mute: $tick-mute;
$btn-bg: rgba($subject-cream, .14);
$wall-bg: rgba($subject-cream, .06);
$focus: $subject-pink;
$on: $stage-ink;
.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__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: #17141a;
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: #fff7e6;
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(255, 247, 230, 0.14);
color: #fff7e6;
font-size: 12px;
line-height: 16px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
}
.mr__btn[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.mr__btn:focus-visible {
outline: 3px solid #ff9ebb;
outline-offset: 2px;
}
.mr__live {
margin: 0;
color: #9a93a6;
white-space: nowrap;
}
.mr__live b {
color: #fff7e6;
}
.mr__wall {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 247, 230, 0.06);
}
.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__mark {
position: absolute;
left: 0;
top: 0;
height: 4px;
border-radius: 999px;
background: rgba(255, 247, 230, 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", mark: "mr__mark" };
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");
// カード: 시드 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;
}
// 최단 列 배치: 카드를 순서대로 읽으며 지금 가장 낮은 列 끝에 얹고 그 列 높이에 カード 높이와 간격을 더한다.
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);
// 기법: 카드를 한 장 놓을 때마다 지금 가장 낮은 열의 끝을 읽어 그 아래에 얹고, 그 列 높이에 カード 높이와 간격을 더한다.
const COLUMNS = 3;
const GAP = 8;
const SETTLE = 12;
const SEED = 7;
const COUNT = 9;
const cards = makeCards(COUNT, SEED, 0.22, 0.46);
const colsEl = q("[data-r=cols]");
const againBtn = q("[data-r=again]");
const marks = [];
const level = [0, 0, 0, 0, 0, 0];
const markY = [0, 0, 0, 0, 0, 0];
let cols = COLUMNS;
let shown = 0;
let userT0 = 0;
let cwNow = 0;
for (let k = 0; k < 6; k += 1) {
const m = document.createElement("i");
m.className = CLS.mark;
wall.appendChild(m);
marks.push(m);
}
function levels() {
for (let k = 0; k < 6; k += 1) level[k] = 0;
for (let i = 0; i < shown; i += 1) {
const k = Math.round(cards[i].tx / (cwNow + GAP));
level[k] = Math.max(level[k], cards[i].ty + cards[i].th + GAP);
}
}
function setShown(n) {
if (n === shown) return;
for (let i = shown; i < n; i += 1) {
cards[i].x = cards[i].tx;
cards[i].y = cards[i].ty - 56;
cards[i].o = reduce ? 1 : 0;
if (reduce) cards[i].y = cards[i].ty;
}
shown = n;
levels();
}
function relayout() {
cwNow = colW(cols, GAP);
const hs = cards.map(function (c) {
return c.ratio * cwNow;
});
const at = pack(hs, cols, cwNow, GAP);
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
c.w = cwNow;
c.h = hs[i];
c.th = hs[i];
c.tx = at[i].x;
c.ty = at[i].y;
if (!ready || reduce) {
c.x = c.tx;
c.y = c.ty;
}
}
if (!ready) shown = reduce ? COUNT : 0;
for (let k = 0; k < 6; k += 1) {
marks[k].style.display = k < cols ? "" : "none";
marks[k].style.width = cwNow.toFixed(1) + "px";
}
levels();
ready = true;
colsEl.textContent = String(cols);
if (reduce) {
for (let k = 0; k < 6; k += 1) markY[k] = level[k];
render();
}
}
function script(ts) {
setShown(clamp(Math.floor(((ts % 2000) - 200) / 170) + 1, 0, COUNT));
}
function step(dt, ts) {
if (!demo && userT0 > 0) setShown(clamp(Math.floor((ts - userT0) / 170) + 1, 0, COUNT));
const k = 1 - Math.exp(-SETTLE * dt);
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
if (i < shown) {
c.x += (c.tx - c.x) * k;
c.y += (c.ty - c.y) * k;
c.o += (1 - c.o) * Math.min(1, k * 1.6);
} else {
c.o = 0;
}
}
for (let m = 0; m < 6; m += 1) markY[m] += (level[m] - markY[m]) * k;
}
function render() {
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
c.el.style.opacity = c.o.toFixed(2);
put(c, c.x, c.y, 1, 1);
}
let low = 0;
for (let m = 1; m < cols; m += 1) {
if (level[m] < level[low] - 0.5) low = m;
}
for (let m = 0; m < 6; m += 1) {
const y = Math.max(markY[m] - 6, 0);
marks[m].style.transform = "translate3d(" + (m * (cwNow + GAP)).toFixed(1) + "px," + y.toFixed(1) + "px,0)";
if (m === low) marks[m].setAttribute("data-low", "1");
else marks[m].removeAttribute("data-low");
}
}
function takeOver() {
if (!demo) return;
stopDemo();
userT0 = 0;
setShown(COUNT);
}
on(q("[data-r=less]"), "click", function () {
takeOver();
cols = clamp(cols - 1, 2, 5);
relayout();
});
on(q("[data-r=more]"), "click", function () {
takeOver();
cols = clamp(cols + 1, 2, 5);
relayout();
});
on(againBtn, "click", function () {
stopDemo();
setShown(0);
userT0 = performance.now();
});
againBtn.hidden = reduce;
relayout();
if (reduce) {
render();
} else {
raf = requestAnimationFrame(frame);
}
})();// ShortestColumnPack - vanilla 판(01-mr-shortest-column-pack) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./ShortestColumnPack.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 ShortestColumnPack() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const CLS = { card: styles["mr__card"], mark: styles["mr__mark"] };
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 SETTLE = 12;
const SEED = 7;
const COUNT = 9;
const cards = makeCards(COUNT, SEED, 0.22, 0.46);
const colsEl = q("[data-r=cols]");
const againBtn = q("[data-r=again]");
const marks: HTMLElement[] = [];
const level: number[] = [0, 0, 0, 0, 0, 0];
const markY: number[] = [0, 0, 0, 0, 0, 0];
let cols: number = COLUMNS;
let shown = 0;
let userT0 = 0;
let cwNow = 0;
for (let k = 0; k < 6; k += 1) {
const m = document.createElement("i");
m.className = CLS.mark;
wall.appendChild(m);
marks.push(m);
}
function levels(): void {
for (let k = 0; k < 6; k += 1) level[k] = 0;
for (let i = 0; i < shown; i += 1) {
const k = Math.round(cards[i].tx / (cwNow + GAP));
level[k] = Math.max(level[k], cards[i].ty + cards[i].th + GAP);
}
}
function setShown(n: number): void {
if (n === shown) return;
for (let i = shown; i < n; i += 1) {
cards[i].x = cards[i].tx;
cards[i].y = cards[i].ty - 56;
cards[i].o = reduce ? 1 : 0;
if (reduce) cards[i].y = cards[i].ty;
}
shown = n;
levels();
}
function relayout(): void {
cwNow = colW(cols, GAP);
const hs = cards.map(function (c: Card): number {
return c.ratio * cwNow;
});
const at = pack(hs, cols, cwNow, GAP);
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
c.w = cwNow;
c.h = hs[i];
c.th = hs[i];
c.tx = at[i].x;
c.ty = at[i].y;
if (!ready || reduce) {
c.x = c.tx;
c.y = c.ty;
}
}
if (!ready) shown = reduce ? COUNT : 0;
for (let k = 0; k < 6; k += 1) {
marks[k].style.display = k < cols ? "" : "none";
marks[k].style.width = cwNow.toFixed(1) + "px";
}
levels();
ready = true;
colsEl.textContent = String(cols);
if (reduce) {
for (let k = 0; k < 6; k += 1) markY[k] = level[k];
render();
}
}
function script(ts: number): void {
setShown(clamp(Math.floor(((ts % 2000) - 200) / 170) + 1, 0, COUNT));
}
function step(dt: number, ts: number): void {
if (!demo && userT0 > 0) setShown(clamp(Math.floor((ts - userT0) / 170) + 1, 0, COUNT));
const k = 1 - Math.exp(-SETTLE * dt);
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
if (i < shown) {
c.x += (c.tx - c.x) * k;
c.y += (c.ty - c.y) * k;
c.o += (1 - c.o) * Math.min(1, k * 1.6);
} else {
c.o = 0;
}
}
for (let m = 0; m < 6; m += 1) markY[m] += (level[m] - markY[m]) * k;
}
function render(): void {
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
c.el.style.opacity = c.o.toFixed(2);
put(c, c.x, c.y, 1, 1);
}
let low = 0;
for (let m = 1; m < cols; m += 1) {
if (level[m] < level[low] - 0.5) low = m;
}
for (let m = 0; m < 6; m += 1) {
const y = Math.max(markY[m] - 6, 0);
marks[m].style.transform = "translate3d(" + (m * (cwNow + GAP)).toFixed(1) + "px," + y.toFixed(1) + "px,0)";
if (m === low) marks[m].setAttribute("data-low", "1");
else marks[m].removeAttribute("data-low");
}
}
function takeOver(): void {
if (!demo) return;
stopDemo();
userT0 = 0;
setShown(COUNT);
}
on(q("[data-r=less]"), "click", function (): void {
takeOver();
cols = clamp(cols - 1, 2, 5);
relayout();
});
on(q("[data-r=more]"), "click", function (): void {
takeOver();
cols = clamp(cols + 1, 2, 5);
relayout();
});
on(againBtn, "click", function (): void {
stopDemo();
setShown(0);
userT0 = performance.now();
});
againBtn.hidden = reduce;
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><button className={styles["mr__btn"]} type="button" data-r="again">다시 쌓기</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="cols">3</b></p>
</div>
<div className={styles["mr__wall"]} data-r="wall"></div>
</div>
</main>
);
}01 · 一番低い列を探して一枚ずつ載せる最短列の配置 — 計測 累積面積 47.727% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
}
02左から右へ読めるように順を直した列配置
縦に埋めるモードは番号が列に沿って下りるので読む順が縦にねじれ、横の順は番号を列数で割った余りで列を決め、均衡は横の順で進みながら列が高くなりすぎたら一番低い列へ回します。同じカード九枚でルールだけを替えると列の下端の差は縦 5px・横 72px・均衡 30px になり(幅 424px の3列)、均衡で入れ替わるのはカード5と6だけです。累積面積は53.1%、動いたフレームは19/23でした。

<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">縦に埋める</button><button class="mr__btn" type="button" data-r="m1" aria-pressed="false">横の順</button><button class="mr__btn" type="button" data-r="m2" aria-pressed="false">均衡</button></div>
<p class="mr__live" data-r="live" aria-live="off" aria-atomic="true"><span class="mr__k">下端の差</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");
// カード: 시드 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;
}
// 최단 列 배치: 카드를 순서대로 읽으며 지금 가장 낮은 列 끝에 얹고 그 列 높이에 カード 높이와 간격을 더한다.
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);
// 기법: 열을 정하는 규칙만 바꾼다. 세로 채움은 번호 ÷ 열당 장수, 가로 순서는 번호 % 列 수, 균형은 가로 순서로 가다가 열이 너무 높으면 가장 낮은 열로 보낸다.
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 · 左から右へ読めるように順を直した列配置 — 計測 累積面積 53.068% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
03画像の比率から行の数を計算するグリッドスパン
配置は本物の CSS grid が行い、JavaScript は span = ceil((h + 8) / 16) で行の数だけを決めて gridRowEnd に書き込みます。画像の比率がばらばらなギャラリーに向き、カードの中の番号の場所にはその行の数が表示されます。累積面積は60.2%、動いたフレームは23/23でした。

<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="幅を狭く">−</button><button class="mr__btn" type="button" data-r="more" aria-label="幅を広く">+</button></div>
<p class="mr__live" data-r="live" aria-live="off" aria-atomic="true"><span class="mr__k">最初のカード span</span> <b data-r="span">5</b></p>
</div>
<div class="mr__wall" data-r="wall"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 카드 높이를 행 단위로 올림한 값을 grid-row-end: span 으로 준다. 배치는 JS 가 계산하고 CSS 는 모양만 맡는다.
@include stage($stage-ink);
$fg: $subject-cream;
$mute: $tick-mute;
$btn-bg: rgba($subject-cream, .14);
$wall-bg: rgba($subject-cream, .06);
$focus: $subject-pink;
$on: $stage-ink;
.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__wall[data-grid] {
display: grid;
grid-template-columns: repeat(var(--cols, 3), 1fr);
grid-auto-rows: var(--unit, 8px);
gap: var(--gap, 8px);
align-content: start;
align-self: flex-start;
width: 100%;
}
.mr__wall[data-grid] .mr__card {
position: relative;
left: auto;
top: auto;
}
.mr__wall[data-grid] .mr__card i { font-size: 12px; }
.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: #17141a;
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: #fff7e6;
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(255, 247, 230, 0.14);
color: #fff7e6;
font-size: 12px;
line-height: 16px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
}
.mr__btn[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.mr__btn:focus-visible {
outline: 3px solid #ff9ebb;
outline-offset: 2px;
}
.mr__live {
margin: 0;
color: #9a93a6;
white-space: nowrap;
}
.mr__live b {
color: #fff7e6;
}
.mr__wall {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 247, 230, 0.06);
}
.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__wall[data-grid] {
display: grid;
grid-template-columns: repeat(var(--cols, 3), 1fr);
grid-auto-rows: var(--unit, 8px);
gap: var(--gap, 8px);
align-content: start;
align-self: flex-start;
width: 100%;
}
.mr__wall[data-grid] .mr__card {
position: relative;
left: auto;
top: auto;
}
.mr__wall[data-grid] .mr__card i {
font-size: 12px;
}
.mr__mark {
position: absolute;
left: 0;
top: 0;
height: 4px;
border-radius: 999px;
background: rgba(255, 247, 230, 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");
// カード: 시드 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;
}
// 최단 列 배치: 카드를 순서대로 읽으며 지금 가장 낮은 列 끝에 얹고 그 列 높이에 カード 높이와 간격을 더한다.
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);
// 기법: カード 높이(列 폭 × 비율)에 간격을 더해 행 단위로 올림한 값을 grid-row-end: span 으로 준다. 자리는 grid 가 알아서 찾는다.
const ROW_UNIT = 8;
const GAP = 8;
const SPAN_BIAS = 0;
const COLUMNS = 3;
const SEED = 5;
const COUNT = 9;
const cards = makeCards(COUNT, SEED, 0.24, 0.56);
const spanEl = q("[data-r=span]");
const spans = [];
let frac = 1;
let want = 1;
let shownFrac = -1;
wall.setAttribute("data-grid", "1");
wall.style.setProperty("--cols", String(COLUMNS));
wall.style.setProperty("--unit", ROW_UNIT + "px");
wall.style.setProperty("--gap", GAP + "px");
for (let i = 0; i < cards.length; i += 1) spans.push(0);
function spanOf(h) {
return Math.ceil((h + GAP) / (ROW_UNIT + GAP)) + SPAN_BIAS;
}
function relayout() {
wall.style.width = Math.round(root.clientWidth * frac) + "px";
const cw = (wall.clientWidth - GAP * (COLUMNS - 1)) / COLUMNS;
for (let i = 0; i < cards.length; i += 1) {
const n = spanOf(cw * cards[i].ratio);
if (n !== spans[i]) {
spans[i] = n;
cards[i].el.style.gridRowEnd = "span " + n;
const badge = cards[i].el.firstChild;
if (badge) badge.textContent = String(n);
}
}
spanEl.textContent = String(spans[0]);
shownFrac = frac;
ready = true;
}
function script(ts) {
frac = 0.62 + 0.38 * (0.5 + 0.5 * Math.cos((ts % 2000) / 2000 * Math.PI * 2));
want = frac;
}
function step(dt, ts) {
if (!demo) frac += (want - frac) * (1 - Math.exp(-12 * dt));
}
function render() {
if (Math.abs(frac - shownFrac) > 0.0005) relayout();
}
function nudge(d) {
stopDemo();
want = clamp(want + d, 0.5, 1);
if (reduce) {
frac = want;
relayout();
}
}
on(q("[data-r=less]"), "click", function () {
nudge(-0.17);
});
on(q("[data-r=more]"), "click", function () {
nudge(0.17);
});
relayout();
if (reduce) {
render();
} else {
raf = requestAnimationFrame(frame);
}
})();// GridRowSpan - vanilla 판(03-mr-grid-row-span) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./GridRowSpan.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 GridRowSpan() {
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);
// 기법: 카드 높이(열 폭 × 비율)에 간격을 더해 행 단위로 올림한 값을 grid-row-end: span 으로 준다. 자리는 grid 가 알아서 찾는다.
const ROW_UNIT = 8;
const GAP = 8;
const SPAN_BIAS = 0;
const COLUMNS = 3;
const SEED = 5;
const COUNT = 9;
const cards = makeCards(COUNT, SEED, 0.24, 0.56);
const spanEl = q("[data-r=span]");
const spans: number[] = [];
let frac = 1;
let want = 1;
let shownFrac = -1;
wall.setAttribute("data-grid", "1");
wall.style.setProperty("--cols", String(COLUMNS));
wall.style.setProperty("--unit", ROW_UNIT + "px");
wall.style.setProperty("--gap", GAP + "px");
for (let i = 0; i < cards.length; i += 1) spans.push(0);
function spanOf(h: number): number {
return Math.ceil((h + GAP) / (ROW_UNIT + GAP)) + SPAN_BIAS;
}
function relayout(): void {
wall.style.width = Math.round(root.clientWidth * frac) + "px";
const cw = (wall.clientWidth - GAP * (COLUMNS - 1)) / COLUMNS;
for (let i = 0; i < cards.length; i += 1) {
const n = spanOf(cw * cards[i].ratio);
if (n !== spans[i]) {
spans[i] = n;
cards[i].el.style.gridRowEnd = "span " + n;
const badge = cards[i].el.firstChild;
if (badge) badge.textContent = String(n);
}
}
spanEl.textContent = String(spans[0]);
shownFrac = frac;
ready = true;
}
function script(ts: number): void {
frac = 0.62 + 0.38 * (0.5 + 0.5 * Math.cos((ts % 2000) / 2000 * Math.PI * 2));
want = frac;
}
function step(dt: number, ts: number): void {
if (!demo) frac += (want - frac) * (1 - Math.exp(-12 * dt));
}
function render(): void {
if (Math.abs(frac - shownFrac) > 0.0005) relayout();
}
function nudge(d: number): void {
stopDemo();
want = clamp(want + d, 0.5, 1);
if (reduce) {
frac = want;
relayout();
}
}
on(q("[data-r=less]"), "click", function (): void {
nudge(-0.17);
});
on(q("[data-r=more]"), "click", function (): void {
nudge(0.17);
});
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</span> <b data-r="span">5</b></p>
</div>
<div className={styles["mr__wall"]} data-r="wall"></div>
</div>
</main>
);
}03 · 画像の比率から行の数を計算するグリッドスパン — 計測 累積面積 60.184% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function spanOf(h) {
return Math.ceil((h + GAP) / (ROW_UNIT + GAP)) + SPAN_BIAS;
}
function relayout() {
wall.style.width = Math.round(root.clientWidth * frac) + "px";
const cw = (wall.clientWidth - GAP * (COLUMNS - 1)) / COLUMNS;
for (let i = 0; i < cards.length; i += 1) {
const n = spanOf(cw * cards[i].ratio);
if (n !== spans[i]) {
spans[i] = n;
cards[i].el.style.gridRowEnd = "span " + n;
const badge = cards[i].el.firstChild;
if (badge) badge.textContent = String(n);
}
}
spanEl.textContent = String(spans[0]);
shownFrac = frac;
ready = true;
}
04列の数が変わるときばねで定位置を探す FLIP 再配置
新しい位置を求めたとき、relayout が古い位置との差をオフセットとして残し、フレームごとに kick がばねでそのオフセットを0まで引き寄せていく方式が FLIP です。幅の差も scale で打ち消すので3列から4列へ替えるときは +0.36 だけ伸びた状態で出発し、途中で列の数をもう一度変えても今の画面上の位置から続くため跳びません。累積面積は48.4%、動いたフレームは19/23でした。

<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="列を減らす">−</button><button class="mr__btn" type="button" data-r="more" aria-label="列を増やす">+</button></div>
<p class="mr__live" data-r="live" aria-live="off" aria-atomic="true"><span class="mr__k">列</span> <b data-r="cols">3</b></p>
</div>
<div class="mr__wall" data-r="wall"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 새 자리를 계산하고 옛 자리와의 차이를 오프셋으로 되돌린 뒤 스프링으로 0 에 수렴시킨다. 배치는 JS 가 계산하고 CSS 는 모양만 맡는다.
@include stage($stage-ink);
$fg: $subject-cream;
$mute: $tick-mute;
$btn-bg: rgba($subject-cream, .14);
$wall-bg: rgba($subject-cream, .06);
$focus: $subject-pink;
$on: $stage-ink;
.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 { transform-origin: 0 0; }
.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: #17141a;
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: #fff7e6;
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(255, 247, 230, 0.14);
color: #fff7e6;
font-size: 12px;
line-height: 16px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
}
.mr__btn[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.mr__btn:focus-visible {
outline: 3px solid #ff9ebb;
outline-offset: 2px;
}
.mr__live {
margin: 0;
color: #9a93a6;
white-space: nowrap;
}
.mr__live b {
color: #fff7e6;
}
.mr__wall {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 247, 230, 0.06);
}
.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 {
transform-origin: 0 0;
}
.mr__mark {
position: absolute;
left: 0;
top: 0;
height: 4px;
border-radius: 999px;
background: rgba(255, 247, 230, 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");
// カード: 시드 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;
}
// 최단 列 배치: 카드를 순서대로 읽으며 지금 가장 낮은 列 끝에 얹고 그 列 높이에 カード 높이와 간격을 더한다.
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);
// 기법: FLIP. 새 자리를 먼저 계산하고(Last), 옛 자리와의 차이를 오프셋으로 되돌려 놓은(Invert) 뒤, 오프셋이 0 이 되도록 스프링을 푼다(Play).
const STIFFNESS = 0.12;
const DAMPING = 0.75;
const STAGGER = 0.03;
const GAP = 8;
const AREA = 15000;
const SEED = 13;
const COUNT = 8;
const SEQUENCE = [3, 4, 2];
const cards = makeCards(COUNT, SEED, 0.3, 0.66);
const colsEl = q("[data-r=cols]");
let cols = 3;
// 스프링 한 칸: 속도에 -강성 × 오프셋을 더하고 감쇠를 곱한 뒤 오프셋에 속도를 더한다. f 는 60fps 기준 프레임 수.
function kick(o, v, f) {
const nv = (v - STIFFNESS * o * f) * Math.pow(DAMPING, f);
return [o + nv * f, nv];
}
function setCols(n) {
if (n === cols && ready) return;
cols = n;
relayout();
}
function relayout() {
const cw = colW(cols, GAP);
const hs = cards.map(function (c) {
return (c.ratio * AREA) / cw;
});
const at = pack(hs, cols, cw, GAP);
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
if (ready && !reduce) {
// First: 지금 화면에 보이는 자리와 크기에서 새 자리와 크기를 뺀 값이 오프셋이 된다.
c.ox = c.tx + c.ox - at[i].x;
c.oy = c.ty + c.oy - at[i].y;
c.osx = (c.tw * (1 + c.osx)) / cw - 1;
c.osy = (c.th * (1 + c.osy)) / hs[i] - 1;
c.wait = i * STAGGER;
}
c.tx = at[i].x;
c.ty = at[i].y;
c.tw = cw;
c.th = hs[i];
c.w = cw;
c.h = hs[i];
if (!ready || reduce) {
c.ox = 0;
c.oy = 0;
c.osx = 0;
c.osy = 0;
}
}
ready = true;
colsEl.textContent = String(cols);
if (reduce) render();
}
function script(ts) {
setCols(SEQUENCE[Math.floor((ts % 2000) / 667)]);
}
function step(dt, ts) {
const f = dt * 60;
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
if (c.wait > 0) {
c.wait -= dt;
continue;
}
const a = kick(c.ox, c.ovx, f);
const b = kick(c.oy, c.ovy, f);
const d = kick(c.osx, c.ovsx, f);
const e = kick(c.osy, c.ovsy, f);
c.ox = a[0];
c.ovx = a[1];
c.oy = b[0];
c.ovy = b[1];
c.osx = d[0];
c.ovsx = d[1];
c.osy = e[0];
c.ovsy = e[1];
}
}
function render() {
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
put(c, c.tx + c.ox, c.ty + c.oy, 1 + c.osx, 1 + c.osy);
}
}
on(q("[data-r=less]"), "click", function () {
stopDemo();
setCols(clamp(cols - 1, 2, 5));
});
on(q("[data-r=more]"), "click", function () {
stopDemo();
setCols(clamp(cols + 1, 2, 5));
});
relayout();
if (reduce) {
render();
} else {
raf = requestAnimationFrame(frame);
}
})();// FlipReflowSpring - vanilla 판(04-mr-flip-reflow-spring) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./FlipReflowSpring.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 FlipReflowSpring() {
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);
// 기법: FLIP. 새 자리를 먼저 계산하고(Last), 옛 자리와의 차이를 오프셋으로 되돌려 놓은(Invert) 뒤, 오프셋이 0 이 되도록 스프링을 푼다(Play).
const STIFFNESS = 0.12;
const DAMPING = 0.75;
const STAGGER = 0.03;
const GAP = 8;
const AREA = 15000;
const SEED = 13;
const COUNT = 8;
const SEQUENCE = [3, 4, 2];
const cards = makeCards(COUNT, SEED, 0.3, 0.66);
const colsEl = q("[data-r=cols]");
let cols: number = 3;
// 스프링 한 칸: 속도에 -강성 × 오프셋을 더하고 감쇠를 곱한 뒤 오프셋에 속도를 더한다. f 는 60fps 기준 프레임 수.
function kick(o: number, v: number, f: number): number[] {
const nv = (v - STIFFNESS * o * f) * Math.pow(DAMPING, f);
return [o + nv * f, nv];
}
function setCols(n: number): void {
if (n === cols && ready) return;
cols = n;
relayout();
}
function relayout(): void {
const cw = colW(cols, GAP);
const hs = cards.map(function (c: Card): number {
return (c.ratio * AREA) / cw;
});
const at = pack(hs, cols, cw, GAP);
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
if (ready && !reduce) {
// First: 지금 화면에 보이는 자리와 크기에서 새 자리와 크기를 뺀 값이 오프셋이 된다.
c.ox = c.tx + c.ox - at[i].x;
c.oy = c.ty + c.oy - at[i].y;
c.osx = (c.tw * (1 + c.osx)) / cw - 1;
c.osy = (c.th * (1 + c.osy)) / hs[i] - 1;
c.wait = i * STAGGER;
}
c.tx = at[i].x;
c.ty = at[i].y;
c.tw = cw;
c.th = hs[i];
c.w = cw;
c.h = hs[i];
if (!ready || reduce) {
c.ox = 0;
c.oy = 0;
c.osx = 0;
c.osy = 0;
}
}
ready = true;
colsEl.textContent = String(cols);
if (reduce) render();
}
function script(ts: number): void {
setCols(SEQUENCE[Math.floor((ts % 2000) / 667)]);
}
function step(dt: number, ts: number): void {
const f = dt * 60;
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
if (c.wait > 0) {
c.wait -= dt;
continue;
}
const a = kick(c.ox, c.ovx, f);
const b = kick(c.oy, c.ovy, f);
const d = kick(c.osx, c.ovsx, f);
const e = kick(c.osy, c.ovsy, f);
c.ox = a[0];
c.ovx = a[1];
c.oy = b[0];
c.ovy = b[1];
c.osx = d[0];
c.ovsx = d[1];
c.osy = e[0];
c.ovsy = e[1];
}
}
function render(): void {
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
put(c, c.tx + c.ox, c.ty + c.oy, 1 + c.osx, 1 + c.osy);
}
}
on(q("[data-r=less]"), "click", function (): void {
stopDemo();
setCols(clamp(cols - 1, 2, 5));
});
on(q("[data-r=more]"), "click", function (): void {
stopDemo();
setCols(clamp(cols + 1, 2, 5));
});
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="cols">3</b></p>
</div>
<div className={styles["mr__wall"]} data-r="wall"></div>
</div>
</main>
);
}04 · 列の数が変わるときばねで定位置を探す FLIP 再配置 — 計測 累積面積 48.372% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function kick(o, v, f) {
const nv = (v - STIFFNESS * o * f) * Math.pow(DAMPING, f);
return [o + nv * f, nv];
}
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
if (ready && !reduce) {
// First: 今の画面上の位置と大きさから新しい位置と大きさを引いた値がオフセットになる。
c.ox = c.tx + c.ox - at[i].x;
c.oy = c.ty + c.oy - at[i].y;
c.osx = (c.tw * (1 + c.osx)) / cw - 1;
c.osy = (c.th * (1 + c.osy)) / hs[i] - 1;
c.wait = i * STAGGER;
}
c.tx = at[i].x;
c.ty = at[i].y;
c.tw = cw;
c.th = hs[i];
c.w = cw;
c.h = hs[i];
if (!ready || reduce) {
c.ox = 0;
c.oy = 0;
c.osx = 0;
c.osy = 0;
}
}
05一行の幅にぴったり合わせて高さを揃える均等な行
縦横比を足していき、一行の幅が壁の幅に届いたらそこで行を切り、その行の高さに倍率 s を一度だけ掛けて右端をそろえます。いっぱいに詰まった行は必ず s ≤ 1 で縮むだけなので、写真アルバムや検索結果の一覧に向きます。累積面積は44.8%、動いたフレームは19/23でした。

<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="行を低く">−</button><button class="mr__btn" type="button" data-r="more" aria-label="行を高く">+</button></div>
<p class="mr__live" data-r="live" aria-live="off" aria-atomic="true"><span class="mr__k">行</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");
// カード: 시드 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;
}
// 최단 列 배치: 카드를 순서대로 읽으며 지금 가장 낮은 列 끝에 얹고 그 列 높이에 カード 높이와 간격을 더한다.
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);
// 기법: 목표 높이에서 가로세로 비율을 더해 가다가 行 너비를 넘으면 끊고, 그 줄을 (남는 너비 ÷ 비율 합)배 해서 오른쪽 끝을 맞춘다.
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 · 一行の幅にぴったり合わせて高さを揃える均等な行 — 計測 累積面積 44.764% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
}
06ポインターの近くで隣が道をあける、レンズ状の配置
カードの中心とポインターの距離 d にガウス型の減衰を掛けて目標の倍率と避ける距離を決め、現在の値は指数接近で追いかけます。近いカードは大きくなり遠いカードは少し縮むので、ホバーで見渡す画像ボードに向きます。累積面積は54.4%、動いたフレームは23/23でした。

<main class="stage">
<div class="mr is-demo" id="mr" data-demo="1">
<div class="mr__bar">
<div class="mr__btns"></div>
<p class="mr__live" data-r="live" aria-live="off" aria-atomic="true"><span class="mr__k">近いカード</span> <b data-r="near">-</b></p>
</div>
<div class="mr__wall" data-r="wall"><i class="mr__ring" data-r="ring"></i><i class="mr__cursor" data-r="cursor"></i></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 포인터 거리에 가우시안 감쇠를 곱해 확대량을 정하고 보간으로 따라간다. 배치는 JS 가 계산하고 CSS 는 모양만 맡는다.
@include stage($stage-ink);
$fg: $subject-cream;
$mute: $tick-mute;
$btn-bg: rgba($subject-cream, .14);
$wall-bg: rgba($subject-cream, .06);
$focus: $subject-pink;
$on: $stage-ink;
.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__wall { touch-action: none; }
.mr__card:focus-visible { outline: 3px solid $subject-cream; outline-offset: 2px; }
.mr__ring {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
border-radius: 50%;
border: 1px dashed rgba($subject-cream, .45);
background: radial-gradient(circle, rgba($subject-cream, .1), rgba($subject-cream, 0) 70%);
z-index: 40;
pointer-events: none;
display: none;
}
.mr__cursor {
position: absolute;
left: -($sp-5 * .5);
top: -($sp-5 * .5);
width: $sp-5;
height: $sp-5;
border-radius: 50%;
background: $subject-cream;
box-shadow: 0 0 0 3px rgba($subject-ink, .55), $shadow-press;
opacity: 0;
z-index: 60;
pointer-events: none;
}
.mr[data-demo] .mr__cursor { opacity: 1; }
.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: #17141a;
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: #fff7e6;
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(255, 247, 230, 0.14);
color: #fff7e6;
font-size: 12px;
line-height: 16px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
}
.mr__btn[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.mr__btn:focus-visible {
outline: 3px solid #ff9ebb;
outline-offset: 2px;
}
.mr__live {
margin: 0;
color: #9a93a6;
white-space: nowrap;
}
.mr__live b {
color: #fff7e6;
}
.mr__wall {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 247, 230, 0.06);
}
.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__wall {
touch-action: none;
}
.mr__card:focus-visible {
outline: 3px solid #fff7e6;
outline-offset: 2px;
}
.mr__ring {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
border-radius: 50%;
border: 1px dashed rgba(255, 247, 230, 0.45);
background: radial-gradient(circle, rgba(255, 247, 230, 0.1), rgba(255, 247, 230, 0) 70%);
z-index: 40;
pointer-events: none;
display: none;
}
.mr__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;
z-index: 60;
pointer-events: none;
}
.mr[data-demo] .mr__cursor {
opacity: 1;
}
.mr__mark {
position: absolute;
left: 0;
top: 0;
height: 4px;
border-radius: 999px;
background: rgba(255, 247, 230, 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");
// カード: 시드 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;
}
// 최단 列 배치: 카드를 순서대로 읽으며 지금 가장 낮은 列 끝에 얹고 그 列 높이에 カード 높이와 간격을 더한다.
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);
// 기법: 포인터와 カード 중심의 거리 d 에 가우시안 감쇠 exp(-FALLOFF (d / LENS_RADIUS)^2) 를 곱해 확대량을 정하고, 매 프레임 LERP 비율만큼만 따라간다.
const LENS_RADIUS = 140;
const GAIN = 0.55;
const FALLOFF = 3;
const LERP = 0.16;
const PUSH = 18;
const COLUMNS = 4;
const GAP = 8;
const SEED = 9;
const COUNT = 12;
const cards = makeCards(COUNT, SEED, 0.3, 0.7);
const cursor = q("[data-r=cursor]");
const ring = q("[data-r=ring]");
const nearEl = q("[data-r=near]");
let px = 0;
let py = 0;
let has = false;
let nearIdx = -1;
ring.style.width = LENS_RADIUS * 2 + "px";
ring.style.height = LENS_RADIUS * 2 + "px";
for (let i = 0; i < cards.length; i += 1) {
const el = cards[i].el;
el.tabIndex = 0;
on(el, "focus", function () {
stopDemo();
const c = cards[i];
px = c.tx + c.w / 2;
py = c.ty + c.h / 2;
has = true;
sync();
});
on(el, "blur", function () {
has = false;
sync();
});
}
function relayout() {
const cw = colW(COLUMNS, GAP);
const hs = cards.map(function (c) {
return c.ratio * cw;
});
const at = pack(hs, COLUMNS, cw, GAP);
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
c.w = cw;
c.h = hs[i];
c.tx = at[i].x;
c.ty = at[i].y;
}
ready = true;
sync();
}
function aim(f) {
let best = -1;
let bestD = 1e9;
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
let ds = 1;
let ux = 0;
let uy = 0;
let lift = 0;
if (has) {
const dx = c.tx + c.w / 2 - px;
const dy = c.ty + c.h / 2 - py;
const d = Math.sqrt(dx * dx + dy * dy);
const g = Math.exp(-FALLOFF * (d / LENS_RADIUS) * (d / LENS_RADIUS));
ds = 1 + GAIN * g - 0.06 * (1 - g);
if (d > 1) {
ux = (dx / d) * PUSH * 4 * g * (1 - g);
uy = (dy / d) * PUSH * 4 * g * (1 - g);
}
lift = g;
if (d < bestD) {
bestD = d;
best = i;
}
}
const k = reduce ? 1 : 1 - Math.pow(1 - LERP, f);
c.s += (ds - c.s) * k;
c.ox += (ux - c.ox) * k;
c.oy += (uy - c.oy) * k;
c.fo = lift;
}
if (best !== nearIdx) {
nearIdx = best;
nearEl.textContent = best < 0 ? "-" : String(best + 1);
}
}
function sync() {
if (reduce) {
aim(1);
render();
}
}
function script(ts) {
const a = (ts % 2000) / 2000 * Math.PI * 2;
px = wall.clientWidth * (0.5 + 0.36 * Math.sin(a));
py = wall.clientHeight * (0.5 + 0.3 * Math.sin(2 * a + 1));
has = true;
}
function step(dt, ts) {
aim(dt * 60);
}
function render() {
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
// 확대된 카드가 벽 밖으로 잘리지 않게 중심을 벽 안으로 붙잡는다.
const gx = Math.max(0, (c.w * (c.s - 1)) / 2);
const gy = Math.max(0, (c.h * (c.s - 1)) / 2);
put(c, clamp(c.tx + c.ox, gx, wall.clientWidth - c.w - gx), clamp(c.ty + c.oy, gy, wall.clientHeight - c.h - gy), c.s, c.s);
c.el.style.zIndex = String(1 + Math.round((c.s - 1) * 20));
c.el.style.setProperty("--lift", c.fo.toFixed(2));
}
cursor.style.display = has ? "" : "none";
ring.style.display = has ? "block" : "none";
cursor.style.transform = "translate3d(" + px.toFixed(1) + "px," + py.toFixed(1) + "px,0)";
ring.style.transform = "translate3d(" + (px - LENS_RADIUS).toFixed(1) + "px," + (py - LENS_RADIUS).toFixed(1) + "px,0)";
}
function aimAt(e) {
const r = wall.getBoundingClientRect();
px = e.clientX - r.left;
py = e.clientY - r.top;
has = true;
sync();
}
on(wall, "pointermove", function (e) {
stopDemo();
aimAt(e);
});
on(wall, "pointerdown", function (e) {
stopDemo();
wall.setPointerCapture(e.pointerId);
aimAt(e);
});
on(wall, "pointerup", function (e) {
if (e.pointerType !== "mouse") {
has = false;
sync();
}
});
on(wall, "pointerleave", function () {
if (!demo) {
has = false;
sync();
}
});
relayout();
if (reduce) {
render();
} else {
raf = requestAnimationFrame(frame);
}
})();// PointerLensField - vanilla 판(06-mr-pointer-lens-field) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./PointerLensField.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 PointerLensField() {
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);
// 기법: 포인터와 카드 중심의 거리 d 에 가우시안 감쇠 exp(-FALLOFF (d / LENS_RADIUS)^2) 를 곱해 확대량을 정하고, 매 프레임 LERP 비율만큼만 따라간다.
const LENS_RADIUS = 140;
const GAIN = 0.55;
const FALLOFF = 3;
const LERP = 0.16;
const PUSH = 18;
const COLUMNS = 4;
const GAP = 8;
const SEED = 9;
const COUNT = 12;
const cards = makeCards(COUNT, SEED, 0.3, 0.7);
const cursor = q("[data-r=cursor]");
const ring = q("[data-r=ring]");
const nearEl = q("[data-r=near]");
let px = 0;
let py = 0;
let has = false;
let nearIdx = -1;
ring.style.width = LENS_RADIUS * 2 + "px";
ring.style.height = LENS_RADIUS * 2 + "px";
for (let i = 0; i < cards.length; i += 1) {
const el = cards[i].el;
el.tabIndex = 0;
on(el, "focus", function (): void {
stopDemo();
const c = cards[i];
px = c.tx + c.w / 2;
py = c.ty + c.h / 2;
has = true;
sync();
});
on(el, "blur", function (): void {
has = false;
sync();
});
}
function relayout(): void {
const cw = colW(COLUMNS, GAP);
const hs = cards.map(function (c: Card): number {
return c.ratio * cw;
});
const at = pack(hs, COLUMNS, cw, GAP);
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
c.w = cw;
c.h = hs[i];
c.tx = at[i].x;
c.ty = at[i].y;
}
ready = true;
sync();
}
function aim(f: number): void {
let best = -1;
let bestD = 1e9;
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
let ds = 1;
let ux = 0;
let uy = 0;
let lift = 0;
if (has) {
const dx = c.tx + c.w / 2 - px;
const dy = c.ty + c.h / 2 - py;
const d = Math.sqrt(dx * dx + dy * dy);
const g = Math.exp(-FALLOFF * (d / LENS_RADIUS) * (d / LENS_RADIUS));
ds = 1 + GAIN * g - 0.06 * (1 - g);
if (d > 1) {
ux = (dx / d) * PUSH * 4 * g * (1 - g);
uy = (dy / d) * PUSH * 4 * g * (1 - g);
}
lift = g;
if (d < bestD) {
bestD = d;
best = i;
}
}
const k = reduce ? 1 : 1 - Math.pow(1 - LERP, f);
c.s += (ds - c.s) * k;
c.ox += (ux - c.ox) * k;
c.oy += (uy - c.oy) * k;
c.fo = lift;
}
if (best !== nearIdx) {
nearIdx = best;
nearEl.textContent = best < 0 ? "-" : String(best + 1);
}
}
function sync(): void {
if (reduce) {
aim(1);
render();
}
}
function script(ts: number): void {
const a = (ts % 2000) / 2000 * Math.PI * 2;
px = wall.clientWidth * (0.5 + 0.36 * Math.sin(a));
py = wall.clientHeight * (0.5 + 0.3 * Math.sin(2 * a + 1));
has = true;
}
function step(dt: number, ts: number): void {
aim(dt * 60);
}
function render(): void {
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
// 확대된 카드가 벽 밖으로 잘리지 않게 중심을 벽 안으로 붙잡는다.
const gx = Math.max(0, (c.w * (c.s - 1)) / 2);
const gy = Math.max(0, (c.h * (c.s - 1)) / 2);
put(c, clamp(c.tx + c.ox, gx, wall.clientWidth - c.w - gx), clamp(c.ty + c.oy, gy, wall.clientHeight - c.h - gy), c.s, c.s);
c.el.style.zIndex = String(1 + Math.round((c.s - 1) * 20));
c.el.style.setProperty("--lift", c.fo.toFixed(2));
}
cursor.style.display = has ? "" : "none";
ring.style.display = has ? "block" : "none";
cursor.style.transform = "translate3d(" + px.toFixed(1) + "px," + py.toFixed(1) + "px,0)";
ring.style.transform = "translate3d(" + (px - LENS_RADIUS).toFixed(1) + "px," + (py - LENS_RADIUS).toFixed(1) + "px,0)";
}
function aimAt(e: PointerEvent): void {
const r = wall.getBoundingClientRect();
px = e.clientX - r.left;
py = e.clientY - r.top;
has = true;
sync();
}
on(wall, "pointermove", function (e: any): void {
stopDemo();
aimAt(e);
});
on(wall, "pointerdown", function (e: any): void {
stopDemo();
wall.setPointerCapture(e.pointerId);
aimAt(e);
});
on(wall, "pointerup", function (e: any): void {
if (e.pointerType !== "mouse") {
has = false;
sync();
}
});
on(wall, "pointerleave", function (): void {
if (!demo) {
has = false;
sync();
}
});
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"]}></div>
<p className={styles["mr__live"]} data-r="live" aria-live="off" aria-atomic="true"><span className={styles["mr__k"]}>가까운 카드</span> <b data-r="near">-</b></p>
</div>
<div className={styles["mr__wall"]} data-r="wall"><i className={styles["mr__ring"]} data-r="ring"></i><i className={styles["mr__cursor"]} data-r="cursor"></i></div>
</div>
</main>
);
}06 · ポインターの近くで隣が道をあける、レンズ状の配置 — 計測 累積面積 54.377% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
if (has) {
const dx = c.tx + c.w / 2 - px;
const dy = c.ty + c.h / 2 - py;
const d = Math.sqrt(dx * dx + dy * dy);
const g = Math.exp(-FALLOFF * (d / LENS_RADIUS) * (d / LENS_RADIUS));
ds = 1 + GAIN * g - 0.06 * (1 - g);
if (d > 1) {
ux = (dx / d) * PUSH * 4 * g * (1 - g);
uy = (dy / d) * PUSH * 4 * g * (1 - g);
}
lift = g;
if (d < bestD) {
bestD = d;
best = i;
}
}
const k = reduce ? 1 : 1 - Math.pow(1 - LERP, f);
c.s += (ds - c.s) * k;
c.ox += (ux - c.ox) * k;
c.oy += (uy - c.oy) * k;
07列ごとに逆向きへ流れる無限のレンガ
列ごとに向きを交互にして SPEED × DEPTH[k] で速さを変え、位置を直接足したあと、列の高さで割った余りに巻き戻して継ぎ目を隠します。今の幅では列ごとにカードを2組つなぐので、DOM のカードは20枚から40枚に増え、ランディングの背景やロゴの壁に向きます。累積面積は62.3%で九つの中で最も広く、動いたフレームは23/23でした。

<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="slow" aria-label="遅く">−</button><button class="mr__btn" type="button" data-r="fast" aria-label="速く">+</button><button class="mr__btn" type="button" data-r="pause" aria-pressed="false">停止</button></div>
<p class="mr__live" data-r="live" aria-live="off" aria-atomic="true"><span class="mr__k">速さ</span> <b data-r="speed">1.0</b>×</p>
</div>
<div class="mr__wall" data-r="wall"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 열마다 깊이 비율 속도로 번갈아 흐르고 한 열 높이만큼 흐르면 되감는다. 배치는 JS 가 계산하고 CSS 는 모양만 맡는다.
@include stage($stage-ink);
$fg: $subject-cream;
$mute: $tick-mute;
$btn-bg: rgba($subject-cream, .14);
$wall-bg: rgba($subject-cream, .06);
$focus: $subject-pink;
$on: $stage-ink;
.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__col {
position: absolute;
top: 0;
will-change: transform;
}
.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: #17141a;
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: #fff7e6;
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(255, 247, 230, 0.14);
color: #fff7e6;
font-size: 12px;
line-height: 16px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
}
.mr__btn[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.mr__btn:focus-visible {
outline: 3px solid #ff9ebb;
outline-offset: 2px;
}
.mr__live {
margin: 0;
color: #9a93a6;
white-space: nowrap;
}
.mr__live b {
color: #fff7e6;
}
.mr__wall {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 247, 230, 0.06);
}
.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__col {
position: absolute;
top: 0;
will-change: transform;
}
.mr__mark {
position: absolute;
left: 0;
top: 0;
height: 4px;
border-radius: 999px;
background: rgba(255, 247, 230, 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", col: "mr__col" };
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");
// カード: 시드 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;
}
// 최단 列 배치: 카드를 순서대로 읽으며 지금 가장 낮은 列 끝에 얹고 그 列 높이에 カード 높이와 간격을 더한다.
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);
// 기법: 열마다 속도에 깊이 비율을 곱하고 방향을 번갈아 정한다. 列 내용을 두 번 이상 이어 붙이고 위치를 한 列 높이로 나눈 나머지로 되감아 이음새를 숨긴다.
const COLUMNS = 4;
const GAP = 8;
const SPEED = 64;
const DEPTH = [1, 0.62, 1.3, 0.8];
const PER = 5;
const SEED = 3;
const rng = makeRng(SEED);
const ratios = [];
const pos = [0, 0, 0, 0];
const spans = [1, 1, 1, 1];
const cols = [];
const speedEl = q("[data-r=speed]");
const pauseBtn = q("[data-r=pause]");
let mult = 1;
let paused = false;
for (let i = 0; i < COLUMNS * PER; i += 1) ratios.push(0.4 + rng() * 0.6);
function build() {
while (wall.firstChild) wall.removeChild(wall.firstChild);
cols.length = 0;
const cw = colW(COLUMNS, GAP);
for (let k = 0; k < COLUMNS; k += 1) {
const col = document.createElement("div");
col.className = CLS.col;
col.style.left = (k * (cw + GAP)).toFixed(1) + "px";
col.style.width = cw.toFixed(1) + "px";
let total = 0;
for (let i = 0; i < PER; i += 1) total += ratios[k * PER + i] * cw + GAP;
const copies = Math.ceil((total + wall.clientHeight) / total);
for (let n = 0; n < copies; n += 1) {
let y = n * total;
for (let i = 0; i < PER; i += 1) {
const h = ratios[k * PER + i] * cw;
const el = document.createElement("div");
el.className = CLS.card;
el.setAttribute("data-c", String((k * PER + i) % 6));
el.innerHTML = "<i>" + (k * PER + i + 1) + "</i>";
if (n > 0) el.setAttribute("aria-hidden", "true");
el.style.width = cw.toFixed(1) + "px";
el.style.height = h.toFixed(1) + "px";
el.style.transform = "translate3d(0," + y.toFixed(1) + "px,0)";
col.appendChild(el);
y += h + GAP;
}
}
spans[k] = total;
cols.push(col);
wall.appendChild(col);
}
}
function relayout() {
build();
for (let k = 0; k < COLUMNS; k += 1) pos[k] = pos[k] % spans[k];
ready = true;
speedEl.textContent = mult.toFixed(1);
render();
}
function script(ts) {
// 시연은 기본 속도로 계속 흐른다.
}
function step(dt, ts) {
if (paused) return;
for (let k = 0; k < COLUMNS; k += 1) {
const dir = k % 2 === 0 ? 1 : -1;
const next = pos[k] + dir * SPEED * DEPTH[k] * mult * dt;
pos[k] = ((next % spans[k]) + spans[k]) % spans[k];
}
}
function render() {
for (let k = 0; k < cols.length; k += 1) {
cols[k].style.transform = "translate3d(0," + (-pos[k]).toFixed(1) + "px,0)";
}
}
function bump() {
// 움직임 줄이기에서는 흐르지 않으므로 누를 때마다 한 칸 밀어서 바뀐 결과를 바로 보여 준다.
if (!reduce) return;
for (let k = 0; k < COLUMNS; k += 1) {
pos[k] = (pos[k] + (k % 2 === 0 ? 1 : -1) * 48 * DEPTH[k] + spans[k]) % spans[k];
}
render();
}
function speedBy(d) {
stopDemo();
mult = clamp(mult + d, 0.5, 3);
speedEl.textContent = mult.toFixed(1);
bump();
}
on(q("[data-r=slow]"), "click", function () {
speedBy(-0.5);
});
on(q("[data-r=fast]"), "click", function () {
speedBy(0.5);
});
on(pauseBtn, "click", function () {
stopDemo();
paused = !paused;
pauseBtn.setAttribute("aria-pressed", paused ? "true" : "false");
});
pauseBtn.hidden = reduce;
relayout();
if (reduce) {
render();
} else {
raf = requestAnimationFrame(frame);
}
})();// ColumnDriftLoop - vanilla 판(07-mr-column-drift-loop) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./ColumnDriftLoop.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 ColumnDriftLoop() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const CLS = { card: styles["mr__card"], col: styles["mr__col"] };
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 = 4;
const GAP = 8;
const SPEED = 64;
const DEPTH = [1, 0.62, 1.3, 0.8];
const PER = 5;
const SEED = 3;
const rng = makeRng(SEED);
const ratios: number[] = [];
const pos: number[] = [0, 0, 0, 0];
const spans: number[] = [1, 1, 1, 1];
const cols: HTMLElement[] = [];
const speedEl = q("[data-r=speed]");
const pauseBtn = q("[data-r=pause]");
let mult = 1;
let paused = false;
for (let i = 0; i < COLUMNS * PER; i += 1) ratios.push(0.4 + rng() * 0.6);
function build(): void {
while (wall.firstChild) wall.removeChild(wall.firstChild);
cols.length = 0;
const cw = colW(COLUMNS, GAP);
for (let k = 0; k < COLUMNS; k += 1) {
const col = document.createElement("div");
col.className = CLS.col;
col.style.left = (k * (cw + GAP)).toFixed(1) + "px";
col.style.width = cw.toFixed(1) + "px";
let total = 0;
for (let i = 0; i < PER; i += 1) total += ratios[k * PER + i] * cw + GAP;
const copies = Math.ceil((total + wall.clientHeight) / total);
for (let n = 0; n < copies; n += 1) {
let y = n * total;
for (let i = 0; i < PER; i += 1) {
const h = ratios[k * PER + i] * cw;
const el = document.createElement("div");
el.className = CLS.card;
el.setAttribute("data-c", String((k * PER + i) % 6));
el.innerHTML = "<i>" + (k * PER + i + 1) + "</i>";
if (n > 0) el.setAttribute("aria-hidden", "true");
el.style.width = cw.toFixed(1) + "px";
el.style.height = h.toFixed(1) + "px";
el.style.transform = "translate3d(0," + y.toFixed(1) + "px,0)";
col.appendChild(el);
y += h + GAP;
}
}
spans[k] = total;
cols.push(col);
wall.appendChild(col);
}
}
function relayout(): void {
build();
for (let k = 0; k < COLUMNS; k += 1) pos[k] = pos[k] % spans[k];
ready = true;
speedEl.textContent = mult.toFixed(1);
render();
}
function script(ts: number): void {
// 시연은 기본 속도로 계속 흐른다.
}
function step(dt: number, ts: number): void {
if (paused) return;
for (let k = 0; k < COLUMNS; k += 1) {
const dir = k % 2 === 0 ? 1 : -1;
const next = pos[k] + dir * SPEED * DEPTH[k] * mult * dt;
pos[k] = ((next % spans[k]) + spans[k]) % spans[k];
}
}
function render(): void {
for (let k = 0; k < cols.length; k += 1) {
cols[k].style.transform = "translate3d(0," + (-pos[k]).toFixed(1) + "px,0)";
}
}
function bump(): void {
// 움직임 줄이기에서는 흐르지 않으므로 누를 때마다 한 칸 밀어서 바뀐 결과를 바로 보여 준다.
if (!reduce) return;
for (let k = 0; k < COLUMNS; k += 1) {
pos[k] = (pos[k] + (k % 2 === 0 ? 1 : -1) * 48 * DEPTH[k] + spans[k]) % spans[k];
}
render();
}
function speedBy(d: number): void {
stopDemo();
mult = clamp(mult + d, 0.5, 3);
speedEl.textContent = mult.toFixed(1);
bump();
}
on(q("[data-r=slow]"), "click", function (): void {
speedBy(-0.5);
});
on(q("[data-r=fast]"), "click", function (): void {
speedBy(0.5);
});
on(pauseBtn, "click", function (): void {
stopDemo();
paused = !paused;
pauseBtn.setAttribute("aria-pressed", paused ? "true" : "false");
});
pauseBtn.hidden = reduce;
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="slow" aria-label="느리게">−</button><button className={styles["mr__btn"]} type="button" data-r="fast" aria-label="빠르게">+</button><button className={styles["mr__btn"]} type="button" data-r="pause" 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="speed">1.0</b>×</p>
</div>
<div className={styles["mr__wall"]} data-r="wall"></div>
</div>
</main>
);
}07 · 列ごとに逆向きへ流れる無限のレンガ — 計測 累積面積 62.272% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
for (let n = 0; n < copies; n += 1) {
let y = n * total;
for (let i = 0; i < PER; i += 1) {
const h = ratios[k * PER + i] * cw;
const el = document.createElement("div");
el.className = CLS.card;
el.setAttribute("data-c", String((k * PER + i) % 6));
el.innerHTML = "<i>" + (k * PER + i + 1) + "</i>";
if (n > 0) el.setAttribute("aria-hidden", "true");
el.style.width = cw.toFixed(1) + "px";
el.style.height = h.toFixed(1) + "px";
el.style.transform = "translate3d(0," + y.toFixed(1) + "px,0)";
col.appendChild(el);
y += h + GAP;
}
}
08フィルターを押すと順番に位置が移るシャッフル
絞り込まれたカードだけをもう一度最短列の配置に載せて目標位置を求め、カードごとの出発を順番に STAGGER を掛けた分だけ遅らせて、時間のイージングで動かします。外れたカードはその場で倍率 0.5・透明度 0 に畳まれ、カテゴリーで絞り込むギャラリーに向きます。累積面積は50.9%、動いたフレームは22/23でした。

<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="f0" aria-pressed="true">すべて</button><button class="mr__btn" type="button" data-r="f1" aria-pressed="false">ピンク</button><button class="mr__btn" type="button" data-r="f2" aria-pressed="false">ミント</button><button class="mr__btn" type="button" data-r="f3" aria-pressed="false">空色</button></div>
<p class="mr__live" data-r="live" aria-live="off" aria-atomic="true"><span class="mr__k">カード</span> <b data-r="count">12</b></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[data-c="0"] { background: linear-gradient(150deg, $subject-pink, mix($subject-pink, $stage-paper, 62%)); }
.mr__card[data-c="1"] { background: linear-gradient(150deg, $subject-mint, mix($subject-mint, $stage-paper, 62%)); }
.mr__card[data-c="2"] { background: linear-gradient(150deg, $subject-sky, mix($subject-sky, $stage-paper, 62%)); }
.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[data-c="0"] {
background: linear-gradient(150deg, #ff9ebb, rgb(100%, 76.4156862745%, 83.4666666667%));
}
.mr__card[data-c="1"] {
background: linear-gradient(150deg, #4cd4a6, rgb(56.4784313725%, 89.5450980392%, 78.3607843137%));
}
.mr__card[data-c="2"] {
background: linear-gradient(150deg, #6ec8ff, rgb(64.7450980392%, 86.6274509804%, 100%));
}
.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");
// カード: 시드 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;
}
// 최단 列 배치: 카드를 순서대로 읽으며 지금 가장 낮은 列 끝에 얹고 그 列 높이에 カード 높이와 간격을 더한다.
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);
// 기법: 걸러진 카드만 다시 최단 列 배치로 새 자리를 구하고, 카드마다 출발을 순번 × STAGGER 만큼 늦춰 같은 이징으로 옮긴다.
const STAGGER = 36;
const DURATION = 440;
const EASE_POWER = 3;
const COLUMNS = 4;
const GAP = 8;
const SEED = 25;
const COUNT = 12;
const cards = makeCards(COUNT, SEED, 0.3, 0.66);
const buttons = [q("[data-r=f0]"), q("[data-r=f1]"), q("[data-r=f2]"), q("[data-r=f3]")];
const countEl = q("[data-r=count]");
let filter = 0;
function easeOut(p) {
return 1 - Math.pow(1 - p, EASE_POWER);
}
function visible(c) {
return filter === 0 || c.cat === filter - 1;
}
for (let i = 0; i < cards.length; i += 1) cards[i].el.setAttribute("data-c", String(cards[i].cat));
function aim(now, fresh) {
const cw = colW(COLUMNS, GAP);
const list = cards.filter(visible);
const at = pack(list.map(function (c) {
return c.ratio * cw;
}), COLUMNS, cw, GAP);
let rank = 0;
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
c.fx = c.x;
c.fy = c.y;
c.fs = c.s;
c.fo = c.o;
c.w = cw;
c.h = c.ratio * cw;
if (visible(c)) {
c.tx = at[rank].x;
c.ty = at[rank].y;
c.ds = 1;
c.dop = 1;
c.t0 = now + rank * STAGGER;
rank += 1;
} else {
c.ds = 0.5;
c.dop = 0;
c.t0 = now;
}
if (fresh) {
c.x = c.tx;
c.y = c.ty;
c.s = c.ds;
c.o = c.dop;
c.t0 = -1e9;
}
}
countEl.textContent = String(list.length);
}
function setFilter(f, now) {
if (f === filter && ready) return;
filter = f;
for (let i = 0; i < 4; i += 1) buttons[i].setAttribute("aria-pressed", i === f ? "true" : "false");
aim(now, reduce);
if (reduce) render();
}
function relayout() {
aim(performance.now(), !ready || reduce);
ready = true;
if (reduce) render();
}
function script(ts) {
const ph = ts % 2000;
setFilter(ph < 300 ? 0 : ph < 800 ? 1 : ph < 1300 ? 2 : ph < 1800 ? 3 : 0, ts);
}
function step(dt, ts) {
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
const e = easeOut(clamp((ts - c.t0) / DURATION, 0, 1));
c.x = c.fx + (c.tx - c.fx) * e;
c.y = c.fy + (c.ty - c.fy) * e;
c.s = c.fs + (c.ds - c.fs) * e;
c.o = c.fo + (c.dop - c.fo) * e;
}
}
function render() {
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
c.el.style.opacity = c.o.toFixed(2);
put(c, c.x, c.y, c.s, c.s);
}
}
for (let i = 0; i < 4; i += 1) {
on(buttons[i], "click", function () {
stopDemo();
setFilter(i, performance.now());
});
}
buttons[0].setAttribute("aria-pressed", "true");
relayout();
if (reduce) {
render();
} else {
raf = requestAnimationFrame(frame);
}
})();// FilterShuffleReflow - vanilla 판(08-mr-filter-shuffle-reflow) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./FilterShuffleReflow.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 FilterShuffleReflow() {
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);
// 기법: 걸러진 카드만 다시 최단 열 배치로 새 자리를 구하고, 카드마다 출발을 순번 × STAGGER 만큼 늦춰 같은 이징으로 옮긴다.
const STAGGER = 36;
const DURATION = 440;
const EASE_POWER = 3;
const COLUMNS = 4;
const GAP = 8;
const SEED = 25;
const COUNT = 12;
const cards = makeCards(COUNT, SEED, 0.3, 0.66);
const buttons = [q("[data-r=f0]"), q("[data-r=f1]"), q("[data-r=f2]"), q("[data-r=f3]")];
const countEl = q("[data-r=count]");
let filter = 0;
function easeOut(p: number): number {
return 1 - Math.pow(1 - p, EASE_POWER);
}
function visible(c: Card): boolean {
return filter === 0 || c.cat === filter - 1;
}
for (let i = 0; i < cards.length; i += 1) cards[i].el.setAttribute("data-c", String(cards[i].cat));
function aim(now: number, fresh: boolean): void {
const cw = colW(COLUMNS, GAP);
const list = cards.filter(visible);
const at = pack(list.map(function (c: Card): number {
return c.ratio * cw;
}), COLUMNS, cw, GAP);
let rank = 0;
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
c.fx = c.x;
c.fy = c.y;
c.fs = c.s;
c.fo = c.o;
c.w = cw;
c.h = c.ratio * cw;
if (visible(c)) {
c.tx = at[rank].x;
c.ty = at[rank].y;
c.ds = 1;
c.dop = 1;
c.t0 = now + rank * STAGGER;
rank += 1;
} else {
c.ds = 0.5;
c.dop = 0;
c.t0 = now;
}
if (fresh) {
c.x = c.tx;
c.y = c.ty;
c.s = c.ds;
c.o = c.dop;
c.t0 = -1e9;
}
}
countEl.textContent = String(list.length);
}
function setFilter(f: number, now: number): void {
if (f === filter && ready) return;
filter = f;
for (let i = 0; i < 4; i += 1) buttons[i].setAttribute("aria-pressed", i === f ? "true" : "false");
aim(now, reduce);
if (reduce) render();
}
function relayout(): void {
aim(performance.now(), !ready || reduce);
ready = true;
if (reduce) render();
}
function script(ts: number): void {
const ph = ts % 2000;
setFilter(ph < 300 ? 0 : ph < 800 ? 1 : ph < 1300 ? 2 : ph < 1800 ? 3 : 0, ts);
}
function step(dt: number, ts: number): void {
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
const e = easeOut(clamp((ts - c.t0) / DURATION, 0, 1));
c.x = c.fx + (c.tx - c.fx) * e;
c.y = c.fy + (c.ty - c.fy) * e;
c.s = c.fs + (c.ds - c.fs) * e;
c.o = c.fo + (c.dop - c.fo) * e;
}
}
function render(): void {
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
c.el.style.opacity = c.o.toFixed(2);
put(c, c.x, c.y, c.s, c.s);
}
}
for (let i = 0; i < 4; i += 1) {
on(buttons[i], "click", function (): void {
stopDemo();
setFilter(i, performance.now());
});
}
buttons[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="f0" aria-pressed="true">전체</button><button className={styles["mr__btn"]} type="button" data-r="f1" aria-pressed="false">분홍</button><button className={styles["mr__btn"]} type="button" data-r="f2" aria-pressed="false">민트</button><button className={styles["mr__btn"]} type="button" data-r="f3" 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="count">12</b></p>
</div>
<div className={styles["mr__wall"]} data-r="wall"></div>
</div>
</main>
);
}08 · フィルターを押すと順番に位置が移るシャッフル — 計測 累積面積 50.885% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
if (visible(c)) {
c.tx = at[rank].x;
c.ty = at[rank].y;
c.ds = 1;
c.dop = 1;
c.t0 = now + rank * STAGGER;
rank += 1;
} else {
c.ds = 0.5;
c.dop = 0;
c.t0 = now;
}
09上から落ちて弾み、積み上がるレンガ
カードごとに DROP_DELAY だけ遅れて出発し、重力で落ちて目標の高さに触れると速度の0.32倍で弾み、縦に潰れてから伸びて止まります。最初の着地は233msから1517msの間に散らばり、全員が止まるまで1850msかかるので、読み込み直後の登場演出に向きます。累積面積は46.3%、動いたフレームは22/23でした。

<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="again">混ぜて再び</button></div>
<p class="mr__live" data-r="live" aria-live="off" aria-atomic="true"><span class="mr__k">積んだ数</span> <b data-r="rest">0</b></p>
</div>
<div class="mr__wall" data-r="wall"></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 중력으로 떨어져 반발 계수로 튕기고 닿는 속도만큼 눌렸다 펴지며 쌓인다. 배치는 JS 가 계산하고 CSS 는 모양만 맡는다.
@include stage($stage-ink);
$fg: $subject-cream;
$mute: $tick-mute;
$btn-bg: rgba($subject-cream, .14);
$wall-bg: rgba($subject-cream, .06);
$focus: $subject-pink;
$on: $stage-ink;
.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 { transform-origin: 50% 100%; }
.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: #17141a;
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: #fff7e6;
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(255, 247, 230, 0.14);
color: #fff7e6;
font-size: 12px;
line-height: 16px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
}
.mr__btn[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.mr__btn:focus-visible {
outline: 3px solid #ff9ebb;
outline-offset: 2px;
}
.mr__live {
margin: 0;
color: #9a93a6;
white-space: nowrap;
}
.mr__live b {
color: #fff7e6;
}
.mr__wall {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 247, 230, 0.06);
}
.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 {
transform-origin: 50% 100%;
}
.mr__mark {
position: absolute;
left: 0;
top: 0;
height: 4px;
border-radius: 999px;
background: rgba(255, 247, 230, 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");
// カード: 시드 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;
}
// 최단 列 배치: 카드를 순서대로 읽으며 지금 가장 낮은 列 끝에 얹고 그 列 높이에 カード 높이와 간격을 더한다.
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);
// 기법: 카드마다 중력 가속으로 떨어지다가 목표 높이에 닿으면 속도를 -BOUNCE 배로 뒤집고, 닿는 속도에 비례해 세로로 눌렸다가 펴진다.
const GRAVITY = 2600;
const BOUNCE = 0.32;
const SQUASH = 0.8;
const DROP_DELAY = 140;
const COLUMNS = 3;
const GAP = 8;
const COUNT = 9;
const cards = makeCards(COUNT, 21, 0.22, 0.48);
const restEl = q("[data-r=rest]");
let seed = 21;
let startAt = -1;
let restNow = -1;
function reset(now) {
startAt = now;
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
c.y = -c.h - 24;
c.x = c.tx;
c.vy = 0;
c.sq = 0;
c.rest = 0;
}
showRest();
}
function showRest() {
let n = 0;
for (let i = 0; i < cards.length; i += 1) n += cards[i].rest;
const shown = n === cards.length ? n : 0;
if (shown !== restNow) {
restNow = shown;
restEl.textContent = String(shown);
}
}
function reroll() {
seed += 1;
const rng = makeRng(seed);
for (let i = 0; i < cards.length; i += 1) cards[i].ratio = 0.22 + rng() * 0.26;
relayout();
}
function relayout() {
const cw = colW(COLUMNS, GAP);
const hs = cards.map(function (c) {
return c.ratio * cw;
});
const at = pack(hs, COLUMNS, cw, GAP);
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
c.w = cw;
c.h = hs[i];
c.tx = at[i].x;
c.ty = at[i].y;
c.x = c.tx;
if (c.rest === 1 || reduce) {
c.y = c.ty;
c.rest = 1;
}
}
if (!ready) {
if (reduce) {
for (let i = 0; i < cards.length; i += 1) cards[i].rest = 1;
} else {
reset(performance.now());
}
}
ready = true;
showRest();
if (reduce) render();
}
function script(ts) {
const base = Math.floor(ts / 2000) * 2000 + 100;
if (base !== startAt) reset(base);
}
function step(dt, ts) {
const n = Math.max(1, Math.ceil(dt / (1 / 120)));
const h = dt / n;
for (let s = 0; s < n; s += 1) {
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
c.sq *= Math.exp(-14 * h);
if (c.rest === 1 || ts - startAt < i * DROP_DELAY) continue;
c.vy += GRAVITY * h;
c.y += c.vy * h;
if (c.y >= c.ty) {
const hit = c.vy;
c.y = c.ty;
if (hit > 90) {
c.vy = -hit * BOUNCE;
c.sq = Math.max(c.sq, Math.min(0.3, (SQUASH * hit) / 4200));
} else {
c.vy = 0;
c.rest = 1;
}
}
}
}
showRest();
}
function render() {
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
put(c, c.x, c.y, 1 + c.sq * 0.45, 1 - c.sq);
}
}
on(q("[data-r=again]"), "click", function () {
stopDemo();
reroll();
if (!reduce) reset(performance.now());
});
relayout();
if (reduce) {
render();
} else {
raf = requestAnimationFrame(frame);
}
})();// GravityDropStack - vanilla 판(09-mr-gravity-drop-stack) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./GravityDropStack.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 GravityDropStack() {
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);
// 기법: 카드마다 중력 가속으로 떨어지다가 목표 높이에 닿으면 속도를 -BOUNCE 배로 뒤집고, 닿는 속도에 비례해 세로로 눌렸다가 펴진다.
const GRAVITY = 2600;
const BOUNCE = 0.32;
const SQUASH = 0.8;
const DROP_DELAY = 140;
const COLUMNS = 3;
const GAP = 8;
const COUNT = 9;
const cards = makeCards(COUNT, 21, 0.22, 0.48);
const restEl = q("[data-r=rest]");
let seed = 21;
let startAt = -1;
let restNow = -1;
function reset(now: number): void {
startAt = now;
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
c.y = -c.h - 24;
c.x = c.tx;
c.vy = 0;
c.sq = 0;
c.rest = 0;
}
showRest();
}
function showRest(): void {
let n = 0;
for (let i = 0; i < cards.length; i += 1) n += cards[i].rest;
const shown = n === cards.length ? n : 0;
if (shown !== restNow) {
restNow = shown;
restEl.textContent = String(shown);
}
}
function reroll(): void {
seed += 1;
const rng = makeRng(seed);
for (let i = 0; i < cards.length; i += 1) cards[i].ratio = 0.22 + rng() * 0.26;
relayout();
}
function relayout(): void {
const cw = colW(COLUMNS, GAP);
const hs = cards.map(function (c: Card): number {
return c.ratio * cw;
});
const at = pack(hs, COLUMNS, cw, GAP);
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
c.w = cw;
c.h = hs[i];
c.tx = at[i].x;
c.ty = at[i].y;
c.x = c.tx;
if (c.rest === 1 || reduce) {
c.y = c.ty;
c.rest = 1;
}
}
if (!ready) {
if (reduce) {
for (let i = 0; i < cards.length; i += 1) cards[i].rest = 1;
} else {
reset(performance.now());
}
}
ready = true;
showRest();
if (reduce) render();
}
function script(ts: number): void {
const base = Math.floor(ts / 2000) * 2000 + 100;
if (base !== startAt) reset(base);
}
function step(dt: number, ts: number): void {
const n = Math.max(1, Math.ceil(dt / (1 / 120)));
const h = dt / n;
for (let s = 0; s < n; s += 1) {
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
c.sq *= Math.exp(-14 * h);
if (c.rest === 1 || ts - startAt < i * DROP_DELAY) continue;
c.vy += GRAVITY * h;
c.y += c.vy * h;
if (c.y >= c.ty) {
const hit = c.vy;
c.y = c.ty;
if (hit > 90) {
c.vy = -hit * BOUNCE;
c.sq = Math.max(c.sq, Math.min(0.3, (SQUASH * hit) / 4200));
} else {
c.vy = 0;
c.rest = 1;
}
}
}
}
showRest();
}
function render(): void {
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
put(c, c.x, c.y, 1 + c.sq * 0.45, 1 - c.sq);
}
}
on(q("[data-r=again]"), "click", function (): void {
stopDemo();
reroll();
if (!reduce) reset(performance.now());
});
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="again">섞어서 다시</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="rest">0</b></p>
</div>
<div className={styles["mr__wall"]} data-r="wall"></div>
</div>
</main>
);
}09 · 上から落ちて弾み、積み上がるレンガ — 計測 累積面積 46.294% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
for (let s = 0; s < n; s += 1) {
for (let i = 0; i < cards.length; i += 1) {
const c = cards[i];
c.sq *= Math.exp(-14 * h);
if (c.rest === 1 || ts - startAt < i * DROP_DELAY) continue;
c.vy += GRAVITY * h;
c.y += c.vy * h;
if (c.y >= c.ty) {
const hit = c.vy;
c.y = c.ty;
if (hit > 90) {
c.vy = -hit * BOUNCE;
c.sq = Math.max(c.sq, Math.min(0.3, (SQUASH * hit) / 4200));
} else {
c.vy = 0;
c.rest = 1;
}
}
}
}
値が配置を変えるところ — 列の数・ばねの強さ・レンズの半径
数字で配置が大きく変わる値は三つあります。以下の数字はソースの同じ関数を Node で直接呼んで数えたもので、壁の幅 440px を前提にしているため、ブラウザーで描画して測った値とは別のものとして読んでください。
列を増やすとカードは細くなり下端がそろう
01 の COLUMNS を3から5にすると、カードの幅は 141px から 82px に狭まり、いちばん長い列の下端は 190px から 66px に下がり、列の下端の差も 54px から 36px に縮みます。同じ九枚がより広く散るので、一つの列に積み上がる高さが短くなるわけです。
強さを上げると行き過ぎは倍になり落ち着くのは遅れる
04 の STIFFNESS を0.12から0.3に上げて DAMPING はそのままにすると、100px 動かしたときに目標を行き過ぎる幅が 21px から 41px に広がります。初めて 1px 以内に入る時刻は約0.28秒でほとんど変わらず、そのあと 1px 以内にとどまり続ける時刻は0.45秒から0.55秒に遅れます。
レンズの半径を広げると縮められていたカードが解ける
06 の LENS_RADIUS を140から240に広げてポインターを壁の中央に置くと、1.1倍を超えて大きくなるカードが12枚中5枚から6枚に増え、最大の倍率は1.31から1.45に上がります。0.97倍を下回って縮められるカードは6枚から0枚になり、レンズが広いほど壁全体が一斉にふくらみます。
実際にブラウザーで描画して得た測定は次の表にまとめました。累積面積がいちばん小さいのは 05 の44.8%で、いちばん大きいのは 07 の62.3%です。動いたフレームが23に届かない場面は 02(19)・04(19)・05(19)・08(22)・09(22)の五つで、その理由までは確かめていません。
| 項目 | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|
| 01 最短列の配置 | 47.7% | 102.9 | 23/23 |
| 02 列を選ぶ三つのルール | 53.1% | 58.7 | 19/23 |
| 03 grid のスパン | 60.2% | 123.3 | 23/23 |
| 04 FLIP とばね | 48.4% | 115.0 | 19/23 |
| 05 均等な行 | 44.8% | 65.5 | 19/23 |
| 06 レンズ配置 | 54.4% | 98.4 | 23/23 |
| 07 流れる列 | 62.3% | 159.2 | 23/23 |
| 08 フィルターのシャッフル | 50.9% | 63.6 | 22/23 |
| 09 落下して積む | 46.3% | 126.8 | 22/23 |
どこで壊れるか — カードが数百枚のとき・狭い画面・動きを減らす設定
カードの高さが比率の掛け算なので実際の記事カードにはそのまま使えない
01・02・03・06・07・08・09 の七つの場面は高さを 比率 × 列幅 で決め、04 は幅が狭まると高くなる 比率 × AREA / 列幅 のモデル、05 は行の高さに比率を掛けて幅を決めます。画像や文章の入った実際のカードは中身が高さを決めるので、一度描いてから高さを測り、その値を 01 の pack のような配置の計算に渡す必要があります。
最短の列は読む順を捨てる
01 の pack は高さだけを見て列を選ぶため、番号が列をまたいで飛びます。横に読まれる一覧なら 02 の横の順を使えますが、列の下端の差が 5px から 72px に開く代償を払うことになり、均衡のルールは 30px で中間を取ります。
grid のスパンは行ごとに隙間を残す
03 は span × 16 - 8 がカードの高さを覆うように切り上げるので、カード一枚につき最大 15px の余りが出て、行の単位を大きくするほど増えます。幅が変わるとスパンが計算し直され、カードの高さは補間なしに行の単位一つ分ずつ跳びます。
FLIP の幅の補正が文字を一瞬ゆがめる
04 は列の数を変えるとき幅の差を scale で打ち消してから始めるので、3列から4列では +0.36 だけ伸びた状態で出発します。番号の文字と角はその分ゆがんでから戻り、強さだけを上げると 21px だった行き過ぎが 41px になります。
均等な行の最後の行は右端が合わない
05 では最後の行の伸びる倍率が1.35を超えると伸ばさないので、右端があいたままになります。目標の高さ44では二行目の5枚の右端が237.6pxで終わり、55では7枚ずつの二行がどちらも 440px にぴったり収まります。
レンズはタッチとキーボードで入り方が違う
06 はポインターがマウスでないとき、指を離した pointerup でレンズを消します。キーボードではカード12枚が tabIndex を持つので、Tab で一枚ずつフォーカスを移したときだけそのカードの中心にレンズが置かれ、拡大したカードは壁の内側に押さえられるため、角では押しのけられる向きが塞がれます。
幅が変わるたびに DOM が作り直される
07 は幅が変わると build() でカードをまるごと作り直します。今は40個なので負担はありませんが、カードが数百枚になるとウィンドウの端をドラッグしている間に引っかかるかもしれません。
落下するレンガは互いにぶつからない
09 には同じ列のカード同士の衝突判定がなく、それぞれ自分の目標の高さでだけ止まります。着地の時刻がずれて重ならずに済んでいるだけなので、DROP_DELAY を減らすと上のカードが下のカードの弾みと重なります。
自分で値を変えて境界を確かめる
01 の COLUMNS、04 の STIFFNESS、06 の LENS_RADIUS を一つずつ変えてみると、上の数字がどこで切り替わるかを手で確かめられます。ファイル一式は、続く圧縮ファイルを vbqsakg9 で開くと取り出せて、九つの場面のソースと React 版が一緒に入っています。
変形三つとアクセシビリティ
| 名前 | 値 | 感じ |
|---|---|---|
| 列の数を変える | 01 COLUMNS 3 → 5 | カードの幅が 141px から 82px に細くなり、いちばん長い列の下端は 190px から 66px に下がって、列の下端の差は 54px から 36px に縮みます。 |
| ばねの強さを変える | 04 STIFFNESS 0.12 → 0.3 | 初めて届く時刻は約0.28秒で変わらず、行き過ぎは 21px から 41px に倍増し、落ち着くのは0.45秒から0.55秒に遅れます。 |
| レンズの半径を変える | 06 LENS_RADIUS 140 → 240 | 1.1倍を超えるカードが5枚から6枚に、最大の倍率が1.31から1.45に増え、縮められるカードは6枚から0枚になります。 |
三つの変形の数字は、ノードでソースの関数を直接呼んで数えた値です。
操作は 06 を除く八つの場面で本物の <button> が受け持ちます。06 はボタンを持たず、カード12枚が tabIndex を持つので Tab で届きます。keydown のハンドラーと preventDefault は九つのファイルのどこにもなく、キーボードはボタン本来の動作である Enter と Space だけで動きます。状態行の aria-live はデモを再生しているあいだは off で、入力が届くと polite に切り替わり、九つの場面とも ResizeObserver で幅の変化を受けて位置を計算し直します。
prefers-reduced-motion が有効だと、九つの場面はどれも自動のデモとアニメーションのループを始めず、結果の配置を一度だけ描きます。入力は補間なしにそのまま反映され、01 の 積み直す と 07 の 停止 のボタンはこのとき隠れます。08 で絞り込まれたカードは透明度が0になるだけで DOM に残るので、リンクカードに替えて使うときは hidden か inert を別に付けてください。
css masonry flexbox だけで足りるか?
列の中にカードを順に入れていく方式は、02 の縦に埋めると同じ順番になります。列の下端の差は 5px といちばん小さくなりますが、番号が列に沿って下りるので読む順はいちばん大きくずれます。横の順を守るには 02 のルールのようにカード一枚ごとに列を計算する必要があり、高さの違うカードを一行にそろえる仕事は 05 のように行ごとに別に計算します。この記事の九つのファイルはカードの位置をすべて計算で決めていて、行の数を grid に渡す 03 だけが配置を CSS に任せています。
場面ごとに向く場所は違います。01 はピンボード、02 は読む順が大事なカード一覧、03 は画像の比率がばらばらなギャラリー、04 はレスポンシブな切り替え、05 は写真アルバム、06 はホバーで見渡す画像ボード、07 はランディングの背景、08 はカテゴリーで絞り込むギャラリー、09 は最初の登場演出に付きます。作品をまとめて見せる画面の組み方は ポートフォリオ ギャラリー UI 9選 — ドラッグからモーフ切替まで で、カードを押して大きく開く切り替えは css ライトボックス 拡大アニメーション 9種まとめ で続きます。幅の変化を受ける観察者は ResizeObserver に、列に分ける基本のルールは CSS multi-column layout にまとまっています。
css masonry image gallery を使い回す前によくある質問
css masonry grid generator のように値を変えて試すにはどこを触りますか
各場面の index.html の <script> の中にある const の値を書き換えます。列の数は COLUMNS、間隔は GAP、カードの高さを決める乱数列の種は SEED です。04 と 05 には COLUMNS がなく、09 では種が makeCards の引数に直接書かれています。
カードの数が増えてもそのまま使えますか
九つの場面は8〜20枚のカードで測ったので、それより多い場合は確かめていません。01 の pack はカード一枚ごとに列の数だけ比べるので増える量はカードの数に比例しますが、07 のように DOM を作り直す場面は数百枚で別に測る必要があります。
ライブラリや画像は必要ですか
必要ありません。九つのファイルとも外部へのリクエストがなく、番号だけを書いたグラデーションの箱で動くので、一つの場面を使うなら index.html と style.css の二つだけを持っていけば動きます。









