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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 가장 낮은 열을 찾아 한 장씩 얹는 최단 열 배치
- 02 왼쪽에서 오른쪽으로 읽히도록 순서를 바로잡은 열 배치
- 03 이미지 비율로 행 칸 수를 계산하는 그리드 스팬
- 04 열 수가 바뀔 때 스프링으로 제자리를 찾는 FLIP 재배치
- 05 한 줄 너비에 딱 맞게 높이를 맞추는 균등 행
- 06 포인터 가까이에서 이웃이 비켜서는 렌즈 배열
- 07 열마다 반대 방향으로 흘러가는 무한 벽돌
- 08 필터를 누르면 자리가 순서대로 옮겨 가는 셔플
- 09 위에서 떨어져 튕기며 쌓이는 낙하 벽돌
css masonry layout 9가지 — 열 높이·행 단위·스프링을 계산해야 빈틈이 닫힌다
장면 하나는 480×300 무대 안에 폭 최대 440px, 높이 216px 의 벽을 두고, 번호만 적힌 그라디언트 카드를 쌓는다. 카드 비율은 시드를 고정한 난수열이 뽑으므로 같은 시각이면 언제나 같은 프레임이 나오고, 벽 위 막대의 버튼으로 열 수·모드·필터를 직접 바꿀 수 있다.
벽돌 놓기는 두 층으로 나뉜다. 카드 높이를 미리 알면 열 끝이 어디인지만 기억해도 목표 자리가 정해지는 장면이 많고, 그 자리를 화면이 곧바로 쓰지 않고 따로 따라간다. 목표 자리를 구하는 규칙도 따라가는 방식도 장면마다 다르다. 속도를 쥔 스프링은 04 하나이고 09 는 중력을 적분하며, 01·02·06 은 목표를 향한 지수 접근이고 07 은 속도에 시간을 곱해 더하며 08 은 시간 이징이다. 03 과 05 는 배치 자체를 그 자리에서 계산한다. 아홉 파일 모두 CSS transition 과 @keyframes 가 한 줄도 없고 라이브러리·외부 주소 요청·Math.random 도 없다.
이미지 비율이 제각각인 갤러리가 목적이면 03 부터, 필터 버튼이 붙는 목록이면 08 부터, 창 폭이 바뀌는 반응형 화면이면 04 부터 열면 된다. 슬라이드·확대·라이트박스 같은 갤러리의 나머지 효과는 이미지 갤러리 CSS 9종 — 메이슨리부터 라이트박스까지와 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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행 칸 수를 계산해 grid 에 맡기는 스팬
배치는 진짜 CSS grid 가 하고 JS 는 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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;
}
}
}
}
값이 배열을 바꾸는 곳 — 열 수·스프링 강성·렌즈 반경
값 하나로 모양이 크게 달라지는 곳은 셋이다. 아래 숫자는 소스의 같은 함수를 노드에서 직접 불러 센 값이고 벽 폭 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 는 줄 높이에 가로세로 비율을 곱해 폭을 정한다. 이미지나 글이 들어간 실제 카드는 내용이 높이를 정하므로 한 번 그려 본 뒤 그 높이를 재서 pack 에 넘겨야 한다.
최단 열은 읽는 순서를 버린다
01 의 pack 은 높이만 보고 열을 고르기 때문에 번호가 열 사이를 건너뛴다. 가로로 읽혀야 하는 목록이면 02 의 가로 순서를 쓰되 열 끝 차이가 5px 에서 72px 로 벌어지는 값을 치르고, 균형 규칙이 30px 로 중간에서 타협한다.
grid 스팬은 칸마다 빈 틈을 남긴다
03 은 span × 16 - 8 이 카드 높이를 덮도록 올림하므로 카드마다 최대 15px 의 여분이 생기고 행 단위를 키울수록 커진다. 폭이 바뀌면 span 이 다시 계산되어 카드 높이가 한 행 단위씩 보간 없이 뛴다.
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 로 풀면 나오고, 여기에 아홉 장면의 소스와 리액트 판이 함께 담겨 있다.
변형 세 가지와 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 열 수 바꾸기 | 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 가 시연 중에는 꺼져 있다가 입력이 오면 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 layout codepen 예제를 가져다 쓰기 전에 자주 묻는 질문
css masonry grid generator 처럼 값을 바꿔 보려면 어디를 만지나?
각 장면 <script> 안의 const 값을 고친다. 열 수는 COLUMNS, 간격은 GAP, 카드 높이를 정하는 난수열은 SEED 이고, 칩에 적힌 이름이 그 장면에서 바꿔 볼 값이다.
카드 수가 많아져도 그대로 쓸 수 있나?
아홉 장면은 카드 8~20장에서 재서 그 이상은 확인하지 않았다. pack 은 카드 하나마다 열 수만큼 비교하므로 늘어나는 양은 카드 수에 비례하지만, 07 처럼 DOM 을 다시 만드는 장면은 수백 장에서 따로 재 봐야 한다.
라이브러리나 이미지가 필요한가?
필요 없다. 아홉 파일 모두 외부 주소 요청이 없고 번호만 적힌 그라디언트 상자로 돌아서, 한 장면의 index.html 과 style.css 만 옮겨도 동작한다.









