06A Lens Layout Where Neighbors Make Way Near the Pointer
A Gaussian falloff on the pointer-to-center distance sets each card's scale, followed by interpolation, so near cards grow and far ones press down slightly.
Best forImage boards scanned by hover
LENS_RADIUSGAINFALLOFFLERP

<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">Nearest card</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");
// Cards: 시드 LCG 로 비율을 뽑는다. 이미지 파일은 없고 그라디언트와 번호만 있다.
function makeCards(n, seed, lo, hi) {
const rng = makeRng(seed);
const list = [];
for (let i = 0; i < n; i += 1) {
const el = document.createElement("div");
el.className = CLS.card;
el.setAttribute("data-c", String(i % 6));
el.innerHTML = "<i>" + (i + 1) + "</i>";
wall.appendChild(el);
list.push({
el: el, ratio: lo + rng() * (hi - lo), cat: Math.floor(rng() * 3),
x: 0, y: 0, w: 0, h: 0, tx: 0, ty: 0, tw: 0, th: 0,
s: 1, ds: 1, o: 1, dop: 1, vx: 0, vy: 0,
ox: 0, oy: 0, osx: 0, osy: 0, ovx: 0, ovy: 0, ovsx: 0, ovsy: 0,
fx: 0, fy: 0, fs: 1, fo: 1, t0: 0, sq: 0, rest: 0, wait: 0
});
}
return list;
}
function colW(cols, gap) {
return (wall.clientWidth - gap * (cols - 1)) / cols;
}
// 최단 Columns 배치: 카드를 순서대로 읽으며 지금 가장 낮은 Columns 끝에 얹고 그 Columns 높이에 Cards 높이와 간격을 더한다.
function pack(hs, cols, cw, gap) {
const colH = [];
for (let k = 0; k < cols; k += 1) colH.push(0);
const out = [];
for (let i = 0; i < hs.length; i += 1) {
let best = 0;
for (let k = 1; k < cols; k += 1) {
if (colH[k] < colH[best] - 0.5) best = k;
}
out.push({ x: best * (cw + gap), y: colH[best] });
colH[best] += hs[i] + gap;
}
return out;
}
function put(c, x, y, sx, sy) {
const st = c.el.style;
st.width = c.w.toFixed(1) + "px";
st.height = c.h.toFixed(1) + "px";
st.transform = "translate3d(" + x.toFixed(2) + "px," + y.toFixed(2) + "px,0) scale(" + sx.toFixed(3) + "," + sy.toFixed(3) + ")";
}
function frame(ts) {
const dt = last < 0 ? 0.016 : Math.min(0.05, (ts - last) / 1000);
last = ts;
if (demo) script(ts);
step(dt, ts);
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(function () {
relayout();
});
ro.observe(root);
// 기법: 포인터와 Cards 중심의 거리 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 A Lens Layout Where Neighbors Make Way Near the Pointer — Measured area 54.377% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · CSS Masonry Layout: 9 Ways to Pack Uneven Cards
The post this demo lives in
CSS Masonry Layout: 9 Ways to Pack Uneven CardsA css masonry layout of one columns or grid line scrambles order and teleports cards on resize — scenes 01, 02 and 04 fi See all demos in this post


