css popover 9가지 — 앵커에서 열리고 가장자리에서 뒤집히는 팝오버
css popover 가 화면 끝에서 잘리고 화살표가 엉뚱한 곳을 가리키고 여럿이 겹친다 — 01번은 여유 공간 계산, 04번은 화살표 보간, 09번은 척력으로 푼다.
아홉 가지 팝오버를 화면에서 바로 열어 본다
01 pm-anchor-collision-flip
02 pm-spring-from-anchor
03 pm-hover-intent-safe-triangle
04 pm-arrow-tracks-anchor
05 pm-rubber-edge-drag
06 pm-nested-cascade-stack
07 pm-menu-roving-spotlight
08 pm-ink-bloom-reveal
09 pm-orbit-popover-ring
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 네 방향 여유 공간을 재서 자리를 고르는 충돌 뒤집기
- 02 앵커 점에서 스프링으로 부풀어 열리는 팝오버
- 03 포인터 방향으로 닫힘을 미루는 안전 삼각형
- 04 몸통이 밀려도 앵커를 가리키는 화살표
- 05 끌면 관성으로 따라오고 가장자리에서 고무줄처럼 버티는 팝오버
- 06 깊이가 한 칸 늘 때마다 그림자와 크기가 계단으로 변하는 중첩 팝오버
- 07 메뉴 항목 사이를 스프링으로 미끄러지는 강조 알약
- 08 앵커에서 번지는 노이즈 가장자리 원형 마스크
- 09 한 앵커 둘레에서 서로 밀어내며 자리 잡는 팝오버 고리
css popover 9가지 — 앵커 둘레 공간·스프링·벡터를 계산해야 제자리에 뜬다
팝오버는 버튼이나 점 같은 앵커에서 열리는 작은 판이다. HTML 에는 열고 닫는 기본 동작을 브라우저가 맡아 주는 popover 속성이 있지만, 이 편의 아홉 장면은 그 속성도 CSS anchor positioning 도 쓰지 않는다. 전부 <div> 하나를 JS 가 계산한 translate3d 로 옮겨 놓는다. 어느 쪽에 뜨고, 얼마나 밀리고, 겹치면 어떻게 비키는지를 눈으로 따라가려는 선택이다.
장면 하나의 무대는 폭 최대 440px, 높이 252px 의 반투명 판이고 폭 320px 화면에서는 약 294×174 로 줄어든다. 이 판의 네 변이 화면 가장자리 역할을 하며 진짜 viewport 가 아니다. 렌더 캡처는 480×300 으로 찍으므로 무대와 캡처 크기를 섞어 읽지 않는다. 움직임은 1/60초 고정 틱으로 돌아서 화면 주사율과 상관없이 같은 모양이 나온다.
따라가는 방식은 장면마다 다르다. 속도 변수를 쥔 스프링은 02·07·09 이고, 01·03·04·05·06 은 목표를 향해 매 틱 일정 비율씩 다가가는 지수 접근이며, 08 은 선형 p 에 1-(1-p)^3 이징을 얹는다. 목표 자리를 구하는 쪽도 01 은 여유 점수, 03 은 삼각형 판정, 08 은 노이즈 다각형, 09 는 겹침 판정으로 제각각이다. 아홉 파일 모두 라이브러리와 외부 요청, Math.random, CSS @keyframes 가 없다.
말풍선이 화면 끝에서 잘리는 문제는 01 부터, css popover menu 를 만들 거라면 03 과 07 부터, 끌어서 옮기는 카드라면 05 부터 열면 된다. 같은 사이트의 CSS 툴팁 말풍선 9종 — 한 줄 복붙으로, 컨텍스트 메뉴 UI 9종 — 클릭 좌표 값이 메뉴 위치를 정하는 방식, CSS 드롭다운 메뉴 애니메이션 9종 — JS 없이, CSS만으로 도 이웃 주제다.
css popover anchor positioning 을 기법별 코드로 본다
팝오버 자리를 정하는 함수를 장면마다 소스에서 이어진 줄 그대로 가져왔고 한 조각은 25줄 이하다. 상수 선언은 조각에서 빼고 값을 설명에 적었으니, 전체는 압축에 든 파일을 보면 된다.
01네 방향 여유 공간을 재서 자리를 고르는 충돌 뒤집기
choose() 가 앵커 둘레 네 변의 여유에서 팝오버 크기와 간격 12px 을 뺀 점수를 매기고, 지금 쪽이 0 미만이거나 최고 점수보다 24 넘게 낮을 때만 방향을 바꾼다. 화면 가장자리의 도움말 말풍선에 맞고, 뒤집힐 때는 팝오버가 앵커 위를 가로질러 미끄러진다. 렌더 측정은 누적면적 52.1%, 움직인 프레임 23/23 이다.

<main class="stage">
<div class="pm is-demo" id="pm" data-demo="1">
<p class="pm__live" data-r="live" aria-live="off" aria-atomic="true"></p>
<i class="pm__g" data-r="g0"></i><i class="pm__g" data-r="g1"></i><i class="pm__g" data-r="g2"></i><i class="pm__g" data-r="g3"></i>
<div class="pm__pop" data-r="pop"><i class="pm__arrow" data-r="arrow"></i><b class="pm__side" data-r="side">BOTTOM</b><span class="pm__sub">도움말 말풍선</span></div>
<button class="pm__anchor" data-r="anchor" type="button" aria-label="앵커 · 방향키로 옮기기"></button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 앵커 사방에서 화면 끝까지의 거리를 재고, 팝오버가 들어갈 자리가 있는 쪽 중 가장 넉넉한 쪽에 놓는다. 위치 계산은 JS, CSS 는 모양만 맡는다.
@include stage($stage-ink);
.pm {
position: relative;
box-sizing: border-box;
overflow: hidden;
width: min(100%, 440px);
height: min(252px, calc(100vh - 2 * clamp(8px, 4vw, 24px)));
border-radius: $r-card;
background: rgba($subject-cream, .07);
color: $subject-cream;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.pm b { font-weight: 800; font-variant-numeric: tabular-nums; }
.pm__live {
position: absolute;
left: $sp-3;
top: $sp-2;
margin: 0;
z-index: 9;
color: $tick-mute;
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.pm button { font-family: inherit; }
.pm button:focus-visible { outline: 3px solid $subject-pink; outline-offset: 2px; }
.pm { touch-action: none; }
.pm__g {
position: absolute;
left: 0;
top: 0;
width: 2px;
height: 1px;
transform-origin: 0 0;
background: rgba($subject-cream, .22);
pointer-events: none;
}
.pm__g[data-hot] { background: $subject-mint; }
.pm__g:nth-of-type(2), .pm__g:nth-of-type(4) { width: 1px; height: 2px; }
.pm__anchor {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
background: $subject-sky;
box-shadow: 0 0 0 4px rgba($subject-sky, .3), $shadow-press;
cursor: grab;
z-index: 5;
}
.pm__pop {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
width: 116px;
height: 52px;
padding: $sp-2 $sp-3;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
box-shadow: $shadow-raised;
z-index: 4;
}
.pm__arrow {
position: absolute;
left: 0;
top: 0;
width: 16px;
height: 16px;
border-radius: $r-xs;
background: $subject-cream;
}
.pm__side { position: relative; display: block; font-size: 14px; line-height: 20px; letter-spacing: .5px; }
.pm__sub { position: relative; display: block; color: rgba($subject-ink, .72); }
@media (prefers-reduced-motion: reduce) {
.pm__anchor { cursor: default; }
}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);
}
.pm {
position: relative;
box-sizing: border-box;
overflow: hidden;
width: min(100%, 440px);
height: min(252px, 100vh - 2 * clamp(8px, 4vw, 24px));
border-radius: 16px;
background: rgba(255, 247, 230, 0.07);
color: #fff7e6;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.pm b {
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.pm__live {
position: absolute;
left: 12px;
top: 8px;
margin: 0;
z-index: 9;
color: #9a93a6;
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.pm button {
font-family: inherit;
}
.pm button:focus-visible {
outline: 3px solid #ff9ebb;
outline-offset: 2px;
}
.pm {
touch-action: none;
}
.pm__g {
position: absolute;
left: 0;
top: 0;
width: 2px;
height: 1px;
transform-origin: 0 0;
background: rgba(255, 247, 230, 0.22);
pointer-events: none;
}
.pm__g[data-hot] {
background: #4cd4a6;
}
.pm__g:nth-of-type(2), .pm__g:nth-of-type(4) {
width: 1px;
height: 2px;
}
.pm__anchor {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
background: #6ec8ff;
box-shadow: 0 0 0 4px rgba(110, 200, 255, 0.3), 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: grab;
z-index: 5;
}
.pm__pop {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
width: 116px;
height: 52px;
padding: 8px 12px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
z-index: 4;
}
.pm__arrow {
position: absolute;
left: 0;
top: 0;
width: 16px;
height: 16px;
border-radius: 4px;
background: #fff7e6;
}
.pm__side {
position: relative;
display: block;
font-size: 14px;
line-height: 20px;
letter-spacing: 0.5px;
}
.pm__sub {
position: relative;
display: block;
color: rgba(23, 20, 26, 0.72);
}
@media (prefers-reduced-motion: reduce) {
.pm__anchor {
cursor: default;
}
}(function () {
const root = document.getElementById("pm");
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
let acc = 0;
let clock = 0;
const offs = [];
const live = root.querySelector("[data-r=live]");
function q(sel) {
return root.querySelector(sel);
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
function seg(t, a, b) {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => 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");
let shown = "";
function say(s) {
if (s === shown)
return;
shown = s;
live.textContent = s;
}
function makeRng(seed) {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function local(e) {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function sync() {
if (reduce) {
snap();
render();
}
}
// 기법: 앵커 사방의 여유 공간을 재서 팝오버가 들어갈 자리가 있는 쪽 중 가장 넉넉한 쪽을 고른다. 지금 쪽이 FLIP_BIAS 이내로 버티면 그대로 둬 경계에서 깜박이지 않는다.
const GAP = 12;
const EDGE_PAD = 8;
const ARROW_SIZE = 8;
const FLIP_BIAS = 24;
const POP_W = 116;
const POP_H = 52;
const ANCHOR_R = 14;
const SEG_MS = 520;
const NAMES = ["TOP", "RIGHT", "BOTTOM", "LEFT"];
const PATH = [[0.5, 0.3], [0.9, 0.28], [0.86, 0.82], [0.14, 0.84], [0.1, 0.22], [0.5, 0.3]];
const anchor = q("[data-r=anchor]");
const pop = q("[data-r=pop]");
const arrow = q("[data-r=arrow]");
const sideEl = q("[data-r=side]");
const guides = [0, 1, 2, 3].map((i) => q("[data-r=g" + i + "]"));
const free = [0, 0, 0, 0];
let nx = reduce ? PATH[2][0] : PATH[0][0];
let ny = reduce ? PATH[2][1] : PATH[0][1];
let side = 2;
let px = 0;
let py = 0;
let placed = false;
let dragging = false;
function choose(w, h, ax, ay) {
free[0] = ay - ANCHOR_R - EDGE_PAD;
free[1] = w - ax - ANCHOR_R - EDGE_PAD;
free[2] = h - ay - ANCHOR_R - EDGE_PAD;
free[3] = ax - ANCHOR_R - EDGE_PAD;
const score = [0, 1, 2, 3].map((s) => free[s] - ((s % 2 === 0 ? POP_H : POP_W) + GAP));
let best = 0;
for (let s = 1; s < 4; s += 1) {
if (score[s] > score[best])
best = s;
}
if (score[side] < 0 || score[side] < score[best] - FLIP_BIAS)
side = best;
}
function target(w, h, ax, ay) {
if (side === 0 || side === 2) {
const x = clamp(ax - POP_W / 2, EDGE_PAD, w - EDGE_PAD - POP_W);
return [x, side === 2 ? ay + ANCHOR_R + GAP : ay - ANCHOR_R - GAP - POP_H];
}
const y = clamp(ay - POP_H / 2, EDGE_PAD, h - EDGE_PAD - POP_H);
return [side === 1 ? ax + ANCHOR_R + GAP : ax - ANCHOR_R - GAP - POP_W, y];
}
function pathAt(ms) {
const u = Math.max(0, ms - 200) / SEG_MS;
const n = PATH.length - 1;
const i = Math.floor(u) % n;
const f = seg(u - Math.floor(u), 0, 1);
return [PATH[i][0] + (PATH[i + 1][0] - PATH[i][0]) * f, PATH[i][1] + (PATH[i + 1][1] - PATH[i][1]) * f];
}
function solve(instant) {
const w = root.clientWidth;
const h = root.clientHeight;
choose(w, h, nx * w, ny * h);
const t2 = target(w, h, nx * w, ny * h);
if (!placed || instant) {
px = t2[0];
py = t2[1];
placed = true;
}
else {
px += (t2[0] - px) * 0.28;
py += (t2[1] - py) * 0.28;
}
}
function tick(ms) {
if (demo) {
const p = pathAt(ms);
nx = p[0];
ny = p[1];
}
solve(false);
}
function snap() {
placed = false;
solve(true);
}
function render() {
const w = root.clientWidth;
const h = root.clientHeight;
const ax = nx * w;
const ay = ny * h;
anchor.style.transform = "translate3d(" + (ax - ANCHOR_R).toFixed(1) + "px," + (ay - ANCHOR_R).toFixed(1) + "px,0)";
pop.style.transform = "translate3d(" + px.toFixed(1) + "px," + py.toFixed(1) + "px,0)";
let cx = 0;
let cy = 0;
if (side === 0 || side === 2) {
cx = clamp(ax - px, 14, POP_W - 14);
cy = side === 2 ? 0 : POP_H;
}
else {
cx = side === 1 ? 0 : POP_W;
cy = clamp(ay - py, 14, POP_H - 14);
}
arrow.style.transform = "translate3d(" + (cx - ARROW_SIZE).toFixed(1) + "px," + (cy - ARROW_SIZE).toFixed(1) + "px,0) rotate(45deg)";
sideEl.textContent = NAMES[side];
guides[0].style.transform = "translate3d(" + (ax - 1).toFixed(1) + "px,0,0) scale(1," + Math.max(0, ay).toFixed(1) + ")";
guides[2].style.transform = "translate3d(" + (ax - 1).toFixed(1) + "px," + ay.toFixed(1) + "px,0) scale(1," + Math.max(0, h - ay).toFixed(1) + ")";
guides[3].style.transform = "translate3d(0," + (ay - 1).toFixed(1) + "px,0) scale(" + Math.max(0, ax).toFixed(1) + ",1)";
guides[1].style.transform = "translate3d(" + ax.toFixed(1) + "px," + (ay - 1).toFixed(1) + "px,0) scale(" + Math.max(0, w - ax).toFixed(1) + ",1)";
for (let s = 0; s < 4; s += 1) {
if (s === side)
guides[s].setAttribute("data-hot", "1");
else
guides[s].removeAttribute("data-hot");
}
say("T " + Math.round(free[0]) + " R " + Math.round(free[1]) + " B " + Math.round(free[2]) + " L " + Math.round(free[3]));
}
function moveTo(e) {
const p = local(e);
nx = clamp(p.x / root.clientWidth, 0.05, 0.95);
ny = clamp(p.y / root.clientHeight, 0.08, 0.92);
sync();
}
on(root, "pointerdown", (e) => {
stopDemo();
dragging = true;
root.setPointerCapture(e.pointerId);
moveTo(e);
});
on(root, "pointermove", (e) => {
if (dragging)
moveTo(e);
});
on(root, "pointerup", () => {
dragging = false;
});
on(root, "pointercancel", () => {
dragging = false;
});
on(anchor, "keydown", (e) => {
const dx = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
const dy = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
if (dx === 0 && dy === 0)
return;
e.preventDefault();
stopDemo();
nx = clamp(nx + dx * 0.06, 0.05, 0.95);
ny = clamp(ny + dy * 0.08, 0.08, 0.92);
sync();
});
snap();
const FRAME_MS = 1000 / 60;
function frame(ts) {
if (t0 < 0) {
t0 = ts;
last = ts;
}
acc += Math.min(100, ts - last);
last = ts;
let n = 0;
while (acc >= FRAME_MS && n < 6) {
clock += FRAME_MS;
tick(clock);
acc -= FRAME_MS;
n += 1;
}
if (n === 6)
acc = 0;
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(() => sync());
ro.observe(root);
if (reduce) {
stopDemo();
snap();
render();
}
else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
ro.disconnect();
};
})();// CollisionFlipPopover - vanilla 판(01-pm-anchor-collision-flip) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./CollisionFlipPopover.module.scss";
export default function CollisionFlipPopover() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
let acc = 0;
let clock = 0;
const offs: Array<() => void> = [];
const live = root.querySelector("[data-r=live]") as HTMLElement;
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function seg(t: number, a: number, b: number): number {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el: EventTarget, type: string, fn: (e: any) => void): void {
el.addEventListener(type, fn);
offs.push(() => 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");
let shown = "";
function say(s: string): void {
if (s === shown) return;
shown = s;
live.textContent = s;
}
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function local(e: PointerEvent): { x: number; y: number } {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function sync(): void {
if (reduce) {
snap();
render();
}
}
// 기법: 앵커 사방의 여유 공간을 재서 팝오버가 들어갈 자리가 있는 쪽 중 가장 넉넉한 쪽을 고른다. 지금 쪽이 FLIP_BIAS 이내로 버티면 그대로 둬 경계에서 깜박이지 않는다.
const GAP = 12;
const EDGE_PAD = 8;
const ARROW_SIZE = 8;
const FLIP_BIAS = 24;
const POP_W = 116;
const POP_H = 52;
const ANCHOR_R = 14;
const SEG_MS = 520;
const NAMES = ["TOP", "RIGHT", "BOTTOM", "LEFT"];
const PATH = [[0.5, 0.3], [0.9, 0.28], [0.86, 0.82], [0.14, 0.84], [0.1, 0.22], [0.5, 0.3]];
const anchor = q<HTMLElement>("[data-r=anchor]");
const pop = q<HTMLElement>("[data-r=pop]");
const arrow = q<HTMLElement>("[data-r=arrow]");
const sideEl = q<HTMLElement>("[data-r=side]");
const guides = [0, 1, 2, 3].map((i) => q<HTMLElement>("[data-r=g" + i + "]"));
const free: number[] = [0, 0, 0, 0];
let nx = reduce ? PATH[2][0] : PATH[0][0];
let ny = reduce ? PATH[2][1] : PATH[0][1];
let side = 2;
let px = 0;
let py = 0;
let placed = false;
let dragging = false;
function choose(w: number, h: number, ax: number, ay: number): void {
free[0] = ay - ANCHOR_R - EDGE_PAD;
free[1] = w - ax - ANCHOR_R - EDGE_PAD;
free[2] = h - ay - ANCHOR_R - EDGE_PAD;
free[3] = ax - ANCHOR_R - EDGE_PAD;
const score = [0, 1, 2, 3].map((s) => free[s] - ((s % 2 === 0 ? POP_H : POP_W) + GAP));
let best = 0;
for (let s = 1; s < 4; s += 1) {
if (score[s] > score[best]) best = s;
}
if (score[side] < 0 || score[side] < score[best] - FLIP_BIAS) side = best;
}
function target(w: number, h: number, ax: number, ay: number): number[] {
if (side === 0 || side === 2) {
const x = clamp(ax - POP_W / 2, EDGE_PAD, w - EDGE_PAD - POP_W);
return [x, side === 2 ? ay + ANCHOR_R + GAP : ay - ANCHOR_R - GAP - POP_H];
}
const y = clamp(ay - POP_H / 2, EDGE_PAD, h - EDGE_PAD - POP_H);
return [side === 1 ? ax + ANCHOR_R + GAP : ax - ANCHOR_R - GAP - POP_W, y];
}
function pathAt(ms: number): number[] {
const u = Math.max(0, ms - 200) / SEG_MS;
const n = PATH.length - 1;
const i = Math.floor(u) % n;
const f = seg(u - Math.floor(u), 0, 1);
return [PATH[i][0] + (PATH[i + 1][0] - PATH[i][0]) * f, PATH[i][1] + (PATH[i + 1][1] - PATH[i][1]) * f];
}
function solve(instant: boolean): void {
const w = root.clientWidth;
const h = root.clientHeight;
choose(w, h, nx * w, ny * h);
const t2 = target(w, h, nx * w, ny * h);
if (!placed || instant) {
px = t2[0];
py = t2[1];
placed = true;
} else {
px += (t2[0] - px) * 0.28;
py += (t2[1] - py) * 0.28;
}
}
function tick(ms: number): void {
if (demo) {
const p = pathAt(ms);
nx = p[0];
ny = p[1];
}
solve(false);
}
function snap(): void {
placed = false;
solve(true);
}
function render(): void {
const w = root.clientWidth;
const h = root.clientHeight;
const ax = nx * w;
const ay = ny * h;
anchor.style.transform = "translate3d(" + (ax - ANCHOR_R).toFixed(1) + "px," + (ay - ANCHOR_R).toFixed(1) + "px,0)";
pop.style.transform = "translate3d(" + px.toFixed(1) + "px," + py.toFixed(1) + "px,0)";
let cx = 0;
let cy = 0;
if (side === 0 || side === 2) {
cx = clamp(ax - px, 14, POP_W - 14);
cy = side === 2 ? 0 : POP_H;
} else {
cx = side === 1 ? 0 : POP_W;
cy = clamp(ay - py, 14, POP_H - 14);
}
arrow.style.transform = "translate3d(" + (cx - ARROW_SIZE).toFixed(1) + "px," + (cy - ARROW_SIZE).toFixed(1) + "px,0) rotate(45deg)";
sideEl.textContent = NAMES[side];
guides[0].style.transform = "translate3d(" + (ax - 1).toFixed(1) + "px,0,0) scale(1," + Math.max(0, ay).toFixed(1) + ")";
guides[2].style.transform = "translate3d(" + (ax - 1).toFixed(1) + "px," + ay.toFixed(1) + "px,0) scale(1," + Math.max(0, h - ay).toFixed(1) + ")";
guides[3].style.transform = "translate3d(0," + (ay - 1).toFixed(1) + "px,0) scale(" + Math.max(0, ax).toFixed(1) + ",1)";
guides[1].style.transform = "translate3d(" + ax.toFixed(1) + "px," + (ay - 1).toFixed(1) + "px,0) scale(" + Math.max(0, w - ax).toFixed(1) + ",1)";
for (let s = 0; s < 4; s += 1) {
if (s === side) guides[s].setAttribute("data-hot", "1");
else guides[s].removeAttribute("data-hot");
}
say("T " + Math.round(free[0]) + " R " + Math.round(free[1]) + " B " + Math.round(free[2]) + " L " + Math.round(free[3]));
}
function moveTo(e: PointerEvent): void {
const p = local(e);
nx = clamp(p.x / root.clientWidth, 0.05, 0.95);
ny = clamp(p.y / root.clientHeight, 0.08, 0.92);
sync();
}
on(root, "pointerdown", (e: PointerEvent) => {
stopDemo();
dragging = true;
root.setPointerCapture(e.pointerId);
moveTo(e);
});
on(root, "pointermove", (e: PointerEvent) => {
if (dragging) moveTo(e);
});
on(root, "pointerup", () => {
dragging = false;
});
on(root, "pointercancel", () => {
dragging = false;
});
on(anchor, "keydown", (e: KeyboardEvent) => {
const dx = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
const dy = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
if (dx === 0 && dy === 0) return;
e.preventDefault();
stopDemo();
nx = clamp(nx + dx * 0.06, 0.05, 0.95);
ny = clamp(ny + dy * 0.08, 0.08, 0.92);
sync();
});
snap();
const FRAME_MS = 1000 / 60;
function frame(ts: number): void {
if (t0 < 0) {
t0 = ts;
last = ts;
}
acc += Math.min(100, ts - last);
last = ts;
let n = 0;
while (acc >= FRAME_MS && n < 6) {
clock += FRAME_MS;
tick(clock);
acc -= FRAME_MS;
n += 1;
}
if (n === 6) acc = 0;
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(() => sync());
ro.observe(root);
if (reduce) {
stopDemo();
snap();
render();
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
ro.disconnect();
};
}, []);
return (
<main className={styles["stage"]}>
<div ref={rootRef} className={styles["pm"] + " is-demo"} data-demo="1">
<p className={styles["pm__live"]} data-r="live" aria-live="off" aria-atomic="true"></p>
<i className={styles["pm__g"]} data-r="g0"></i><i className={styles["pm__g"]} data-r="g1"></i><i className={styles["pm__g"]} data-r="g2"></i><i className={styles["pm__g"]} data-r="g3"></i>
<div className={styles["pm__pop"]} data-r="pop"><i className={styles["pm__arrow"]} data-r="arrow"></i><b className={styles["pm__side"]} data-r="side">BOTTOM</b><span className={styles["pm__sub"]}>도움말 말풍선</span></div>
<button className={styles["pm__anchor"]} data-r="anchor" type="button" aria-label="앵커 · 방향키로 옮기기"></button>
</div>
</main>
);
}01 · 네 방향 여유 공간을 재서 자리를 고르는 충돌 뒤집기 — 측정 누적면적 52.147% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function choose(w, h, ax, ay) {
free[0] = ay - ANCHOR_R - EDGE_PAD;
free[1] = w - ax - ANCHOR_R - EDGE_PAD;
free[2] = h - ay - ANCHOR_R - EDGE_PAD;
free[3] = ax - ANCHOR_R - EDGE_PAD;
const score = [0, 1, 2, 3].map((s) => free[s] - ((s % 2 === 0 ? POP_H : POP_W) + GAP));
let best = 0;
for (let s = 1; s < 4; s += 1) {
if (score[s] > score[best])
best = s;
}
if (score[side] < 0 || score[side] < score[best] - FLIP_BIAS)
side = best;
}
02앵커 점에서 스프링으로 부풀어 열리는 팝오버
틱마다 (vel + (목표 - size) * STIFFNESS) * DAMPING 으로 크기를 풀어서 목표 1 을 살짝 넘었다가 돌아오고, 변형 기준점을 앵커 중심에 두어 앵커에서 자라난 것처럼 보인다. 버튼 옆 빠른 설정 패널에 맞는다. 렌더 측정은 누적면적 29.8%, 움직인 프레임 16/23 이다.

<main class="stage">
<div class="pm is-demo" id="pm" data-demo="1">
<p class="pm__live" data-r="live" aria-live="off" aria-atomic="true"></p>
<button class="pm__move" data-r="move" type="button">자리 옮기기</button>
<i class="pm__origin" data-r="origin"></i>
<button class="pm__anchor" data-r="anchor" type="button" aria-expanded="false" aria-controls="pm-panel" aria-label="빠른 설정 열기"></button>
<div class="pm__pop" id="pm-panel" data-r="pop"><b class="pm__ttl">빠른 설정</b><span class="pm__row"><i class="pm__sw"></i>알림</span><span class="pm__row"><i class="pm__sw pm__sw--on"></i>소리</span><span class="pm__row"><i class="pm__sw"></i>진동</span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: transform-origin 을 앵커 중심 좌표로 계산하고, scale 만 스프링으로 풀어 앵커에서 자라난 듯 열린다.
@include stage($stage-yellow);
.pm {
position: relative;
box-sizing: border-box;
overflow: hidden;
width: min(100%, 440px);
height: min(252px, calc(100vh - 2 * clamp(8px, 4vw, 24px)));
border-radius: $r-card;
background: rgba($subject-ink, .07);
color: $subject-ink;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.pm b { font-weight: 800; font-variant-numeric: tabular-nums; }
.pm__live {
position: absolute;
left: $sp-3;
top: $sp-2;
margin: 0;
z-index: 9;
color: rgba($subject-ink, .72);
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.pm button { font-family: inherit; }
.pm button:focus-visible { outline: 3px solid $subject-ink; outline-offset: 2px; }
.pm__move {
position: absolute;
right: $sp-3;
top: $sp-2;
z-index: 8;
height: 24px;
padding: 0 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba($subject-ink, .12);
color: $subject-ink;
font-size: 12px;
line-height: 16px;
font-weight: 700;
cursor: pointer;
}
.pm__origin {
position: absolute;
left: 0;
top: 0;
width: 6px;
height: 6px;
border-radius: 50%;
background: $subject-ink;
z-index: 6;
pointer-events: none;
}
.pm__anchor {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
background: $subject-lilac;
box-shadow: 0 0 0 4px rgba($subject-lilac, .4), $shadow-press;
cursor: pointer;
z-index: 5;
}
.pm__pop {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-1;
width: 156px;
height: 92px;
padding: $sp-3;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
box-shadow: $shadow-raised;
visibility: hidden;
z-index: 4;
will-change: transform;
}
.pm__ttl { display: block; font-size: 13px; line-height: 20px; }
.pm__row { display: flex; align-items: center; gap: $sp-2; }
.pm__sw {
position: relative;
width: 24px;
height: 12px;
border-radius: $r-pill;
background: rgba($subject-ink, .22);
}
.pm__sw::after {
content: "";
position: absolute;
left: 2px;
top: 2px;
width: 8px;
height: 8px;
border-radius: 50%;
background: $subject-cream;
}
.pm__sw--on { background: $subject-mint; }
.pm__sw--on::after { left: 14px; }
@media (prefers-reduced-motion: reduce) {
.pm__pop { 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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pm {
position: relative;
box-sizing: border-box;
overflow: hidden;
width: min(100%, 440px);
height: min(252px, 100vh - 2 * clamp(8px, 4vw, 24px));
border-radius: 16px;
background: rgba(23, 20, 26, 0.07);
color: #17141a;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.pm b {
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.pm__live {
position: absolute;
left: 12px;
top: 8px;
margin: 0;
z-index: 9;
color: rgba(23, 20, 26, 0.72);
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.pm button {
font-family: inherit;
}
.pm button:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
.pm__move {
position: absolute;
right: 12px;
top: 8px;
z-index: 8;
height: 24px;
padding: 0 12px;
border: 0;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
color: #17141a;
font-size: 12px;
line-height: 16px;
font-weight: 700;
cursor: pointer;
}
.pm__origin {
position: absolute;
left: 0;
top: 0;
width: 6px;
height: 6px;
border-radius: 50%;
background: #17141a;
z-index: 6;
pointer-events: none;
}
.pm__anchor {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
background: #b9a0ff;
box-shadow: 0 0 0 4px rgba(185, 160, 255, 0.4), 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: pointer;
z-index: 5;
}
.pm__pop {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 4px;
width: 156px;
height: 92px;
padding: 12px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
visibility: hidden;
z-index: 4;
will-change: transform;
}
.pm__ttl {
display: block;
font-size: 13px;
line-height: 20px;
}
.pm__row {
display: flex;
align-items: center;
gap: 8px;
}
.pm__sw {
position: relative;
width: 24px;
height: 12px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.22);
}
.pm__sw::after {
content: "";
position: absolute;
left: 2px;
top: 2px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff7e6;
}
.pm__sw--on {
background: #4cd4a6;
}
.pm__sw--on::after {
left: 14px;
}
@media (prefers-reduced-motion: reduce) {
.pm__pop {
will-change: auto;
}
}(function () {
const root = document.getElementById("pm");
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
let acc = 0;
let clock = 0;
const offs = [];
const live = root.querySelector("[data-r=live]");
function q(sel) {
return root.querySelector(sel);
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
function seg(t, a, b) {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => 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");
let shown = "";
function say(s) {
if (s === shown)
return;
shown = s;
live.textContent = s;
}
function makeRng(seed) {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function local(e) {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function sync() {
if (reduce) {
snap();
render();
}
}
// 기법: transform-origin 을 앵커 중심(ORIGIN_X, ORIGIN_Y)으로 계산해 두고, scale 을 스프링으로 풀면 목표 1 을 넘었다 돌아오며 앵커에서 자란 것처럼 보인다.
const STIFFNESS = 0.14;
const DAMPING = 0.66;
const PANEL_W = 156;
const PANEL_H = 92;
const ANCHOR_R = 14;
const SLOTS = [0.16, 0.5, 0.84];
const ANCHOR_NY = 0.22;
const PAD = 8;
const CYCLE_MS = 2400;
const anchor = q("[data-r=anchor]");
const pop = q("[data-r=pop]");
const origin = q("[data-r=origin]");
const moveBtn = q("[data-r=move]");
let ORIGIN_X = 0;
let ORIGIN_Y = 0;
let size = 0;
let vel = 0;
let want = reduce;
let slot = 1;
let nx = SLOTS[1];
let expanded = false;
let peak = 0;
function place() {
const w = root.clientWidth;
const h = root.clientHeight;
const ax = nx * w;
const ay = ANCHOR_NY * h;
const x = clamp(ax - PANEL_W / 2, PAD, w - PAD - PANEL_W);
const y = ay + ANCHOR_R + 10;
ORIGIN_X = ax - x;
ORIGIN_Y = ay - y;
return [ax, ay, x, y];
}
function tick(ms) {
if (demo) {
const cycle = Math.floor(ms / CYCLE_MS);
const phase = ms - cycle * CYCLE_MS;
want = phase >= 250 && phase < 1350;
slot = (cycle + (phase >= 1900 ? 1 : 0)) % SLOTS.length;
}
nx += (SLOTS[slot] - nx) * 0.2;
vel = (vel + ((want ? 1 : 0) - size) * STIFFNESS) * DAMPING;
size += vel;
if (size < 0) {
size = 0;
vel = 0;
}
if (want)
peak = Math.max(peak, size);
else if (size < 0.02)
peak = 0;
}
function snap() {
nx = SLOTS[slot];
size = want ? 1 : 0;
vel = 0;
peak = size;
}
function render() {
const p = place();
anchor.style.transform = "translate3d(" + (p[0] - ANCHOR_R).toFixed(1) + "px," + (p[1] - ANCHOR_R).toFixed(1) + "px,0)";
origin.style.transform = "translate3d(" + (p[0] - 3).toFixed(1) + "px," + (p[1] - 3).toFixed(1) + "px,0)";
pop.style.transformOrigin = ORIGIN_X.toFixed(1) + "px " + ORIGIN_Y.toFixed(1) + "px";
pop.style.transform = "translate3d(" + p[2].toFixed(1) + "px," + p[3].toFixed(1) + "px,0) scale(" + size.toFixed(3) + ")";
pop.style.opacity = String(clamp(size * 1.8, 0, 1));
pop.style.visibility = size > 0.01 ? "visible" : "hidden";
if (want !== expanded) {
expanded = want;
anchor.setAttribute("aria-expanded", want ? "true" : "false");
}
say("scale " + size.toFixed(2) + " peak " + peak.toFixed(2));
}
function toggle() {
stopDemo();
want = !want;
sync();
}
on(anchor, "click", toggle);
on(moveBtn, "click", () => {
stopDemo();
slot = (slot + 1) % SLOTS.length;
sync();
});
on(root, "keydown", (e) => {
if (e.key !== "Escape" || !want)
return;
e.preventDefault();
stopDemo();
want = false;
anchor.focus({ preventScroll: true });
sync();
});
const FRAME_MS = 1000 / 60;
function frame(ts) {
if (t0 < 0) {
t0 = ts;
last = ts;
}
acc += Math.min(100, ts - last);
last = ts;
let n = 0;
while (acc >= FRAME_MS && n < 6) {
clock += FRAME_MS;
tick(clock);
acc -= FRAME_MS;
n += 1;
}
if (n === 6)
acc = 0;
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(() => sync());
ro.observe(root);
if (reduce) {
stopDemo();
snap();
render();
}
else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
ro.disconnect();
};
})();// SpringFromAnchor - vanilla 판(02-pm-spring-from-anchor) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SpringFromAnchor.module.scss";
export default function SpringFromAnchor() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
let acc = 0;
let clock = 0;
const offs: Array<() => void> = [];
const live = root.querySelector("[data-r=live]") as HTMLElement;
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function seg(t: number, a: number, b: number): number {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el: EventTarget, type: string, fn: (e: any) => void): void {
el.addEventListener(type, fn);
offs.push(() => 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");
let shown = "";
function say(s: string): void {
if (s === shown) return;
shown = s;
live.textContent = s;
}
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function local(e: PointerEvent): { x: number; y: number } {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function sync(): void {
if (reduce) {
snap();
render();
}
}
// 기법: transform-origin 을 앵커 중심(ORIGIN_X, ORIGIN_Y)으로 계산해 두고, scale 을 스프링으로 풀면 목표 1 을 넘었다 돌아오며 앵커에서 자란 것처럼 보인다.
const STIFFNESS = 0.14;
const DAMPING = 0.66;
const PANEL_W = 156;
const PANEL_H = 92;
const ANCHOR_R = 14;
const SLOTS = [0.16, 0.5, 0.84];
const ANCHOR_NY = 0.22;
const PAD = 8;
const CYCLE_MS = 2400;
const anchor = q<HTMLElement>("[data-r=anchor]");
const pop = q<HTMLElement>("[data-r=pop]");
const origin = q<HTMLElement>("[data-r=origin]");
const moveBtn = q<HTMLElement>("[data-r=move]");
let ORIGIN_X = 0;
let ORIGIN_Y = 0;
let size = 0;
let vel = 0;
let want = reduce;
let slot = 1;
let nx = SLOTS[1];
let expanded = false;
let peak = 0;
function place(): number[] {
const w = root.clientWidth;
const h = root.clientHeight;
const ax = nx * w;
const ay = ANCHOR_NY * h;
const x = clamp(ax - PANEL_W / 2, PAD, w - PAD - PANEL_W);
const y = ay + ANCHOR_R + 10;
ORIGIN_X = ax - x;
ORIGIN_Y = ay - y;
return [ax, ay, x, y];
}
function tick(ms: number): void {
if (demo) {
const cycle = Math.floor(ms / CYCLE_MS);
const phase = ms - cycle * CYCLE_MS;
want = phase >= 250 && phase < 1350;
slot = (cycle + (phase >= 1900 ? 1 : 0)) % SLOTS.length;
}
nx += (SLOTS[slot] - nx) * 0.2;
vel = (vel + ((want ? 1 : 0) - size) * STIFFNESS) * DAMPING;
size += vel;
if (size < 0) {
size = 0;
vel = 0;
}
if (want) peak = Math.max(peak, size);
else if (size < 0.02) peak = 0;
}
function snap(): void {
nx = SLOTS[slot];
size = want ? 1 : 0;
vel = 0;
peak = size;
}
function render(): void {
const p = place();
anchor.style.transform = "translate3d(" + (p[0] - ANCHOR_R).toFixed(1) + "px," + (p[1] - ANCHOR_R).toFixed(1) + "px,0)";
origin.style.transform = "translate3d(" + (p[0] - 3).toFixed(1) + "px," + (p[1] - 3).toFixed(1) + "px,0)";
pop.style.transformOrigin = ORIGIN_X.toFixed(1) + "px " + ORIGIN_Y.toFixed(1) + "px";
pop.style.transform = "translate3d(" + p[2].toFixed(1) + "px," + p[3].toFixed(1) + "px,0) scale(" + size.toFixed(3) + ")";
pop.style.opacity = String(clamp(size * 1.8, 0, 1));
pop.style.visibility = size > 0.01 ? "visible" : "hidden";
if (want !== expanded) {
expanded = want;
anchor.setAttribute("aria-expanded", want ? "true" : "false");
}
say("scale " + size.toFixed(2) + " peak " + peak.toFixed(2));
}
function toggle(): void {
stopDemo();
want = !want;
sync();
}
on(anchor, "click", toggle);
on(moveBtn, "click", () => {
stopDemo();
slot = (slot + 1) % SLOTS.length;
sync();
});
on(root, "keydown", (e: KeyboardEvent) => {
if (e.key !== "Escape" || !want) return;
e.preventDefault();
stopDemo();
want = false;
anchor.focus({ preventScroll: true });
sync();
});
const FRAME_MS = 1000 / 60;
function frame(ts: number): void {
if (t0 < 0) {
t0 = ts;
last = ts;
}
acc += Math.min(100, ts - last);
last = ts;
let n = 0;
while (acc >= FRAME_MS && n < 6) {
clock += FRAME_MS;
tick(clock);
acc -= FRAME_MS;
n += 1;
}
if (n === 6) acc = 0;
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(() => sync());
ro.observe(root);
if (reduce) {
stopDemo();
snap();
render();
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
ro.disconnect();
};
}, []);
return (
<main className={styles["stage"]}>
<div ref={rootRef} className={styles["pm"] + " is-demo"} data-demo="1">
<p className={styles["pm__live"]} data-r="live" aria-live="off" aria-atomic="true"></p>
<button className={styles["pm__move"]} data-r="move" type="button">자리 옮기기</button>
<i className={styles["pm__origin"]} data-r="origin"></i>
<button className={styles["pm__anchor"]} data-r="anchor" type="button" aria-expanded="false" aria-controls="pm-panel" aria-label="빠른 설정 열기"></button>
<div className={styles["pm__pop"]} id="pm-panel" data-r="pop"><b className={styles["pm__ttl"]}>빠른 설정</b><span className={styles["pm__row"]}><i className={styles["pm__sw"]}></i>알림</span><span className={styles["pm__row"]}><i className={styles["pm__sw"] + " " + styles["pm__sw--on"]}></i>소리</span><span className={styles["pm__row"]}><i className={styles["pm__sw"]}></i>진동</span></div>
</div>
</main>
);
}02 · 앵커 점에서 스프링으로 부풀어 열리는 팝오버 — 측정 누적면적 29.803% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function tick(ms) {
if (demo) {
const cycle = Math.floor(ms / CYCLE_MS);
const phase = ms - cycle * CYCLE_MS;
want = phase >= 250 && phase < 1350;
slot = (cycle + (phase >= 1900 ? 1 : 0)) % SLOTS.length;
}
nx += (SLOTS[slot] - nx) * 0.2;
vel = (vel + ((want ? 1 : 0) - size) * STIFFNESS) * DAMPING;
size += vel;
if (size < 0) {
size = 0;
vel = 0;
}
if (want)
peak = Math.max(peak, size);
else if (size < 0.02)
peak = 0;
}
03포인터 방향으로 닫힘을 미루는 안전 삼각형
6틱 전 포인터 위치에서 열린 패널의 왼쪽 두 모서리로 삼각형을 긋고, 포인터가 SPEED_MIN 이상의 속도로 오른쪽 삼각형 안을 지나는 동안에는 다른 항목에 닿아도 INTENT_MS 260ms 가 지나기 전엔 전환하지 않는다. 호버로 열리는 서브 메뉴에 맞는다. 렌더 측정은 누적면적 34.0%, 움직인 프레임 18/23 이다.

<main class="stage">
<div class="pm is-demo" id="pm" data-demo="1">
<p class="pm__live" data-r="live" aria-live="off" aria-atomic="true"></p>
<i class="pm__tri" data-r="tri"></i>
<div class="pm__menu"><button class="pm__item" data-r="item" type="button" aria-haspopup="true" aria-expanded="false">프로필</button><button class="pm__item" data-r="item" type="button" aria-haspopup="true" aria-expanded="false">알림</button><button class="pm__item" data-r="item" type="button" aria-haspopup="true" aria-expanded="false">보안</button></div>
<div class="pm__sub" data-r="sub"><b class="pm__ttl">프로필</b><span class="pm__r">열기</span><span class="pm__r">고정</span><span class="pm__r">삭제</span></div><div class="pm__sub" data-r="sub"><b class="pm__ttl">알림</b><span class="pm__r">열기</span><span class="pm__r">고정</span><span class="pm__r">삭제</span></div><div class="pm__sub" data-r="sub"><b class="pm__ttl">보안</b><span class="pm__r">열기</span><span class="pm__r">고정</span><span class="pm__r">삭제</span></div>
<i class="pm__cur" data-r="cur"></i>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 포인터 속도와 방향으로 앵커에서 팝오버 모서리를 잇는 삼각형을 그리고, 그 안을 지나는 동안엔 닫힘과 전환을 미룬다.
@include stage($stage-ink);
.pm {
position: relative;
box-sizing: border-box;
overflow: hidden;
width: min(100%, 440px);
height: min(252px, calc(100vh - 2 * clamp(8px, 4vw, 24px)));
border-radius: $r-card;
background: rgba($subject-cream, .07);
color: $subject-cream;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.pm b { font-weight: 800; font-variant-numeric: tabular-nums; }
.pm__live {
position: absolute;
left: $sp-3;
top: $sp-2;
margin: 0;
z-index: 9;
color: $tick-mute;
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.pm button { font-family: inherit; }
.pm button:focus-visible { outline: 3px solid $subject-pink; outline-offset: 2px; }
.pm__tri {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba($subject-sky, .22);
pointer-events: none;
}
.pm__tri[data-on="1"] { background: rgba($subject-mint, .4); }
.pm__menu {
position: absolute;
left: 12px;
top: 34px;
display: flex;
flex-direction: column;
gap: 4px;
width: 112px;
}
.pm__item {
box-sizing: border-box;
height: 36px;
padding: 0 $sp-3;
border: 0;
border-radius: $r-control;
background: rgba($subject-cream, .1);
color: $subject-cream;
font-size: 13px;
line-height: 20px;
font-weight: 700;
text-align: left;
cursor: pointer;
}
.pm__item[data-open] { background: $subject-lilac; color: $subject-ink; }
.pm__sub {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: $sp-1;
height: 132px;
padding: $sp-3;
border-radius: $r-card;
background: $subject-cream;
color: $subject-ink;
box-shadow: $shadow-raised;
visibility: hidden;
}
.pm__ttl { display: block; font-size: 13px; line-height: 20px; }
.pm__r { display: block; padding: $sp-1 $sp-2; border-radius: $r-xs; background: rgba($subject-ink, .07); }
.pm__cur {
position: absolute;
left: 0;
top: 0;
width: 14px;
height: 14px;
border-radius: 50%;
background: $subject-pink;
box-shadow: 0 0 0 3px rgba($subject-ink, .5);
opacity: 0;
pointer-events: none;
z-index: 6;
}
.pm[data-demo] .pm__cur { opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.pm__cur { display: none; }
}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);
}
.pm {
position: relative;
box-sizing: border-box;
overflow: hidden;
width: min(100%, 440px);
height: min(252px, 100vh - 2 * clamp(8px, 4vw, 24px));
border-radius: 16px;
background: rgba(255, 247, 230, 0.07);
color: #fff7e6;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.pm b {
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.pm__live {
position: absolute;
left: 12px;
top: 8px;
margin: 0;
z-index: 9;
color: #9a93a6;
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.pm button {
font-family: inherit;
}
.pm button:focus-visible {
outline: 3px solid #ff9ebb;
outline-offset: 2px;
}
.pm__tri {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: rgba(110, 200, 255, 0.22);
pointer-events: none;
}
.pm__tri[data-on="1"] {
background: rgba(76, 212, 166, 0.4);
}
.pm__menu {
position: absolute;
left: 12px;
top: 34px;
display: flex;
flex-direction: column;
gap: 4px;
width: 112px;
}
.pm__item {
box-sizing: border-box;
height: 36px;
padding: 0 12px;
border: 0;
border-radius: 12px;
background: rgba(255, 247, 230, 0.1);
color: #fff7e6;
font-size: 13px;
line-height: 20px;
font-weight: 700;
text-align: left;
cursor: pointer;
}
.pm__item[data-open] {
background: #b9a0ff;
color: #17141a;
}
.pm__sub {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 4px;
height: 132px;
padding: 12px;
border-radius: 16px;
background: #fff7e6;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
visibility: hidden;
}
.pm__ttl {
display: block;
font-size: 13px;
line-height: 20px;
}
.pm__r {
display: block;
padding: 4px 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.07);
}
.pm__cur {
position: absolute;
left: 0;
top: 0;
width: 14px;
height: 14px;
border-radius: 50%;
background: #ff9ebb;
box-shadow: 0 0 0 3px rgba(23, 20, 26, 0.5);
opacity: 0;
pointer-events: none;
z-index: 6;
}
.pm[data-demo] .pm__cur {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.pm__cur {
display: none;
}
}(function () {
const root = document.getElementById("pm");
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
let acc = 0;
let clock = 0;
const offs = [];
const live = root.querySelector("[data-r=live]");
function q(sel) {
return root.querySelector(sel);
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
function seg(t, a, b) {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => 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");
let shown = "";
function say(s) {
if (s === shown)
return;
shown = s;
live.textContent = s;
}
function makeRng(seed) {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function local(e) {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function sync() {
if (reduce) {
snap();
render();
}
}
// 기법: 포인터가 떠나는 순간의 위치를 꼭짓점으로 삼고 팝오버 왼쪽 두 모서리를 밑변으로 하는 삼각형을 그린다. 그 안을 SPEED_MIN 이상으로 팝오버 쪽으로 지나는 동안엔 다른 항목에 닿아도 닫지 않는다.
const INTENT_MS = 260;
const TRIANGLE_PAD = 10;
const SPEED_MIN = 60;
const GRACE = 140;
const MENU_X = 12;
const MENU_W = 112;
const MENU_TOP = 34;
const ITEM_H = 36;
const ITEM_GAP = 4;
const PANEL_X = 132;
const PANEL_H = 132;
const TRAIL = 6;
const CYCLE_MS = 3200;
const items = Array.from(root.querySelectorAll("[data-r=item]"));
const subs = Array.from(root.querySelectorAll("[data-r=sub]"));
const tri = q("[data-r=tri]");
const cur = q("[data-r=cur]");
const trail = [];
const vis = [0, 0, 0];
let mx = MENU_X + MENU_W / 2;
let my = MENU_TOP + ITEM_H / 2;
let openIdx = 0;
let since = -1;
let leftAt = -1;
let inTri = false;
let spd = 0;
let lastOpen = -2;
function itemY(i) {
return MENU_TOP + i * (ITEM_H + ITEM_GAP);
}
function panelTop(i) {
return clamp(itemY(i), MENU_TOP - 4, root.clientHeight - 8 - PANEL_H);
}
function panelW() {
return Math.min(150, root.clientWidth - PANEL_X - 8);
}
function hitItem(x, y) {
if (x < MENU_X || x > MENU_X + MENU_W)
return -1;
for (let i = 0; i < 3; i += 1) {
if (y >= itemY(i) && y < itemY(i) + ITEM_H + ITEM_GAP)
return i;
}
return -1;
}
function inPanel(x, y) {
if (openIdx < 0)
return false;
const t = panelTop(openIdx);
return x >= PANEL_X && x <= PANEL_X + panelW() && y >= t && y <= t + PANEL_H;
}
function side(ax, ay, bx, by, px, py) {
return (px - bx) * (ay - by) - (ax - bx) * (py - by);
}
function inTriangle(px, py, a, b, c) {
const d1 = side(px, py, a[0], a[1], b[0], b[1]);
const d2 = side(px, py, b[0], b[1], c[0], c[1]);
const d3 = side(px, py, c[0], c[1], a[0], a[1]);
const neg = d1 < 0 || d2 < 0 || d3 < 0;
const pos = d1 > 0 || d2 > 0 || d3 > 0;
return !(neg && pos);
}
function corners() {
const t = panelTop(Math.max(0, openIdx));
return [[PANEL_X, t - TRIANGLE_PAD], [PANEL_X, t + PANEL_H + TRIANGLE_PAD]];
}
function script(ms) {
const t = ms % CYCLE_MS;
const a = [MENU_X + MENU_W / 2, itemY(0) + ITEM_H / 2];
const b = [PANEL_X + 40, panelTop(0) + 90];
const c = [MENU_X + MENU_W / 2, itemY(2) + ITEM_H / 2];
let p = a;
let f = 0;
if (t < 200)
p = a;
else if (t < 900) {
f = seg(t, 200, 900);
p = [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f];
}
else if (t < 1200)
p = b;
else if (t < 1800) {
f = seg(t, 1200, 1800);
p = [b[0] + (c[0] - b[0]) * f, b[1] + (c[1] - b[1]) * f];
}
else if (t < 2400)
p = c;
else if (t < 3000) {
f = seg(t, 2400, 3000);
p = [c[0] + (a[0] - c[0]) * f, c[1] + (a[1] - c[1]) * f];
}
mx = p[0];
my = p[1];
}
function tick(ms) {
if (demo)
script(ms);
trail.push([mx, my]);
if (trail.length > TRAIL + 1)
trail.shift();
const old = trail[0];
const dx = mx - old[0];
const dy = my - old[1];
spd = (Math.hypot(dx, dy) / (trail.length - 1 || 1)) * 60;
const hover = hitItem(mx, my);
const c = corners();
inTri = spd >= SPEED_MIN && dx > 0 && inTriangle(mx, my, old, c[0], c[1]);
if (hover >= 0 && hover !== openIdx) {
if (inTri && openIdx >= 0) {
if (since < 0)
since = ms;
if (ms - since > INTENT_MS)
openIdx = hover;
}
else {
openIdx = hover;
since = -1;
}
leftAt = -1;
}
else if (hover === openIdx || inPanel(mx, my)) {
since = -1;
leftAt = -1;
}
else {
since = -1;
if (leftAt < 0)
leftAt = ms;
if (ms - leftAt > GRACE && hover < 0 && mx < MENU_X - 4)
openIdx = -1;
}
for (let i = 0; i < 3; i += 1)
vis[i] += ((i === openIdx ? 1 : 0) - vis[i]) * 0.3;
}
function snap() {
for (let i = 0; i < 3; i += 1)
vis[i] = i === openIdx ? 1 : 0;
inTri = false;
}
function render() {
const w = panelW();
for (let i = 0; i < 3; i += 1) {
subs[i].style.width = w + "px";
subs[i].style.transform = "translate3d(" + (PANEL_X + (1 - vis[i]) * -10).toFixed(1) + "px," + panelTop(i).toFixed(1) + "px,0)";
subs[i].style.opacity = vis[i].toFixed(2);
subs[i].style.visibility = vis[i] > 0.02 ? "visible" : "hidden";
}
if (openIdx !== lastOpen) {
lastOpen = openIdx;
for (let i = 0; i < 3; i += 1) {
items[i].setAttribute("aria-expanded", i === openIdx ? "true" : "false");
if (i === openIdx)
items[i].setAttribute("data-open", "1");
else
items[i].removeAttribute("data-open");
}
}
const old = trail.length ? trail[0] : [mx, my];
const c = corners();
tri.style.clipPath = "polygon(" + old[0].toFixed(1) + "px " + old[1].toFixed(1) + "px," + c[0][0] + "px " + c[0][1].toFixed(1) + "px," + c[1][0] + "px " + c[1][1].toFixed(1) + "px)";
tri.setAttribute("data-on", inTri ? "1" : "0");
tri.style.visibility = openIdx >= 0 && mx < PANEL_X ? "visible" : "hidden";
cur.style.transform = "translate3d(" + (mx - 7).toFixed(1) + "px," + (my - 7).toFixed(1) + "px,0)";
say("speed " + Math.round(spd) + " triangle " + (inTri ? "in" : "out") + " open " + (openIdx + 1));
}
on(root, "pointermove", (e) => {
stopDemo();
const p = local(e);
mx = p.x;
my = p.y;
if (reduce && hitItem(mx, my) >= 0)
openIdx = hitItem(mx, my);
sync();
});
items.forEach((el, i) => {
on(el, "focus", () => {
stopDemo();
openIdx = i;
sync();
});
});
on(root, "keydown", (e) => {
if (e.key !== "Escape" || openIdx < 0)
return;
e.preventDefault();
stopDemo();
openIdx = -1;
sync();
});
const FRAME_MS = 1000 / 60;
function frame(ts) {
if (t0 < 0) {
t0 = ts;
last = ts;
}
acc += Math.min(100, ts - last);
last = ts;
let n = 0;
while (acc >= FRAME_MS && n < 6) {
clock += FRAME_MS;
tick(clock);
acc -= FRAME_MS;
n += 1;
}
if (n === 6)
acc = 0;
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(() => sync());
ro.observe(root);
if (reduce) {
stopDemo();
snap();
render();
}
else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
ro.disconnect();
};
})();// SafeTriangleMenu - vanilla 판(03-pm-hover-intent-safe-triangle) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./SafeTriangleMenu.module.scss";
export default function SafeTriangleMenu() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
let acc = 0;
let clock = 0;
const offs: Array<() => void> = [];
const live = root.querySelector("[data-r=live]") as HTMLElement;
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function seg(t: number, a: number, b: number): number {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el: EventTarget, type: string, fn: (e: any) => void): void {
el.addEventListener(type, fn);
offs.push(() => 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");
let shown = "";
function say(s: string): void {
if (s === shown) return;
shown = s;
live.textContent = s;
}
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function local(e: PointerEvent): { x: number; y: number } {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function sync(): void {
if (reduce) {
snap();
render();
}
}
// 기법: 포인터가 떠나는 순간의 위치를 꼭짓점으로 삼고 팝오버 왼쪽 두 모서리를 밑변으로 하는 삼각형을 그린다. 그 안을 SPEED_MIN 이상으로 팝오버 쪽으로 지나는 동안엔 다른 항목에 닿아도 닫지 않는다.
const INTENT_MS = 260;
const TRIANGLE_PAD = 10;
const SPEED_MIN = 60;
const GRACE = 140;
const MENU_X = 12;
const MENU_W = 112;
const MENU_TOP = 34;
const ITEM_H = 36;
const ITEM_GAP = 4;
const PANEL_X = 132;
const PANEL_H = 132;
const TRAIL = 6;
const CYCLE_MS = 3200;
const items = Array.from(root.querySelectorAll("[data-r=item]")) as HTMLElement[];
const subs = Array.from(root.querySelectorAll("[data-r=sub]")) as HTMLElement[];
const tri = q<HTMLElement>("[data-r=tri]");
const cur = q<HTMLElement>("[data-r=cur]");
const trail: number[][] = [];
const vis = [0, 0, 0];
let mx = MENU_X + MENU_W / 2;
let my = MENU_TOP + ITEM_H / 2;
let openIdx = 0;
let since = -1;
let leftAt = -1;
let inTri = false;
let spd = 0;
let lastOpen = -2;
function itemY(i: number): number {
return MENU_TOP + i * (ITEM_H + ITEM_GAP);
}
function panelTop(i: number): number {
return clamp(itemY(i), MENU_TOP - 4, root.clientHeight - 8 - PANEL_H);
}
function panelW(): number {
return Math.min(150, root.clientWidth - PANEL_X - 8);
}
function hitItem(x: number, y: number): number {
if (x < MENU_X || x > MENU_X + MENU_W) return -1;
for (let i = 0; i < 3; i += 1) {
if (y >= itemY(i) && y < itemY(i) + ITEM_H + ITEM_GAP) return i;
}
return -1;
}
function inPanel(x: number, y: number): boolean {
if (openIdx < 0) return false;
const t = panelTop(openIdx);
return x >= PANEL_X && x <= PANEL_X + panelW() && y >= t && y <= t + PANEL_H;
}
function side(ax: number, ay: number, bx: number, by: number, px: number, py: number): number {
return (px - bx) * (ay - by) - (ax - bx) * (py - by);
}
function inTriangle(px: number, py: number, a: number[], b: number[], c: number[]): boolean {
const d1 = side(px, py, a[0], a[1], b[0], b[1]);
const d2 = side(px, py, b[0], b[1], c[0], c[1]);
const d3 = side(px, py, c[0], c[1], a[0], a[1]);
const neg = d1 < 0 || d2 < 0 || d3 < 0;
const pos = d1 > 0 || d2 > 0 || d3 > 0;
return !(neg && pos);
}
function corners(): number[][] {
const t = panelTop(Math.max(0, openIdx));
return [[PANEL_X, t - TRIANGLE_PAD], [PANEL_X, t + PANEL_H + TRIANGLE_PAD]];
}
function script(ms: number): void {
const t = ms % CYCLE_MS;
const a = [MENU_X + MENU_W / 2, itemY(0) + ITEM_H / 2];
const b = [PANEL_X + 40, panelTop(0) + 90];
const c = [MENU_X + MENU_W / 2, itemY(2) + ITEM_H / 2];
let p = a;
let f = 0;
if (t < 200) p = a;
else if (t < 900) {
f = seg(t, 200, 900);
p = [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f];
} else if (t < 1200) p = b;
else if (t < 1800) {
f = seg(t, 1200, 1800);
p = [b[0] + (c[0] - b[0]) * f, b[1] + (c[1] - b[1]) * f];
} else if (t < 2400) p = c;
else if (t < 3000) {
f = seg(t, 2400, 3000);
p = [c[0] + (a[0] - c[0]) * f, c[1] + (a[1] - c[1]) * f];
}
mx = p[0];
my = p[1];
}
function tick(ms: number): void {
if (demo) script(ms);
trail.push([mx, my]);
if (trail.length > TRAIL + 1) trail.shift();
const old = trail[0];
const dx = mx - old[0];
const dy = my - old[1];
spd = (Math.hypot(dx, dy) / (trail.length - 1 || 1)) * 60;
const hover = hitItem(mx, my);
const c = corners();
inTri = spd >= SPEED_MIN && dx > 0 && inTriangle(mx, my, old, c[0], c[1]);
if (hover >= 0 && hover !== openIdx) {
if (inTri && openIdx >= 0) {
if (since < 0) since = ms;
if (ms - since > INTENT_MS) openIdx = hover;
} else {
openIdx = hover;
since = -1;
}
leftAt = -1;
} else if (hover === openIdx || inPanel(mx, my)) {
since = -1;
leftAt = -1;
} else {
since = -1;
if (leftAt < 0) leftAt = ms;
if (ms - leftAt > GRACE && hover < 0 && mx < MENU_X - 4) openIdx = -1;
}
for (let i = 0; i < 3; i += 1) vis[i] += ((i === openIdx ? 1 : 0) - vis[i]) * 0.3;
}
function snap(): void {
for (let i = 0; i < 3; i += 1) vis[i] = i === openIdx ? 1 : 0;
inTri = false;
}
function render(): void {
const w = panelW();
for (let i = 0; i < 3; i += 1) {
subs[i].style.width = w + "px";
subs[i].style.transform = "translate3d(" + (PANEL_X + (1 - vis[i]) * -10).toFixed(1) + "px," + panelTop(i).toFixed(1) + "px,0)";
subs[i].style.opacity = vis[i].toFixed(2);
subs[i].style.visibility = vis[i] > 0.02 ? "visible" : "hidden";
}
if (openIdx !== lastOpen) {
lastOpen = openIdx;
for (let i = 0; i < 3; i += 1) {
items[i].setAttribute("aria-expanded", i === openIdx ? "true" : "false");
if (i === openIdx) items[i].setAttribute("data-open", "1");
else items[i].removeAttribute("data-open");
}
}
const old = trail.length ? trail[0] : [mx, my];
const c = corners();
tri.style.clipPath = "polygon(" + old[0].toFixed(1) + "px " + old[1].toFixed(1) + "px," + c[0][0] + "px " + c[0][1].toFixed(1) + "px," + c[1][0] + "px " + c[1][1].toFixed(1) + "px)";
tri.setAttribute("data-on", inTri ? "1" : "0");
tri.style.visibility = openIdx >= 0 && mx < PANEL_X ? "visible" : "hidden";
cur.style.transform = "translate3d(" + (mx - 7).toFixed(1) + "px," + (my - 7).toFixed(1) + "px,0)";
say("speed " + Math.round(spd) + " triangle " + (inTri ? "in" : "out") + " open " + (openIdx + 1));
}
on(root, "pointermove", (e: PointerEvent) => {
stopDemo();
const p = local(e);
mx = p.x;
my = p.y;
if (reduce && hitItem(mx, my) >= 0) openIdx = hitItem(mx, my);
sync();
});
items.forEach((el, i) => {
on(el, "focus", () => {
stopDemo();
openIdx = i;
sync();
});
});
on(root, "keydown", (e: KeyboardEvent) => {
if (e.key !== "Escape" || openIdx < 0) return;
e.preventDefault();
stopDemo();
openIdx = -1;
sync();
});
const FRAME_MS = 1000 / 60;
function frame(ts: number): void {
if (t0 < 0) {
t0 = ts;
last = ts;
}
acc += Math.min(100, ts - last);
last = ts;
let n = 0;
while (acc >= FRAME_MS && n < 6) {
clock += FRAME_MS;
tick(clock);
acc -= FRAME_MS;
n += 1;
}
if (n === 6) acc = 0;
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(() => sync());
ro.observe(root);
if (reduce) {
stopDemo();
snap();
render();
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
ro.disconnect();
};
}, []);
return (
<main className={styles["stage"]}>
<div ref={rootRef} className={styles["pm"] + " is-demo"} data-demo="1">
<p className={styles["pm__live"]} data-r="live" aria-live="off" aria-atomic="true"></p>
<i className={styles["pm__tri"]} data-r="tri"></i>
<div className={styles["pm__menu"]}><button className={styles["pm__item"]} data-r="item" type="button" aria-haspopup="true" aria-expanded="false">프로필</button><button className={styles["pm__item"]} data-r="item" type="button" aria-haspopup="true" aria-expanded="false">알림</button><button className={styles["pm__item"]} data-r="item" type="button" aria-haspopup="true" aria-expanded="false">보안</button></div>
<div className={styles["pm__sub"]} data-r="sub"><b className={styles["pm__ttl"]}>프로필</b><span className={styles["pm__r"]}>열기</span><span className={styles["pm__r"]}>고정</span><span className={styles["pm__r"]}>삭제</span></div><div className={styles["pm__sub"]} data-r="sub"><b className={styles["pm__ttl"]}>알림</b><span className={styles["pm__r"]}>열기</span><span className={styles["pm__r"]}>고정</span><span className={styles["pm__r"]}>삭제</span></div><div className={styles["pm__sub"]} data-r="sub"><b className={styles["pm__ttl"]}>보안</b><span className={styles["pm__r"]}>열기</span><span className={styles["pm__r"]}>고정</span><span className={styles["pm__r"]}>삭제</span></div>
<i className={styles["pm__cur"]} data-r="cur"></i>
</div>
</main>
);
}03 · 포인터 방향으로 닫힘을 미루는 안전 삼각형 — 측정 누적면적 33.965% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function side(ax, ay, bx, by, px, py) {
return (px - bx) * (ay - by) - (ax - bx) * (py - by);
}
function inTriangle(px, py, a, b, c) {
const d1 = side(px, py, a[0], a[1], b[0], b[1]);
const d2 = side(px, py, b[0], b[1], c[0], c[1]);
const d3 = side(px, py, c[0], c[1], a[0], a[1]);
const neg = d1 < 0 || d2 < 0 || d3 < 0;
const pos = d1 > 0 || d2 > 0 || d3 > 0;
return !(neg && pos);
}
function corners() {
const t = panelTop(Math.max(0, openIdx));
return [[PANEL_X, t - TRIANGLE_PAD], [PANEL_X, t + PANEL_H + TRIANGLE_PAD]];
}
04몸통이 밀려도 앵커를 가리키는 화살표
몸통은 가장자리 여백 EDGE_PAD 에서 막혀 밀리지만 화살표 끝은 앵커 x 를 틱당 ARROW_LERP 0.15 씩 쫓아서, 몸통이 최대 72px 밀려도 꼬리가 앵커 쪽을 가리킨다. 좁은 화면의 입력 안내에 맞는다. 렌더 측정은 누적면적 36.3%, 움직인 프레임 23/23 이다.

<main class="stage">
<div class="pm is-demo" id="pm" data-demo="1">
<p class="pm__live" data-r="live" aria-live="off" aria-atomic="true"></p>
<i class="pm__line" data-r="line"></i>
<div class="pm__pop" data-r="pop"><i class="pm__arrow" data-r="arrow"></i><span class="pm__txt">이메일 주소를 확인하세요</span></div>
<button class="pm__anchor" data-r="anchor" type="button" aria-label="앵커 · 좌우 방향키로 옮기기"></button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 팝오버 몸통은 가장자리에 막혀 밀리지만 화살표 끝은 앵커 중심을 보간으로 쫓아가 항상 앵커를 가리킨다.
@include stage($stage-paper);
.pm {
position: relative;
box-sizing: border-box;
overflow: hidden;
width: min(100%, 440px);
height: min(252px, calc(100vh - 2 * clamp(8px, 4vw, 24px)));
border-radius: $r-card;
background: rgba($subject-ink, .07);
color: $subject-ink;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.pm b { font-weight: 800; font-variant-numeric: tabular-nums; }
.pm__live {
position: absolute;
left: $sp-3;
top: $sp-2;
margin: 0;
z-index: 9;
color: rgba($subject-ink, .72);
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.pm button { font-family: inherit; }
.pm button:focus-visible { outline: 3px solid $subject-ink; outline-offset: 2px; }
.pm { touch-action: none; }
$accent: $subject-lilac;
.pm__line {
position: absolute;
left: 0;
top: 0;
width: 2px;
height: 1px;
transform-origin: 0 0;
background: rgba($subject-ink, .3);
pointer-events: none;
}
.pm__anchor {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
background: $subject-pink;
box-shadow: 0 0 0 4px rgba($subject-pink, .4), $shadow-press;
cursor: grab;
z-index: 5;
}
.pm__pop {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
display: flex;
align-items: center;
width: 168px;
height: 56px;
padding: $sp-2 $sp-3;
border-radius: $r-control;
background: $accent;
color: $subject-ink;
box-shadow: $shadow-raised;
z-index: 4;
}
.pm__arrow {
position: absolute;
left: 0;
top: 0;
width: 16px;
height: 16px;
border-radius: $r-xs;
background: $accent;
}
.pm__txt { position: relative; word-break: keep-all; }
@media (prefers-reduced-motion: reduce) {
.pm__anchor { cursor: default; }
}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);
}
.pm {
position: relative;
box-sizing: border-box;
overflow: hidden;
width: min(100%, 440px);
height: min(252px, 100vh - 2 * clamp(8px, 4vw, 24px));
border-radius: 16px;
background: rgba(23, 20, 26, 0.07);
color: #17141a;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.pm b {
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.pm__live {
position: absolute;
left: 12px;
top: 8px;
margin: 0;
z-index: 9;
color: rgba(23, 20, 26, 0.72);
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.pm button {
font-family: inherit;
}
.pm button:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
.pm {
touch-action: none;
}
.pm__line {
position: absolute;
left: 0;
top: 0;
width: 2px;
height: 1px;
transform-origin: 0 0;
background: rgba(23, 20, 26, 0.3);
pointer-events: none;
}
.pm__anchor {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
background: #ff9ebb;
box-shadow: 0 0 0 4px rgba(255, 158, 187, 0.4), 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: grab;
z-index: 5;
}
.pm__pop {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
display: flex;
align-items: center;
width: 168px;
height: 56px;
padding: 8px 12px;
border-radius: 12px;
background: #b9a0ff;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
z-index: 4;
}
.pm__arrow {
position: absolute;
left: 0;
top: 0;
width: 16px;
height: 16px;
border-radius: 4px;
background: #b9a0ff;
}
.pm__txt {
position: relative;
word-break: keep-all;
}
@media (prefers-reduced-motion: reduce) {
.pm__anchor {
cursor: default;
}
}(function () {
const root = document.getElementById("pm");
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
let acc = 0;
let clock = 0;
const offs = [];
const live = root.querySelector("[data-r=live]");
function q(sel) {
return root.querySelector(sel);
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
function seg(t, a, b) {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => 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");
let shown = "";
function say(s) {
if (s === shown)
return;
shown = s;
live.textContent = s;
}
function makeRng(seed) {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function local(e) {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function sync() {
if (reduce) {
snap();
render();
}
}
// 기법: 몸통은 EDGE_PAD 에서 막혀 앵커보다 최대 SHIFT_MAX 만큼 덜 가고, 화살표 끝은 따로 ARROW_LERP 로 앵커 중심을 쫓는다. 꼬리와 몸통이 서로 다른 속도로 움직인다.
const ARROW_LERP = 0.15;
const EDGE_PAD = 8;
const ARROW_SIZE = 8;
const SHIFT_MAX = 72;
const BODY_W = 168;
const BODY_H = 56;
const BODY_LERP = 0.35;
const ANCHOR_R = 14;
const GAP = 12;
const SWEEP_MS = 2400;
const anchor = q("[data-r=anchor]");
const pop = q("[data-r=pop]");
const arrow = q("[data-r=arrow]");
const line = q("[data-r=line]");
let nx = reduce ? 0.94 : 0.5;
let bodyX = 0;
let tip = 0;
let placed = false;
let dragging = false;
let shift = 0;
function axOf(w) {
return clamp(nx * w, EDGE_PAD + BODY_W / 2 - SHIFT_MAX, w - EDGE_PAD - BODY_W / 2 + SHIFT_MAX);
}
function bodyTarget(w, ax) {
const desired = ax - BODY_W / 2;
const clamped = clamp(desired, EDGE_PAD, w - EDGE_PAD - BODY_W);
shift = clamp(clamped - desired, -SHIFT_MAX, SHIFT_MAX);
return desired + shift;
}
function tick(ms) {
if (demo)
nx = 0.5 + 0.47 * Math.sin((Math.max(0, ms - 200) / SWEEP_MS) * Math.PI * 2);
const w = root.clientWidth;
const ax = axOf(w);
if (!placed)
snap();
bodyX += (bodyTarget(w, ax) - bodyX) * BODY_LERP;
tip += (ax - tip) * ARROW_LERP;
}
function snap() {
const w = root.clientWidth;
const ax = axOf(w);
bodyX = bodyTarget(w, ax);
tip = ax;
placed = true;
}
function render() {
const w = root.clientWidth;
const h = root.clientHeight;
const ax = axOf(w);
const ay = h * 0.3;
const by = ay + ANCHOR_R + GAP;
anchor.style.transform = "translate3d(" + (ax - ANCHOR_R).toFixed(1) + "px," + (ay - ANCHOR_R).toFixed(1) + "px,0)";
pop.style.transform = "translate3d(" + bodyX.toFixed(1) + "px," + by.toFixed(1) + "px,0)";
const rel = clamp(tip - bodyX, ARROW_SIZE + 6, BODY_W - ARROW_SIZE - 6);
arrow.style.transform = "translate3d(" + (rel - ARROW_SIZE).toFixed(1) + "px," + (-ARROW_SIZE).toFixed(1) + "px,0) rotate(45deg)";
line.style.transform = "translate3d(" + (ax - 1).toFixed(1) + "px," + (ay + ANCHOR_R).toFixed(1) + "px,0) scale(1," + (by - ay - ANCHOR_R).toFixed(1) + ")";
say("shift " + Math.round(shift) + " gap " + Math.round(Math.abs(tip - ax)));
}
function moveTo(e) {
nx = clamp(local(e).x / root.clientWidth, 0.04, 0.96);
sync();
}
on(root, "pointerdown", (e) => {
stopDemo();
dragging = true;
root.setPointerCapture(e.pointerId);
moveTo(e);
});
on(root, "pointermove", (e) => {
if (dragging)
moveTo(e);
});
on(root, "pointerup", () => {
dragging = false;
});
on(root, "pointercancel", () => {
dragging = false;
});
on(anchor, "keydown", (e) => {
const dx = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (dx === 0)
return;
e.preventDefault();
stopDemo();
nx = clamp(nx + dx * 0.06, 0.04, 0.96);
sync();
});
snap();
const FRAME_MS = 1000 / 60;
function frame(ts) {
if (t0 < 0) {
t0 = ts;
last = ts;
}
acc += Math.min(100, ts - last);
last = ts;
let n = 0;
while (acc >= FRAME_MS && n < 6) {
clock += FRAME_MS;
tick(clock);
acc -= FRAME_MS;
n += 1;
}
if (n === 6)
acc = 0;
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(() => sync());
ro.observe(root);
if (reduce) {
stopDemo();
snap();
render();
}
else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
ro.disconnect();
};
})();// ArrowTracksAnchor - vanilla 판(04-pm-arrow-tracks-anchor) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./ArrowTracksAnchor.module.scss";
export default function ArrowTracksAnchor() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
let acc = 0;
let clock = 0;
const offs: Array<() => void> = [];
const live = root.querySelector("[data-r=live]") as HTMLElement;
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function seg(t: number, a: number, b: number): number {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el: EventTarget, type: string, fn: (e: any) => void): void {
el.addEventListener(type, fn);
offs.push(() => 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");
let shown = "";
function say(s: string): void {
if (s === shown) return;
shown = s;
live.textContent = s;
}
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function local(e: PointerEvent): { x: number; y: number } {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function sync(): void {
if (reduce) {
snap();
render();
}
}
// 기법: 몸통은 EDGE_PAD 에서 막혀 앵커보다 최대 SHIFT_MAX 만큼 덜 가고, 화살표 끝은 따로 ARROW_LERP 로 앵커 중심을 쫓는다. 꼬리와 몸통이 서로 다른 속도로 움직인다.
const ARROW_LERP = 0.15;
const EDGE_PAD = 8;
const ARROW_SIZE = 8;
const SHIFT_MAX = 72;
const BODY_W = 168;
const BODY_H = 56;
const BODY_LERP = 0.35;
const ANCHOR_R = 14;
const GAP = 12;
const SWEEP_MS = 2400;
const anchor = q<HTMLElement>("[data-r=anchor]");
const pop = q<HTMLElement>("[data-r=pop]");
const arrow = q<HTMLElement>("[data-r=arrow]");
const line = q<HTMLElement>("[data-r=line]");
let nx = reduce ? 0.94 : 0.5;
let bodyX = 0;
let tip = 0;
let placed = false;
let dragging = false;
let shift = 0;
function axOf(w: number): number {
return clamp(nx * w, EDGE_PAD + BODY_W / 2 - SHIFT_MAX, w - EDGE_PAD - BODY_W / 2 + SHIFT_MAX);
}
function bodyTarget(w: number, ax: number): number {
const desired = ax - BODY_W / 2;
const clamped = clamp(desired, EDGE_PAD, w - EDGE_PAD - BODY_W);
shift = clamp(clamped - desired, -SHIFT_MAX, SHIFT_MAX);
return desired + shift;
}
function tick(ms: number): void {
if (demo) nx = 0.5 + 0.47 * Math.sin((Math.max(0, ms - 200) / SWEEP_MS) * Math.PI * 2);
const w = root.clientWidth;
const ax = axOf(w);
if (!placed) snap();
bodyX += (bodyTarget(w, ax) - bodyX) * BODY_LERP;
tip += (ax - tip) * ARROW_LERP;
}
function snap(): void {
const w = root.clientWidth;
const ax = axOf(w);
bodyX = bodyTarget(w, ax);
tip = ax;
placed = true;
}
function render(): void {
const w = root.clientWidth;
const h = root.clientHeight;
const ax = axOf(w);
const ay = h * 0.3;
const by = ay + ANCHOR_R + GAP;
anchor.style.transform = "translate3d(" + (ax - ANCHOR_R).toFixed(1) + "px," + (ay - ANCHOR_R).toFixed(1) + "px,0)";
pop.style.transform = "translate3d(" + bodyX.toFixed(1) + "px," + by.toFixed(1) + "px,0)";
const rel = clamp(tip - bodyX, ARROW_SIZE + 6, BODY_W - ARROW_SIZE - 6);
arrow.style.transform = "translate3d(" + (rel - ARROW_SIZE).toFixed(1) + "px," + (-ARROW_SIZE).toFixed(1) + "px,0) rotate(45deg)";
line.style.transform = "translate3d(" + (ax - 1).toFixed(1) + "px," + (ay + ANCHOR_R).toFixed(1) + "px,0) scale(1," + (by - ay - ANCHOR_R).toFixed(1) + ")";
say("shift " + Math.round(shift) + " gap " + Math.round(Math.abs(tip - ax)));
}
function moveTo(e: PointerEvent): void {
nx = clamp(local(e).x / root.clientWidth, 0.04, 0.96);
sync();
}
on(root, "pointerdown", (e: PointerEvent) => {
stopDemo();
dragging = true;
root.setPointerCapture(e.pointerId);
moveTo(e);
});
on(root, "pointermove", (e: PointerEvent) => {
if (dragging) moveTo(e);
});
on(root, "pointerup", () => {
dragging = false;
});
on(root, "pointercancel", () => {
dragging = false;
});
on(anchor, "keydown", (e: KeyboardEvent) => {
const dx = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
if (dx === 0) return;
e.preventDefault();
stopDemo();
nx = clamp(nx + dx * 0.06, 0.04, 0.96);
sync();
});
snap();
const FRAME_MS = 1000 / 60;
function frame(ts: number): void {
if (t0 < 0) {
t0 = ts;
last = ts;
}
acc += Math.min(100, ts - last);
last = ts;
let n = 0;
while (acc >= FRAME_MS && n < 6) {
clock += FRAME_MS;
tick(clock);
acc -= FRAME_MS;
n += 1;
}
if (n === 6) acc = 0;
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(() => sync());
ro.observe(root);
if (reduce) {
stopDemo();
snap();
render();
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
ro.disconnect();
};
}, []);
return (
<main className={styles["stage"]}>
<div ref={rootRef} className={styles["pm"] + " is-demo"} data-demo="1">
<p className={styles["pm__live"]} data-r="live" aria-live="off" aria-atomic="true"></p>
<i className={styles["pm__line"]} data-r="line"></i>
<div className={styles["pm__pop"]} data-r="pop"><i className={styles["pm__arrow"]} data-r="arrow"></i><span className={styles["pm__txt"]}>이메일 주소를 확인하세요</span></div>
<button className={styles["pm__anchor"]} data-r="anchor" type="button" aria-label="앵커 · 좌우 방향키로 옮기기"></button>
</div>
</main>
);
}04 · 몸통이 밀려도 앵커를 가리키는 화살표 — 측정 누적면적 36.272% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function axOf(w) {
return clamp(nx * w, EDGE_PAD + BODY_W / 2 - SHIFT_MAX, w - EDGE_PAD - BODY_W / 2 + SHIFT_MAX);
}
function bodyTarget(w, ax) {
const desired = ax - BODY_W / 2;
const clamped = clamp(desired, EDGE_PAD, w - EDGE_PAD - BODY_W);
shift = clamp(clamped - desired, -SHIFT_MAX, SHIFT_MAX);
return desired + shift;
}
function tick(ms) {
if (demo)
nx = 0.5 + 0.47 * Math.sin((Math.max(0, ms - 200) / SWEEP_MS) * Math.PI * 2);
const w = root.clientWidth;
const ax = axOf(w);
if (!placed)
snap();
bodyX += (bodyTarget(w, ax) - bodyX) * BODY_LERP;
tip += (ax - tip) * ARROW_LERP;
}
05끌면 관성으로 따라오고 가장자리에서 고무줄처럼 버티는 팝오버
앵커를 끌면 팝오버가 틱당 0.14 비율로 뒤따르고, 허용 사각형을 넘은 거리는 rubber() 가 28px 에 가까워지도록 줄여서 고무줄처럼 당겨진다. 옮길 수 있는 메모 카드에 맞는다. 렌더 측정은 누적면적 54.5%, 움직인 프레임 23/23 이다.

<main class="stage">
<div class="pm is-demo" id="pm" data-demo="1">
<p class="pm__live" data-r="live" aria-live="off" aria-atomic="true"></p>
<i class="pm__bound" data-r="bound"></i>
<i class="pm__tether" data-r="tether"></i>
<div class="pm__pop" data-r="pop"><b class="pm__ttl">메모</b><span class="pm__txt">끌어서 옮기세요</span></div>
<button class="pm__anchor" data-r="anchor" type="button" aria-label="핀 · 방향키로 옮기기"></button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 허용 범위를 넘은 거리는 저항 계수로 줄이고 상한(LAG)에서 포화시켜, 앵커와 팝오버 사이 끈이 고무줄처럼 늘어난다.
@include stage($stage-ink);
.pm {
position: relative;
box-sizing: border-box;
overflow: hidden;
width: min(100%, 440px);
height: min(252px, calc(100vh - 2 * clamp(8px, 4vw, 24px)));
border-radius: $r-card;
background: rgba($subject-cream, .07);
color: $subject-cream;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.pm b { font-weight: 800; font-variant-numeric: tabular-nums; }
.pm__live {
position: absolute;
left: $sp-3;
top: $sp-2;
margin: 0;
z-index: 9;
color: $tick-mute;
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.pm button { font-family: inherit; }
.pm button:focus-visible { outline: 3px solid $subject-pink; outline-offset: 2px; }
.pm { touch-action: none; }
.pm__bound {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
border: 2px dashed rgba($subject-cream, .26);
border-radius: $r-control;
pointer-events: none;
}
.pm__tether {
position: absolute;
left: 0;
top: 0;
width: 1px;
height: 2px;
transform-origin: 0 50%;
background: $subject-pink;
opacity: .8;
pointer-events: none;
}
.pm__anchor {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
background: $subject-pink;
box-shadow: 0 0 0 4px rgba($subject-pink, .3), $shadow-press;
cursor: grab;
z-index: 5;
}
.pm__pop {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
width: 116px;
height: 56px;
padding: $sp-2 $sp-3;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
box-shadow: $shadow-raised;
z-index: 4;
}
.pm__ttl { display: block; font-size: 13px; line-height: 20px; }
.pm__txt { display: block; color: rgba($subject-ink, .72); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) {
.pm__anchor { cursor: default; }
}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);
}
.pm {
position: relative;
box-sizing: border-box;
overflow: hidden;
width: min(100%, 440px);
height: min(252px, 100vh - 2 * clamp(8px, 4vw, 24px));
border-radius: 16px;
background: rgba(255, 247, 230, 0.07);
color: #fff7e6;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.pm b {
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.pm__live {
position: absolute;
left: 12px;
top: 8px;
margin: 0;
z-index: 9;
color: #9a93a6;
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.pm button {
font-family: inherit;
}
.pm button:focus-visible {
outline: 3px solid #ff9ebb;
outline-offset: 2px;
}
.pm {
touch-action: none;
}
.pm__bound {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
border: 2px dashed rgba(255, 247, 230, 0.26);
border-radius: 12px;
pointer-events: none;
}
.pm__tether {
position: absolute;
left: 0;
top: 0;
width: 1px;
height: 2px;
transform-origin: 0 50%;
background: #ff9ebb;
opacity: 0.8;
pointer-events: none;
}
.pm__anchor {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
background: #ff9ebb;
box-shadow: 0 0 0 4px rgba(255, 158, 187, 0.3), 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: grab;
z-index: 5;
}
.pm__pop {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
width: 116px;
height: 56px;
padding: 8px 12px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
z-index: 4;
}
.pm__ttl {
display: block;
font-size: 13px;
line-height: 20px;
}
.pm__txt {
display: block;
color: rgba(23, 20, 26, 0.72);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.pm__anchor {
cursor: default;
}
}(function () {
const root = document.getElementById("pm");
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
let acc = 0;
let clock = 0;
const offs = [];
const live = root.querySelector("[data-r=live]");
function q(sel) {
return root.querySelector(sel);
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
function seg(t, a, b) {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => 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");
let shown = "";
function say(s) {
if (s === shown)
return;
shown = s;
live.textContent = s;
}
function makeRng(seed) {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function local(e) {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function sync() {
if (reduce) {
snap();
render();
}
}
// 기법: 팝오버 목표 위치가 허용 범위를 넘으면 넘은 거리에 저항(RUBBER)을 곱하고 LAG 에서 포화시킨 뒤, FOLLOW_LERP 관성으로 쫓아간다.
const FOLLOW_LERP = 0.14;
const RUBBER = 0.55;
const EDGE_PAD = 20;
const LAG = 28;
const POP_W = 116;
const POP_H = 56;
const ANCHOR_R = 14;
const LIFT = 10;
const SWEEP_MS = 2600;
const anchor = q("[data-r=anchor]");
const pop = q("[data-r=pop]");
const tether = q("[data-r=tether]");
const bound = q("[data-r=bound]");
let nx = reduce ? 0.94 : 0.5;
let ny = reduce ? 0.2 : 0.55;
let px = 0;
let py = 0;
let placed = false;
let dragging = false;
let overX = 0;
let overY = 0;
function rubber(over) {
const o = Math.abs(over);
const r = LAG * (1 - 1 / (1 + (o * RUBBER) / LAG));
return over < 0 ? -r : r;
}
function resist(v, lo, hi) {
if (v < lo)
return lo + rubber(v - lo);
if (v > hi)
return hi + rubber(v - hi);
return v;
}
function goal() {
const w = root.clientWidth;
const h = root.clientHeight;
const tx = nx * w - POP_W / 2;
const ty = ny * h - ANCHOR_R - LIFT - POP_H;
const lx = EDGE_PAD;
const hx = w - EDGE_PAD - POP_W;
const ly = EDGE_PAD;
const hy = h - EDGE_PAD - POP_H;
overX = tx < lx ? tx - lx : tx > hx ? tx - hx : 0;
overY = ty < ly ? ty - ly : ty > hy ? ty - hy : 0;
return [resist(tx, lx, hx), resist(ty, ly, hy)];
}
function tick(ms) {
if (demo) {
const t = Math.max(0, ms - 200);
nx = clamp(0.5 + 0.58 * Math.sin((t / SWEEP_MS) * Math.PI * 2), 0.06, 0.94);
ny = clamp(0.5 + 0.5 * Math.sin((t / 1700) * Math.PI * 2 + 1), 0.08, 0.94);
}
const g = goal();
if (!placed)
snap();
px += (g[0] - px) * FOLLOW_LERP;
py += (g[1] - py) * FOLLOW_LERP;
}
function snap() {
const g = goal();
px = g[0];
py = g[1];
placed = true;
}
function render() {
const w = root.clientWidth;
const h = root.clientHeight;
const ax = nx * w;
const ay = ny * h;
anchor.style.transform = "translate3d(" + (ax - ANCHOR_R).toFixed(1) + "px," + (ay - ANCHOR_R).toFixed(1) + "px,0)";
pop.style.transform = "translate3d(" + px.toFixed(1) + "px," + py.toFixed(1) + "px,0)";
const cx = px + POP_W / 2;
const cy = py + POP_H / 2;
const len = Math.hypot(cx - ax, cy - ay);
tether.style.transform = "translate3d(" + ax.toFixed(1) + "px," + (ay - 1).toFixed(1) + "px,0) rotate(" + Math.atan2(cy - ay, cx - ax).toFixed(4) + "rad) scale(" + len.toFixed(1) + ",1)";
bound.style.transform = "translate3d(" + EDGE_PAD + "px," + EDGE_PAD + "px,0)";
bound.style.width = Math.max(0, w - 2 * EDGE_PAD) + "px";
bound.style.height = Math.max(0, h - 2 * EDGE_PAD) + "px";
say("over " + Math.round(Math.hypot(overX, overY)) + " stretch " + Math.round(Math.hypot(rubber(overX), rubber(overY))));
}
function moveTo(e) {
const p = local(e);
nx = clamp(p.x / root.clientWidth, 0.04, 0.96);
ny = clamp(p.y / root.clientHeight, 0.06, 0.96);
sync();
}
on(root, "pointerdown", (e) => {
stopDemo();
dragging = true;
root.setPointerCapture(e.pointerId);
moveTo(e);
});
on(root, "pointermove", (e) => {
if (dragging)
moveTo(e);
});
on(root, "pointerup", () => {
dragging = false;
});
on(root, "pointercancel", () => {
dragging = false;
});
on(anchor, "keydown", (e) => {
const dx = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
const dy = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
if (dx === 0 && dy === 0)
return;
e.preventDefault();
stopDemo();
nx = clamp(nx + dx * 0.06, 0.04, 0.96);
ny = clamp(ny + dy * 0.08, 0.06, 0.96);
sync();
});
snap();
const FRAME_MS = 1000 / 60;
function frame(ts) {
if (t0 < 0) {
t0 = ts;
last = ts;
}
acc += Math.min(100, ts - last);
last = ts;
let n = 0;
while (acc >= FRAME_MS && n < 6) {
clock += FRAME_MS;
tick(clock);
acc -= FRAME_MS;
n += 1;
}
if (n === 6)
acc = 0;
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(() => sync());
ro.observe(root);
if (reduce) {
stopDemo();
snap();
render();
}
else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
ro.disconnect();
};
})();// RubberEdgeDrag - vanilla 판(05-pm-rubber-edge-drag) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./RubberEdgeDrag.module.scss";
export default function RubberEdgeDrag() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
let acc = 0;
let clock = 0;
const offs: Array<() => void> = [];
const live = root.querySelector("[data-r=live]") as HTMLElement;
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function seg(t: number, a: number, b: number): number {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el: EventTarget, type: string, fn: (e: any) => void): void {
el.addEventListener(type, fn);
offs.push(() => 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");
let shown = "";
function say(s: string): void {
if (s === shown) return;
shown = s;
live.textContent = s;
}
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function local(e: PointerEvent): { x: number; y: number } {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function sync(): void {
if (reduce) {
snap();
render();
}
}
// 기법: 팝오버 목표 위치가 허용 범위를 넘으면 넘은 거리에 저항(RUBBER)을 곱하고 LAG 에서 포화시킨 뒤, FOLLOW_LERP 관성으로 쫓아간다.
const FOLLOW_LERP = 0.14;
const RUBBER = 0.55;
const EDGE_PAD = 20;
const LAG = 28;
const POP_W = 116;
const POP_H = 56;
const ANCHOR_R = 14;
const LIFT = 10;
const SWEEP_MS = 2600;
const anchor = q<HTMLElement>("[data-r=anchor]");
const pop = q<HTMLElement>("[data-r=pop]");
const tether = q<HTMLElement>("[data-r=tether]");
const bound = q<HTMLElement>("[data-r=bound]");
let nx = reduce ? 0.94 : 0.5;
let ny = reduce ? 0.2 : 0.55;
let px = 0;
let py = 0;
let placed = false;
let dragging = false;
let overX = 0;
let overY = 0;
function rubber(over: number): number {
const o = Math.abs(over);
const r = LAG * (1 - 1 / (1 + (o * RUBBER) / LAG));
return over < 0 ? -r : r;
}
function resist(v: number, lo: number, hi: number): number {
if (v < lo) return lo + rubber(v - lo);
if (v > hi) return hi + rubber(v - hi);
return v;
}
function goal(): number[] {
const w = root.clientWidth;
const h = root.clientHeight;
const tx = nx * w - POP_W / 2;
const ty = ny * h - ANCHOR_R - LIFT - POP_H;
const lx = EDGE_PAD;
const hx = w - EDGE_PAD - POP_W;
const ly = EDGE_PAD;
const hy = h - EDGE_PAD - POP_H;
overX = tx < lx ? tx - lx : tx > hx ? tx - hx : 0;
overY = ty < ly ? ty - ly : ty > hy ? ty - hy : 0;
return [resist(tx, lx, hx), resist(ty, ly, hy)];
}
function tick(ms: number): void {
if (demo) {
const t = Math.max(0, ms - 200);
nx = clamp(0.5 + 0.58 * Math.sin((t / SWEEP_MS) * Math.PI * 2), 0.06, 0.94);
ny = clamp(0.5 + 0.5 * Math.sin((t / 1700) * Math.PI * 2 + 1), 0.08, 0.94);
}
const g = goal();
if (!placed) snap();
px += (g[0] - px) * FOLLOW_LERP;
py += (g[1] - py) * FOLLOW_LERP;
}
function snap(): void {
const g = goal();
px = g[0];
py = g[1];
placed = true;
}
function render(): void {
const w = root.clientWidth;
const h = root.clientHeight;
const ax = nx * w;
const ay = ny * h;
anchor.style.transform = "translate3d(" + (ax - ANCHOR_R).toFixed(1) + "px," + (ay - ANCHOR_R).toFixed(1) + "px,0)";
pop.style.transform = "translate3d(" + px.toFixed(1) + "px," + py.toFixed(1) + "px,0)";
const cx = px + POP_W / 2;
const cy = py + POP_H / 2;
const len = Math.hypot(cx - ax, cy - ay);
tether.style.transform = "translate3d(" + ax.toFixed(1) + "px," + (ay - 1).toFixed(1) + "px,0) rotate(" + Math.atan2(cy - ay, cx - ax).toFixed(4) + "rad) scale(" + len.toFixed(1) + ",1)";
bound.style.transform = "translate3d(" + EDGE_PAD + "px," + EDGE_PAD + "px,0)";
bound.style.width = Math.max(0, w - 2 * EDGE_PAD) + "px";
bound.style.height = Math.max(0, h - 2 * EDGE_PAD) + "px";
say("over " + Math.round(Math.hypot(overX, overY)) + " stretch " + Math.round(Math.hypot(rubber(overX), rubber(overY))));
}
function moveTo(e: PointerEvent): void {
const p = local(e);
nx = clamp(p.x / root.clientWidth, 0.04, 0.96);
ny = clamp(p.y / root.clientHeight, 0.06, 0.96);
sync();
}
on(root, "pointerdown", (e: PointerEvent) => {
stopDemo();
dragging = true;
root.setPointerCapture(e.pointerId);
moveTo(e);
});
on(root, "pointermove", (e: PointerEvent) => {
if (dragging) moveTo(e);
});
on(root, "pointerup", () => {
dragging = false;
});
on(root, "pointercancel", () => {
dragging = false;
});
on(anchor, "keydown", (e: KeyboardEvent) => {
const dx = e.key === "ArrowRight" ? 1 : e.key === "ArrowLeft" ? -1 : 0;
const dy = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
if (dx === 0 && dy === 0) return;
e.preventDefault();
stopDemo();
nx = clamp(nx + dx * 0.06, 0.04, 0.96);
ny = clamp(ny + dy * 0.08, 0.06, 0.96);
sync();
});
snap();
const FRAME_MS = 1000 / 60;
function frame(ts: number): void {
if (t0 < 0) {
t0 = ts;
last = ts;
}
acc += Math.min(100, ts - last);
last = ts;
let n = 0;
while (acc >= FRAME_MS && n < 6) {
clock += FRAME_MS;
tick(clock);
acc -= FRAME_MS;
n += 1;
}
if (n === 6) acc = 0;
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(() => sync());
ro.observe(root);
if (reduce) {
stopDemo();
snap();
render();
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
ro.disconnect();
};
}, []);
return (
<main className={styles["stage"]}>
<div ref={rootRef} className={styles["pm"] + " is-demo"} data-demo="1">
<p className={styles["pm__live"]} data-r="live" aria-live="off" aria-atomic="true"></p>
<i className={styles["pm__bound"]} data-r="bound"></i>
<i className={styles["pm__tether"]} data-r="tether"></i>
<div className={styles["pm__pop"]} data-r="pop"><b className={styles["pm__ttl"]}>메모</b><span className={styles["pm__txt"]}>끌어서 옮기세요</span></div>
<button className={styles["pm__anchor"]} data-r="anchor" type="button" aria-label="핀 · 방향키로 옮기기"></button>
</div>
</main>
);
}05 · 끌면 관성으로 따라오고 가장자리에서 고무줄처럼 버티는 팝오버 — 측정 누적면적 54.481% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function rubber(over) {
const o = Math.abs(over);
const r = LAG * (1 - 1 / (1 + (o * RUBBER) / LAG));
return over < 0 ? -r : r;
}
function resist(v, lo, hi) {
if (v < lo)
return lo + rubber(v - lo);
if (v > hi)
return hi + rubber(v - hi);
return v;
}
function goal() {
const w = root.clientWidth;
const h = root.clientHeight;
const tx = nx * w - POP_W / 2;
const ty = ny * h - ANCHOR_R - LIFT - POP_H;
const lx = EDGE_PAD;
const hx = w - EDGE_PAD - POP_W;
const ly = EDGE_PAD;
const hy = h - EDGE_PAD - POP_H;
overX = tx < lx ? tx - lx : tx > hx ? tx - hx : 0;
overY = ty < ly ? ty - ly : ty > hy ? ty - hy : 0;
return [resist(tx, lx, hx), resist(ty, ly, hy)];
}
06깊이가 한 칸 늘 때마다 그림자와 크기가 계단으로 변하는 중첩 팝오버
깊이 d 가 한 칸 늘면 크기가 0.07 씩, 밝기가 0.1 씩 줄고 그림자는 7px 씩 깊어진다. 설정 안의 설정 메뉴에 맞고, 1단은 크기 1.00·그림자 7px·밝기 1.00 에서 3단은 0.86·21px·0.80 까지 내려간다. 렌더 측정은 누적면적 23.1%, 움직인 프레임 16/23 이다.

<main class="stage">
<div class="pm is-demo" id="pm" data-demo="1">
<p class="pm__live" data-r="live" aria-live="off" aria-atomic="true"></p>
<button class="pm__open pm__open--a" data-r="op" type="button" aria-expanded="false">설정</button>
<div class="pm__pop" data-r="lv"><b class="pm__ttl">설정</b><button class="pm__open" data-r="op" type="button" aria-expanded="false">알림</button></div>
<div class="pm__pop" data-r="lv"><b class="pm__ttl">알림</b><button class="pm__open" data-r="op" type="button" aria-expanded="false">소리</button></div>
<div class="pm__pop" data-r="lv"><b class="pm__ttl">소리</b><span class="pm__txt">볼륨 · 진동</span></div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 중첩 깊이 d 를 세어 그림자 높이는 키우고(위로 뜬 것처럼) 크기와 밝기는 한 단계씩 줄여 위아래 관계를 보인다.
@include stage($stage-yellow);
.pm {
position: relative;
box-sizing: border-box;
overflow: hidden;
width: min(100%, 440px);
height: min(252px, calc(100vh - 2 * clamp(8px, 4vw, 24px)));
border-radius: $r-card;
background: rgba($subject-ink, .07);
color: $subject-ink;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.pm b { font-weight: 800; font-variant-numeric: tabular-nums; }
.pm__live {
position: absolute;
left: $sp-3;
top: $sp-2;
margin: 0;
z-index: 9;
color: rgba($subject-ink, .72);
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.pm button { font-family: inherit; }
.pm button:focus-visible { outline: 3px solid $subject-ink; outline-offset: 2px; }
.pm__open {
box-sizing: border-box;
height: 28px;
padding: 0 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-lilac;
color: $subject-ink;
font-size: 12px;
line-height: 16px;
font-weight: 700;
cursor: pointer;
}
.pm__open--a { position: absolute; left: 12px; top: 34px; width: 64px; padding: 0; background: $subject-pink; z-index: 8; }
.pm__open[aria-expanded="true"] { background: $subject-ink; color: $subject-cream; }
.pm__pop {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: $sp-1;
width: 128px;
height: 70px;
padding: $sp-3;
border-radius: $r-control;
background: $subject-cream;
color: $subject-ink;
transform-origin: 0 0;
visibility: hidden;
}
.pm__pop:nth-of-type(1) { z-index: 3; }
.pm__pop:nth-of-type(2) { z-index: 4; }
.pm__pop:nth-of-type(3) { z-index: 5; }
.pm__ttl { display: block; font-size: 13px; line-height: 20px; }
.pm__txt { display: block; color: rgba($subject-ink, .72); }
@media (prefers-reduced-motion: reduce) {
.pm__open { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.pm {
position: relative;
box-sizing: border-box;
overflow: hidden;
width: min(100%, 440px);
height: min(252px, 100vh - 2 * clamp(8px, 4vw, 24px));
border-radius: 16px;
background: rgba(23, 20, 26, 0.07);
color: #17141a;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.pm b {
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.pm__live {
position: absolute;
left: 12px;
top: 8px;
margin: 0;
z-index: 9;
color: rgba(23, 20, 26, 0.72);
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.pm button {
font-family: inherit;
}
.pm button:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
.pm__open {
box-sizing: border-box;
height: 28px;
padding: 0 12px;
border: 0;
border-radius: 999px;
background: #b9a0ff;
color: #17141a;
font-size: 12px;
line-height: 16px;
font-weight: 700;
cursor: pointer;
}
.pm__open--a {
position: absolute;
left: 12px;
top: 34px;
width: 64px;
padding: 0;
background: #ff9ebb;
z-index: 8;
}
.pm__open[aria-expanded=true] {
background: #17141a;
color: #fff7e6;
}
.pm__pop {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
width: 128px;
height: 70px;
padding: 12px;
border-radius: 12px;
background: #fff7e6;
color: #17141a;
transform-origin: 0 0;
visibility: hidden;
}
.pm__pop:nth-of-type(1) {
z-index: 3;
}
.pm__pop:nth-of-type(2) {
z-index: 4;
}
.pm__pop:nth-of-type(3) {
z-index: 5;
}
.pm__ttl {
display: block;
font-size: 13px;
line-height: 20px;
}
.pm__txt {
display: block;
color: rgba(23, 20, 26, 0.72);
}
@media (prefers-reduced-motion: reduce) {
.pm__open {
transition: none;
}
}(function () {
const root = document.getElementById("pm");
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
let acc = 0;
let clock = 0;
const offs = [];
const live = root.querySelector("[data-r=live]");
function q(sel) {
return root.querySelector(sel);
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
function seg(t, a, b) {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => 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");
let shown = "";
function say(s) {
if (s === shown)
return;
shown = s;
live.textContent = s;
}
function makeRng(seed) {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function local(e) {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function sync() {
if (reduce) {
snap();
render();
}
}
// 기법: 팝오버 안에서 또 열리는 팝오버의 깊이 d 를 세어, 그림자 높이는 d 에 비례해 키우고 크기·밝기는 d 가 늘 때마다 한 단계씩 줄인다.
const DEPTH_STEP = 0.1;
const SHADOW_LIFT = 7;
const SCALE_STEP = 0.07;
const STAGGER = 260;
const POP_W = 128;
const POP_H = 70;
const X0 = 84;
const Y0 = 34;
const STEP_Y = 28;
const CYCLE_MS = 3000;
const ops = Array.from(root.querySelectorAll("[data-r=op]"));
const lv = Array.from(root.querySelectorAll("[data-r=lv]"));
const openAmt = [0, 0, 0];
let depth = reduce ? 3 : 0;
let lastDepth = -1;
function script(ms) {
const t = ms % CYCLE_MS;
const base = 300;
depth = t < base ? 0 : t < base + STAGGER ? 1 : t < base + 2 * STAGGER ? 2 : t < 1900 ? 3 : t < 1900 + STAGGER ? 2 : t < 1900 + 2 * STAGGER ? 1 : 0;
}
function tick(ms) {
if (demo)
script(ms);
for (let i = 0; i < 3; i += 1)
openAmt[i] += ((depth > i ? 1 : 0) - openAmt[i]) * 0.22;
}
function snap() {
for (let i = 0; i < 3; i += 1)
openAmt[i] = depth > i ? 1 : 0;
}
function render() {
const w = root.clientWidth;
const stepX = clamp((w - X0 - POP_W - 8) / 2, 28, 84);
for (let i = 0; i < 3; i += 1) {
const d = i + 1;
const o = openAmt[i];
const sc = (1 - (d - 1) * SCALE_STEP) * (0.92 + 0.08 * o);
const lift = d * SHADOW_LIFT * o;
const x = X0 + i * stepX - (1 - o) * 14;
const y = Y0 + i * STEP_Y - (1 - o) * 8;
lv[i].style.transform = "translate3d(" + x.toFixed(1) + "px," + y.toFixed(1) + "px,0) scale(" + sc.toFixed(3) + ")";
lv[i].style.opacity = o.toFixed(2);
lv[i].style.visibility = o > 0.02 ? "visible" : "hidden";
lv[i].style.filter = "brightness(" + (1 - (d - 1) * DEPTH_STEP).toFixed(2) + ")";
lv[i].style.boxShadow = "0 " + lift.toFixed(1) + "px " + (lift * 2).toFixed(1) + "px rgba(23, 20, 26, .34)";
}
if (depth !== lastDepth) {
lastDepth = depth;
for (let i = 0; i < 3; i += 1)
ops[i].setAttribute("aria-expanded", depth > i ? "true" : "false");
}
const dd = Math.max(1, depth);
say("depth " + depth + " lift " + (dd * SHADOW_LIFT) + " scale " + (1 - (dd - 1) * SCALE_STEP).toFixed(2));
}
ops.forEach((el, i) => {
on(el, "click", () => {
stopDemo();
depth = depth > i ? i : i + 1;
sync();
});
});
on(root, "keydown", (e) => {
if (e.key !== "Escape" || depth === 0)
return;
e.preventDefault();
stopDemo();
depth -= 1;
ops[depth].focus({ preventScroll: true });
sync();
});
const FRAME_MS = 1000 / 60;
function frame(ts) {
if (t0 < 0) {
t0 = ts;
last = ts;
}
acc += Math.min(100, ts - last);
last = ts;
let n = 0;
while (acc >= FRAME_MS && n < 6) {
clock += FRAME_MS;
tick(clock);
acc -= FRAME_MS;
n += 1;
}
if (n === 6)
acc = 0;
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(() => sync());
ro.observe(root);
if (reduce) {
stopDemo();
snap();
render();
}
else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
ro.disconnect();
};
})();// NestedCascadeStack - vanilla 판(06-pm-nested-cascade-stack) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./NestedCascadeStack.module.scss";
export default function NestedCascadeStack() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
let acc = 0;
let clock = 0;
const offs: Array<() => void> = [];
const live = root.querySelector("[data-r=live]") as HTMLElement;
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function seg(t: number, a: number, b: number): number {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el: EventTarget, type: string, fn: (e: any) => void): void {
el.addEventListener(type, fn);
offs.push(() => 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");
let shown = "";
function say(s: string): void {
if (s === shown) return;
shown = s;
live.textContent = s;
}
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function local(e: PointerEvent): { x: number; y: number } {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function sync(): void {
if (reduce) {
snap();
render();
}
}
// 기법: 팝오버 안에서 또 열리는 팝오버의 깊이 d 를 세어, 그림자 높이는 d 에 비례해 키우고 크기·밝기는 d 가 늘 때마다 한 단계씩 줄인다.
const DEPTH_STEP = 0.1;
const SHADOW_LIFT = 7;
const SCALE_STEP = 0.07;
const STAGGER = 260;
const POP_W = 128;
const POP_H = 70;
const X0 = 84;
const Y0 = 34;
const STEP_Y = 28;
const CYCLE_MS = 3000;
const ops = Array.from(root.querySelectorAll("[data-r=op]")) as HTMLElement[];
const lv = Array.from(root.querySelectorAll("[data-r=lv]")) as HTMLElement[];
const openAmt = [0, 0, 0];
let depth = reduce ? 3 : 0;
let lastDepth = -1;
function script(ms: number): void {
const t = ms % CYCLE_MS;
const base = 300;
depth = t < base ? 0 : t < base + STAGGER ? 1 : t < base + 2 * STAGGER ? 2 : t < 1900 ? 3 : t < 1900 + STAGGER ? 2 : t < 1900 + 2 * STAGGER ? 1 : 0;
}
function tick(ms: number): void {
if (demo) script(ms);
for (let i = 0; i < 3; i += 1) openAmt[i] += ((depth > i ? 1 : 0) - openAmt[i]) * 0.22;
}
function snap(): void {
for (let i = 0; i < 3; i += 1) openAmt[i] = depth > i ? 1 : 0;
}
function render(): void {
const w = root.clientWidth;
const stepX = clamp((w - X0 - POP_W - 8) / 2, 28, 84);
for (let i = 0; i < 3; i += 1) {
const d = i + 1;
const o = openAmt[i];
const sc = (1 - (d - 1) * SCALE_STEP) * (0.92 + 0.08 * o);
const lift = d * SHADOW_LIFT * o;
const x = X0 + i * stepX - (1 - o) * 14;
const y = Y0 + i * STEP_Y - (1 - o) * 8;
lv[i].style.transform = "translate3d(" + x.toFixed(1) + "px," + y.toFixed(1) + "px,0) scale(" + sc.toFixed(3) + ")";
lv[i].style.opacity = o.toFixed(2);
lv[i].style.visibility = o > 0.02 ? "visible" : "hidden";
lv[i].style.filter = "brightness(" + (1 - (d - 1) * DEPTH_STEP).toFixed(2) + ")";
lv[i].style.boxShadow = "0 " + lift.toFixed(1) + "px " + (lift * 2).toFixed(1) + "px rgba(23, 20, 26, .34)";
}
if (depth !== lastDepth) {
lastDepth = depth;
for (let i = 0; i < 3; i += 1) ops[i].setAttribute("aria-expanded", depth > i ? "true" : "false");
}
const dd = Math.max(1, depth);
say("depth " + depth + " lift " + (dd * SHADOW_LIFT) + " scale " + (1 - (dd - 1) * SCALE_STEP).toFixed(2));
}
ops.forEach((el, i) => {
on(el, "click", () => {
stopDemo();
depth = depth > i ? i : i + 1;
sync();
});
});
on(root, "keydown", (e: KeyboardEvent) => {
if (e.key !== "Escape" || depth === 0) return;
e.preventDefault();
stopDemo();
depth -= 1;
ops[depth].focus({ preventScroll: true });
sync();
});
const FRAME_MS = 1000 / 60;
function frame(ts: number): void {
if (t0 < 0) {
t0 = ts;
last = ts;
}
acc += Math.min(100, ts - last);
last = ts;
let n = 0;
while (acc >= FRAME_MS && n < 6) {
clock += FRAME_MS;
tick(clock);
acc -= FRAME_MS;
n += 1;
}
if (n === 6) acc = 0;
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(() => sync());
ro.observe(root);
if (reduce) {
stopDemo();
snap();
render();
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
ro.disconnect();
};
}, []);
return (
<main className={styles["stage"]}>
<div ref={rootRef} className={styles["pm"] + " is-demo"} data-demo="1">
<p className={styles["pm__live"]} data-r="live" aria-live="off" aria-atomic="true"></p>
<button className={styles["pm__open"] + " " + styles["pm__open--a"]} data-r="op" type="button" aria-expanded="false">설정</button>
<div className={styles["pm__pop"]} data-r="lv"><b className={styles["pm__ttl"]}>설정</b><button className={styles["pm__open"]} data-r="op" type="button" aria-expanded="false">알림</button></div>
<div className={styles["pm__pop"]} data-r="lv"><b className={styles["pm__ttl"]}>알림</b><button className={styles["pm__open"]} data-r="op" type="button" aria-expanded="false">소리</button></div>
<div className={styles["pm__pop"]} data-r="lv"><b className={styles["pm__ttl"]}>소리</b><span className={styles["pm__txt"]}>볼륨 · 진동</span></div>
</div>
</main>
);
}06 · 깊이가 한 칸 늘 때마다 그림자와 크기가 계단으로 변하는 중첩 팝오버 — 측정 누적면적 23.144% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
for (let i = 0; i < 3; i += 1) {
const d = i + 1;
const o = openAmt[i];
const sc = (1 - (d - 1) * SCALE_STEP) * (0.92 + 0.08 * o);
const lift = d * SHADOW_LIFT * o;
const x = X0 + i * stepX - (1 - o) * 14;
const y = Y0 + i * STEP_Y - (1 - o) * 8;
lv[i].style.transform = "translate3d(" + x.toFixed(1) + "px," + y.toFixed(1) + "px,0) scale(" + sc.toFixed(3) + ")";
lv[i].style.opacity = o.toFixed(2);
lv[i].style.visibility = o > 0.02 ? "visible" : "hidden";
lv[i].style.filter = "brightness(" + (1 - (d - 1) * DEPTH_STEP).toFixed(2) + ")";
lv[i].style.boxShadow = "0 " + lift.toFixed(1) + "px " + (lift * 2).toFixed(1) + "px rgba(23, 20, 26, .34)";
}
07메뉴 항목 사이를 스프링으로 미끄러지는 강조 알약
알약의 y 와 h 가 각각 속도를 쥔 스프링(PILL_STIFF 0.18, PILL_DAMP 0.72)으로 가리킨 항목의 위치와 높이를 쫓고, 항목이 바뀌면 420ms 동안 빛줄기가 한 번 훑는다. 계정 드롭다운 메뉴에 맞는다. 렌더 측정은 누적면적 18.3%, 움직인 프레임 19/23 이다.

<main class="stage">
<div class="pm is-demo" id="pm" data-demo="1">
<p class="pm__live" data-r="live" aria-live="off" aria-atomic="true"></p>
<div class="pm__menu" data-r="menu">
<i class="pm__pill" data-r="pill"><i class="pm__sheen" data-r="sheen"></i></i>
<button class="pm__it" data-r="it" type="button">프로필</button>
<button class="pm__it" data-r="it" type="button">알림</button>
<button class="pm__it" data-r="it" type="button">요금</button>
<button class="pm__it pm__it--tall" data-r="it" type="button">로그아웃</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 알약은 항목마다 켜졌다 꺼지지 않고 하나가 위치와 높이를 스프링으로 따라 움직이며, 바뀔 때마다 빛줄기가 한 번 훑는다.
@include stage($stage-ink);
.pm {
position: relative;
box-sizing: border-box;
overflow: hidden;
width: min(100%, 440px);
height: min(252px, calc(100vh - 2 * clamp(8px, 4vw, 24px)));
border-radius: $r-card;
background: rgba($subject-cream, .07);
color: $subject-cream;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.pm b { font-weight: 800; font-variant-numeric: tabular-nums; }
.pm__live {
position: absolute;
left: $sp-3;
top: $sp-2;
margin: 0;
z-index: 9;
color: $tick-mute;
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.pm button { font-family: inherit; }
.pm button:focus-visible { outline: 3px solid $subject-pink; outline-offset: 2px; }
.pm__menu {
position: absolute;
left: 50%;
top: 22px;
box-sizing: border-box;
width: min(192px, calc(100% - 24px));
margin-left: calc(min(192px, 100% - 24px) / -2);
padding: $sp-1 $sp-2;
border-radius: $r-card;
background: $tick-panel;
box-shadow: $shadow-raised;
}
.pm__pill {
position: absolute;
left: $sp-2;
right: $sp-2;
top: 0;
height: 32px;
overflow: hidden;
border-radius: $r-control;
background: $subject-lilac;
will-change: transform;
}
.pm__sheen {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: linear-gradient(90deg, transparent, rgba($subject-cream, .7), transparent);
pointer-events: none;
}
.pm__it {
position: relative;
display: block;
box-sizing: border-box;
width: 100%;
height: 32px;
padding: 0 $sp-3;
border: 0;
border-radius: $r-control;
background: transparent;
color: $subject-cream;
font-size: 13px;
line-height: 20px;
font-weight: 700;
text-align: left;
cursor: pointer;
}
.pm__it--tall { height: 40px; }
.pm__it[data-on] { color: $subject-ink; }
@media (prefers-reduced-motion: reduce) {
.pm__pill { will-change: auto; }
.pm__sheen { display: none; }
}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);
}
.pm {
position: relative;
box-sizing: border-box;
overflow: hidden;
width: min(100%, 440px);
height: min(252px, 100vh - 2 * clamp(8px, 4vw, 24px));
border-radius: 16px;
background: rgba(255, 247, 230, 0.07);
color: #fff7e6;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.pm b {
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.pm__live {
position: absolute;
left: 12px;
top: 8px;
margin: 0;
z-index: 9;
color: #9a93a6;
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.pm button {
font-family: inherit;
}
.pm button:focus-visible {
outline: 3px solid #ff9ebb;
outline-offset: 2px;
}
.pm__menu {
position: absolute;
left: 50%;
top: 22px;
box-sizing: border-box;
width: min(192px, 100% - 24px);
margin-left: calc(min(192px, 100% - 24px) / -2);
padding: 4px 8px;
border-radius: 16px;
background: #231f28;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.pm__pill {
position: absolute;
left: 8px;
right: 8px;
top: 0;
height: 32px;
overflow: hidden;
border-radius: 12px;
background: #b9a0ff;
will-change: transform;
}
.pm__sheen {
position: absolute;
left: 0;
top: 0;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 247, 230, 0.7), transparent);
pointer-events: none;
}
.pm__it {
position: relative;
display: block;
box-sizing: border-box;
width: 100%;
height: 32px;
padding: 0 12px;
border: 0;
border-radius: 12px;
background: transparent;
color: #fff7e6;
font-size: 13px;
line-height: 20px;
font-weight: 700;
text-align: left;
cursor: pointer;
}
.pm__it--tall {
height: 40px;
}
.pm__it[data-on] {
color: #17141a;
}
@media (prefers-reduced-motion: reduce) {
.pm__pill {
will-change: auto;
}
.pm__sheen {
display: none;
}
}(function () {
const root = document.getElementById("pm");
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
let acc = 0;
let clock = 0;
const offs = [];
const live = root.querySelector("[data-r=live]");
function q(sel) {
return root.querySelector(sel);
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
function seg(t, a, b) {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => 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");
let shown = "";
function say(s) {
if (s === shown)
return;
shown = s;
live.textContent = s;
}
function makeRng(seed) {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function local(e) {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function sync() {
if (reduce) {
snap();
render();
}
}
// 기법: 강조 알약의 위치와 높이를 각각 스프링(PILL_STIFF, PILL_DAMP)으로 풀어 가리킨 항목을 쫓게 하고, 항목이 바뀔 때마다 SHEEN_W 폭의 빛줄기를 한 번 훑는다.
const PILL_STIFF = 0.18;
const PILL_DAMP = 0.72;
const SHEEN_W = 48;
const ITEM_H = 32;
const SHEEN_MS = 420;
const STEP_MS = 420;
const SEQ = [0, 2, 3, 1, 0];
const menu = q("[data-r=menu]");
const pill = q("[data-r=pill]");
const sheen = q("[data-r=sheen]");
const its = Array.from(root.querySelectorAll("[data-r=it]"));
let want = 0;
let prevWant = -1;
let py = 0;
let ph = ITEM_H;
let vy = 0;
let vh = 0;
let sheenT = SHEEN_MS;
let placed = false;
function goalY() {
return its[want].offsetTop;
}
function goalH() {
return its[want].offsetHeight || ITEM_H;
}
function tick(ms) {
if (demo)
want = SEQ[Math.floor(Math.max(0, ms - 200) / STEP_MS) % SEQ.length];
if (!placed)
snap();
if (want !== prevWant) {
prevWant = want;
sheenT = 0;
}
vy = (vy + (goalY() - py) * PILL_STIFF) * PILL_DAMP;
py += vy;
vh = (vh + (goalH() - ph) * PILL_STIFF) * PILL_DAMP;
ph += vh;
sheenT = Math.min(SHEEN_MS, sheenT + 1000 / 60);
}
function snap() {
py = goalY();
ph = goalH();
vy = 0;
vh = 0;
prevWant = want;
sheenT = SHEEN_MS;
placed = true;
}
function render() {
pill.style.height = ph.toFixed(1) + "px";
pill.style.transform = "translate3d(0," + py.toFixed(1) + "px,0)";
const wide = pill.clientWidth;
const p = sheenT / SHEEN_MS;
sheen.style.width = SHEEN_W + "px";
sheen.style.transform = "translate3d(" + (-SHEEN_W + p * (wide + SHEEN_W)).toFixed(1) + "px,0,0) skewX(-18deg)";
sheen.style.opacity = p >= 1 ? "0" : "1";
for (let i = 0; i < its.length; i += 1) {
if (i === want)
its[i].setAttribute("data-on", "1");
else
its[i].removeAttribute("data-on");
}
say("item " + (want + 1) + " y " + Math.round(py) + " h " + Math.round(ph));
}
function pick(i) {
stopDemo();
want = i;
sync();
}
its.forEach((el, i) => {
on(el, "pointerenter", () => pick(i));
on(el, "focus", () => pick(i));
on(el, "keydown", (e) => {
const d = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
if (d === 0)
return;
e.preventDefault();
const n = (i + d + its.length) % its.length;
its[n].focus({ preventScroll: true });
});
});
snap();
const FRAME_MS = 1000 / 60;
function frame(ts) {
if (t0 < 0) {
t0 = ts;
last = ts;
}
acc += Math.min(100, ts - last);
last = ts;
let n = 0;
while (acc >= FRAME_MS && n < 6) {
clock += FRAME_MS;
tick(clock);
acc -= FRAME_MS;
n += 1;
}
if (n === 6)
acc = 0;
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(() => sync());
ro.observe(root);
if (reduce) {
stopDemo();
snap();
render();
}
else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
ro.disconnect();
};
})();// RovingSpotlightMenu - vanilla 판(07-pm-menu-roving-spotlight) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./RovingSpotlightMenu.module.scss";
export default function RovingSpotlightMenu() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
let acc = 0;
let clock = 0;
const offs: Array<() => void> = [];
const live = root.querySelector("[data-r=live]") as HTMLElement;
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function seg(t: number, a: number, b: number): number {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el: EventTarget, type: string, fn: (e: any) => void): void {
el.addEventListener(type, fn);
offs.push(() => 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");
let shown = "";
function say(s: string): void {
if (s === shown) return;
shown = s;
live.textContent = s;
}
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function local(e: PointerEvent): { x: number; y: number } {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function sync(): void {
if (reduce) {
snap();
render();
}
}
// 기법: 강조 알약의 위치와 높이를 각각 스프링(PILL_STIFF, PILL_DAMP)으로 풀어 가리킨 항목을 쫓게 하고, 항목이 바뀔 때마다 SHEEN_W 폭의 빛줄기를 한 번 훑는다.
const PILL_STIFF = 0.18;
const PILL_DAMP = 0.72;
const SHEEN_W = 48;
const ITEM_H = 32;
const SHEEN_MS = 420;
const STEP_MS = 420;
const SEQ = [0, 2, 3, 1, 0];
const menu = q<HTMLElement>("[data-r=menu]");
const pill = q<HTMLElement>("[data-r=pill]");
const sheen = q<HTMLElement>("[data-r=sheen]");
const its = Array.from(root.querySelectorAll("[data-r=it]")) as HTMLElement[];
let want = 0;
let prevWant = -1;
let py = 0;
let ph = ITEM_H;
let vy = 0;
let vh = 0;
let sheenT = SHEEN_MS;
let placed = false;
function goalY(): number {
return its[want].offsetTop;
}
function goalH(): number {
return its[want].offsetHeight || ITEM_H;
}
function tick(ms: number): void {
if (demo) want = SEQ[Math.floor(Math.max(0, ms - 200) / STEP_MS) % SEQ.length];
if (!placed) snap();
if (want !== prevWant) {
prevWant = want;
sheenT = 0;
}
vy = (vy + (goalY() - py) * PILL_STIFF) * PILL_DAMP;
py += vy;
vh = (vh + (goalH() - ph) * PILL_STIFF) * PILL_DAMP;
ph += vh;
sheenT = Math.min(SHEEN_MS, sheenT + 1000 / 60);
}
function snap(): void {
py = goalY();
ph = goalH();
vy = 0;
vh = 0;
prevWant = want;
sheenT = SHEEN_MS;
placed = true;
}
function render(): void {
pill.style.height = ph.toFixed(1) + "px";
pill.style.transform = "translate3d(0," + py.toFixed(1) + "px,0)";
const wide = pill.clientWidth;
const p = sheenT / SHEEN_MS;
sheen.style.width = SHEEN_W + "px";
sheen.style.transform = "translate3d(" + (-SHEEN_W + p * (wide + SHEEN_W)).toFixed(1) + "px,0,0) skewX(-18deg)";
sheen.style.opacity = p >= 1 ? "0" : "1";
for (let i = 0; i < its.length; i += 1) {
if (i === want) its[i].setAttribute("data-on", "1");
else its[i].removeAttribute("data-on");
}
say("item " + (want + 1) + " y " + Math.round(py) + " h " + Math.round(ph));
}
function pick(i: number): void {
stopDemo();
want = i;
sync();
}
its.forEach((el, i) => {
on(el, "pointerenter", () => pick(i));
on(el, "focus", () => pick(i));
on(el, "keydown", (e: KeyboardEvent) => {
const d = e.key === "ArrowDown" ? 1 : e.key === "ArrowUp" ? -1 : 0;
if (d === 0) return;
e.preventDefault();
const n = (i + d + its.length) % its.length;
its[n].focus({ preventScroll: true });
});
});
snap();
const FRAME_MS = 1000 / 60;
function frame(ts: number): void {
if (t0 < 0) {
t0 = ts;
last = ts;
}
acc += Math.min(100, ts - last);
last = ts;
let n = 0;
while (acc >= FRAME_MS && n < 6) {
clock += FRAME_MS;
tick(clock);
acc -= FRAME_MS;
n += 1;
}
if (n === 6) acc = 0;
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(() => sync());
ro.observe(root);
if (reduce) {
stopDemo();
snap();
render();
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
ro.disconnect();
};
}, []);
return (
<main className={styles["stage"]}>
<div ref={rootRef} className={styles["pm"] + " is-demo"} data-demo="1">
<p className={styles["pm__live"]} data-r="live" aria-live="off" aria-atomic="true"></p>
<div className={styles["pm__menu"]} data-r="menu">
<i className={styles["pm__pill"]} data-r="pill"><i className={styles["pm__sheen"]} data-r="sheen"></i></i>
<button className={styles["pm__it"]} data-r="it" type="button">프로필</button>
<button className={styles["pm__it"]} data-r="it" type="button">알림</button>
<button className={styles["pm__it"]} data-r="it" type="button">요금</button>
<button className={styles["pm__it"] + " " + styles["pm__it--tall"]} data-r="it" type="button">로그아웃</button>
</div>
</div>
</main>
);
}07 · 메뉴 항목 사이를 스프링으로 미끄러지는 강조 알약 — 측정 누적면적 18.318% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function goalY() {
return its[want].offsetTop;
}
function goalH() {
return its[want].offsetHeight || ITEM_H;
}
function tick(ms) {
if (demo)
want = SEQ[Math.floor(Math.max(0, ms - 200) / STEP_MS) % SEQ.length];
if (!placed)
snap();
if (want !== prevWant) {
prevWant = want;
sheenT = 0;
}
vy = (vy + (goalY() - py) * PILL_STIFF) * PILL_DAMP;
py += vy;
vh = (vh + (goalH() - ph) * PILL_STIFF) * PILL_DAMP;
ph += vh;
sheenT = Math.min(SHEEN_MS, sheenT + 1000 / 60);
}
08앵커에서 번지는 노이즈 가장자리 원형 마스크
열릴 때 앵커 점에서 퍼지는 clip-path: polygon() 의 반지름을 각도마다 2차원 값 노이즈로 기준의 ±24% 안에서 흔들어서, 잉크가 번지듯 불규칙한 가장자리로 내용이 드러난다. 이미지 미리보기 팝업에 맞는다. 렌더 측정은 누적면적 33.9%, 움직인 프레임 14/23 이다.

<main class="stage">
<div class="pm is-demo" id="pm" data-demo="1">
<p class="pm__live" data-r="live" aria-live="off" aria-atomic="true"></p>
<i class="pm__ring" data-r="ring"></i>
<div class="pm__card" data-r="card"><i class="pm__sun"></i><i class="pm__hill"></i><b class="pm__ttl">미리보기</b></div>
<button class="pm__anchor" data-r="anchor" type="button" aria-expanded="false" aria-label="이미지 미리보기 열기"></button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 앵커 점에서 퍼지는 원형 마스크의 반지름을 각도마다 2차원 노이즈로 흔들어, 잉크가 번지듯 불규칙한 가장자리로 내용이 드러난다.
@include stage($stage-paper);
.pm {
position: relative;
box-sizing: border-box;
overflow: hidden;
width: min(100%, 440px);
height: min(252px, calc(100vh - 2 * clamp(8px, 4vw, 24px)));
border-radius: $r-card;
background: rgba($subject-ink, .07);
color: $subject-ink;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.pm b { font-weight: 800; font-variant-numeric: tabular-nums; }
.pm__live {
position: absolute;
left: $sp-3;
top: $sp-2;
margin: 0;
z-index: 9;
color: rgba($subject-ink, .72);
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.pm button { font-family: inherit; }
.pm button:focus-visible { outline: 3px solid $subject-ink; outline-offset: 2px; }
.pm__anchor {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
width: 44px;
height: 44px;
padding: 0;
border: 0;
border-radius: $r-control;
background: linear-gradient(145deg, $subject-sky, $subject-lilac);
box-shadow: $shadow-press;
cursor: pointer;
z-index: 5;
}
.pm__ring, .pm__card {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
border-radius: $r-card;
visibility: hidden;
}
.pm__ring { background: $subject-lilac; z-index: 3; }
.pm__card {
overflow: hidden;
background: linear-gradient(170deg, $subject-sky 0%, $subject-pink 62%, $subject-lilac 100%);
color: $subject-ink;
box-shadow: $shadow-raised;
z-index: 4;
}
.pm__sun {
position: absolute;
right: 18%;
top: 16%;
width: 36px;
height: 36px;
border-radius: 50%;
background: $subject-cream;
}
.pm__hill {
position: absolute;
left: -10%;
right: -10%;
bottom: -38%;
height: 70%;
border-radius: 50%;
background: $subject-mint;
}
.pm__ttl { position: absolute; left: $sp-3; bottom: $sp-3; font-size: 13px; line-height: 20px; }
@media (prefers-reduced-motion: reduce) {
.pm__anchor { cursor: default; }
}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);
}
.pm {
position: relative;
box-sizing: border-box;
overflow: hidden;
width: min(100%, 440px);
height: min(252px, 100vh - 2 * clamp(8px, 4vw, 24px));
border-radius: 16px;
background: rgba(23, 20, 26, 0.07);
color: #17141a;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.pm b {
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.pm__live {
position: absolute;
left: 12px;
top: 8px;
margin: 0;
z-index: 9;
color: rgba(23, 20, 26, 0.72);
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.pm button {
font-family: inherit;
}
.pm button:focus-visible {
outline: 3px solid #17141a;
outline-offset: 2px;
}
.pm__anchor {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
width: 44px;
height: 44px;
padding: 0;
border: 0;
border-radius: 12px;
background: linear-gradient(145deg, #6ec8ff, #b9a0ff);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: pointer;
z-index: 5;
}
.pm__ring, .pm__card {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
border-radius: 16px;
visibility: hidden;
}
.pm__ring {
background: #b9a0ff;
z-index: 3;
}
.pm__card {
overflow: hidden;
background: linear-gradient(170deg, #6ec8ff 0%, #ff9ebb 62%, #b9a0ff 100%);
color: #17141a;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
z-index: 4;
}
.pm__sun {
position: absolute;
right: 18%;
top: 16%;
width: 36px;
height: 36px;
border-radius: 50%;
background: #fff7e6;
}
.pm__hill {
position: absolute;
left: -10%;
right: -10%;
bottom: -38%;
height: 70%;
border-radius: 50%;
background: #4cd4a6;
}
.pm__ttl {
position: absolute;
left: 12px;
bottom: 12px;
font-size: 13px;
line-height: 20px;
}
@media (prefers-reduced-motion: reduce) {
.pm__anchor {
cursor: default;
}
}(function () {
const root = document.getElementById("pm");
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
let acc = 0;
let clock = 0;
const offs = [];
const live = root.querySelector("[data-r=live]");
function q(sel) {
return root.querySelector(sel);
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
function seg(t, a, b) {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => 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");
let shown = "";
function say(s) {
if (s === shown)
return;
shown = s;
live.textContent = s;
}
function makeRng(seed) {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function local(e) {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function sync() {
if (reduce) {
snap();
render();
}
}
// 기법: 앵커 점에서 퍼지는 원형 마스크의 반지름을 각도마다 2차원 값 노이즈로 흔들어 다각형 clip-path 로 만든다. 뒤에 RING 만큼 큰 같은 모양을 깔아 잉크 가장자리를 낸다.
const BLOOM_MS = 700;
const NOISE_AMP = 0.24;
const NOISE_FREQ = 2.2;
const RING = 10;
const POINTS = 48;
const CYCLE_MS = 3200;
const ANCHOR_SIZE = 44;
const anchor = q("[data-r=anchor]");
const card = q("[data-r=card]");
const ring = q("[data-r=ring]");
let want = reduce;
let p = 0;
let expanded = false;
let radius = 0;
let cw = 0;
let ch = 0;
let ox = 0;
let oy = 0;
function hash(ix, iy) {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
return (((h ^ (h >>> 16)) >>> 0) / 4294967296) * 2 - 1;
}
function noise2(x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = hash(ix, iy);
const b = hash(ix + 1, iy);
const c = hash(ix, iy + 1);
const d = hash(ix + 1, iy + 1);
return a + (b - a) * ux + (c - a) * uy + (a - b - c + d) * ux * uy;
}
function mask(r, extra) {
const pts = [];
for (let i = 0; i < POINTS; i += 1) {
const a = (i / POINTS) * Math.PI * 2;
const n = noise2(Math.cos(a) * NOISE_FREQ + 7 + p * 0.8, Math.sin(a) * NOISE_FREQ + 3);
const rr = Math.max(0, r * (1 + NOISE_AMP * n) + extra);
pts.push((ox + Math.cos(a) * rr).toFixed(1) + "px " + (oy + Math.sin(a) * rr).toFixed(1) + "px");
}
return "polygon(" + pts.join(",") + ")";
}
function layout() {
const w = root.clientWidth;
const h = root.clientHeight;
cw = Math.min(260, w - 96);
ch = Math.min(150, h - 28);
const ax = 20 + ANCHOR_SIZE / 2;
const ay = h / 2;
const cx = 80;
const cy = (h - ch) / 2;
ox = ax - cx;
oy = ay - cy;
for (const el of [card, ring]) {
el.style.width = cw + "px";
el.style.height = ch + "px";
el.style.transform = "translate3d(" + cx + "px," + cy.toFixed(1) + "px,0)";
}
anchor.style.transform = "translate3d(20px," + (ay - ANCHOR_SIZE / 2).toFixed(1) + "px,0)";
const far = Math.max(Math.hypot(ox, oy), Math.hypot(ox, oy - ch), Math.hypot(ox - cw, oy), Math.hypot(ox - cw, oy - ch));
radius = (far * 1.05) / (1 - NOISE_AMP);
}
function tick(ms) {
if (demo) {
const t = ms % CYCLE_MS;
want = t >= 250 && t < 1750;
}
const dp = 1000 / 60 / BLOOM_MS;
p = clamp(p + (want ? dp : -dp), 0, 1);
}
function snap() {
p = want ? 1 : 0;
}
function render() {
layout();
const e = 1 - Math.pow(1 - p, 3);
const r = radius * e;
const on = p > 0.004;
card.style.visibility = on ? "visible" : "hidden";
ring.style.visibility = on ? "visible" : "hidden";
if (on) {
card.style.clipPath = p >= 1 ? "none" : mask(r, 0);
ring.style.clipPath = p >= 1 ? "none" : mask(r, RING);
ring.style.opacity = p >= 1 ? "0" : "1";
}
if (want !== expanded) {
expanded = want;
anchor.setAttribute("aria-expanded", want ? "true" : "false");
}
say("r " + Math.round(r) + " noise " + Math.round(NOISE_AMP * 100) + "%");
}
function toggle() {
stopDemo();
want = !want;
sync();
}
on(anchor, "click", toggle);
on(root, "keydown", (e) => {
if (e.key !== "Escape" || !want)
return;
e.preventDefault();
stopDemo();
want = false;
anchor.focus({ preventScroll: true });
sync();
});
const FRAME_MS = 1000 / 60;
function frame(ts) {
if (t0 < 0) {
t0 = ts;
last = ts;
}
acc += Math.min(100, ts - last);
last = ts;
let n = 0;
while (acc >= FRAME_MS && n < 6) {
clock += FRAME_MS;
tick(clock);
acc -= FRAME_MS;
n += 1;
}
if (n === 6)
acc = 0;
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(() => sync());
ro.observe(root);
if (reduce) {
stopDemo();
snap();
render();
}
else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
ro.disconnect();
};
})();// InkBloomReveal - vanilla 판(08-pm-ink-bloom-reveal) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./InkBloomReveal.module.scss";
export default function InkBloomReveal() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
let acc = 0;
let clock = 0;
const offs: Array<() => void> = [];
const live = root.querySelector("[data-r=live]") as HTMLElement;
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function seg(t: number, a: number, b: number): number {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el: EventTarget, type: string, fn: (e: any) => void): void {
el.addEventListener(type, fn);
offs.push(() => 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");
let shown = "";
function say(s: string): void {
if (s === shown) return;
shown = s;
live.textContent = s;
}
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function local(e: PointerEvent): { x: number; y: number } {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function sync(): void {
if (reduce) {
snap();
render();
}
}
// 기법: 앵커 점에서 퍼지는 원형 마스크의 반지름을 각도마다 2차원 값 노이즈로 흔들어 다각형 clip-path 로 만든다. 뒤에 RING 만큼 큰 같은 모양을 깔아 잉크 가장자리를 낸다.
const BLOOM_MS = 700;
const NOISE_AMP = 0.24;
const NOISE_FREQ = 2.2;
const RING = 10;
const POINTS = 48;
const CYCLE_MS = 3200;
const ANCHOR_SIZE = 44;
const anchor = q<HTMLElement>("[data-r=anchor]");
const card = q<HTMLElement>("[data-r=card]");
const ring = q<HTMLElement>("[data-r=ring]");
let want = reduce;
let p = 0;
let expanded = false;
let radius = 0;
let cw = 0;
let ch = 0;
let ox = 0;
let oy = 0;
function hash(ix: number, iy: number): number {
let h = (Math.imul(ix, 374761393) + Math.imul(iy, 668265263)) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
return (((h ^ (h >>> 16)) >>> 0) / 4294967296) * 2 - 1;
}
function noise2(x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = hash(ix, iy);
const b = hash(ix + 1, iy);
const c = hash(ix, iy + 1);
const d = hash(ix + 1, iy + 1);
return a + (b - a) * ux + (c - a) * uy + (a - b - c + d) * ux * uy;
}
function mask(r: number, extra: number): string {
const pts: string[] = [];
for (let i = 0; i < POINTS; i += 1) {
const a = (i / POINTS) * Math.PI * 2;
const n = noise2(Math.cos(a) * NOISE_FREQ + 7 + p * 0.8, Math.sin(a) * NOISE_FREQ + 3);
const rr = Math.max(0, r * (1 + NOISE_AMP * n) + extra);
pts.push((ox + Math.cos(a) * rr).toFixed(1) + "px " + (oy + Math.sin(a) * rr).toFixed(1) + "px");
}
return "polygon(" + pts.join(",") + ")";
}
function layout(): void {
const w = root.clientWidth;
const h = root.clientHeight;
cw = Math.min(260, w - 96);
ch = Math.min(150, h - 28);
const ax = 20 + ANCHOR_SIZE / 2;
const ay = h / 2;
const cx = 80;
const cy = (h - ch) / 2;
ox = ax - cx;
oy = ay - cy;
for (const el of [card, ring]) {
el.style.width = cw + "px";
el.style.height = ch + "px";
el.style.transform = "translate3d(" + cx + "px," + cy.toFixed(1) + "px,0)";
}
anchor.style.transform = "translate3d(20px," + (ay - ANCHOR_SIZE / 2).toFixed(1) + "px,0)";
const far = Math.max(Math.hypot(ox, oy), Math.hypot(ox, oy - ch), Math.hypot(ox - cw, oy), Math.hypot(ox - cw, oy - ch));
radius = (far * 1.05) / (1 - NOISE_AMP);
}
function tick(ms: number): void {
if (demo) {
const t = ms % CYCLE_MS;
want = t >= 250 && t < 1750;
}
const dp = 1000 / 60 / BLOOM_MS;
p = clamp(p + (want ? dp : -dp), 0, 1);
}
function snap(): void {
p = want ? 1 : 0;
}
function render(): void {
layout();
const e = 1 - Math.pow(1 - p, 3);
const r = radius * e;
const on = p > 0.004;
card.style.visibility = on ? "visible" : "hidden";
ring.style.visibility = on ? "visible" : "hidden";
if (on) {
card.style.clipPath = p >= 1 ? "none" : mask(r, 0);
ring.style.clipPath = p >= 1 ? "none" : mask(r, RING);
ring.style.opacity = p >= 1 ? "0" : "1";
}
if (want !== expanded) {
expanded = want;
anchor.setAttribute("aria-expanded", want ? "true" : "false");
}
say("r " + Math.round(r) + " noise " + Math.round(NOISE_AMP * 100) + "%");
}
function toggle(): void {
stopDemo();
want = !want;
sync();
}
on(anchor, "click", toggle);
on(root, "keydown", (e: KeyboardEvent) => {
if (e.key !== "Escape" || !want) return;
e.preventDefault();
stopDemo();
want = false;
anchor.focus({ preventScroll: true });
sync();
});
const FRAME_MS = 1000 / 60;
function frame(ts: number): void {
if (t0 < 0) {
t0 = ts;
last = ts;
}
acc += Math.min(100, ts - last);
last = ts;
let n = 0;
while (acc >= FRAME_MS && n < 6) {
clock += FRAME_MS;
tick(clock);
acc -= FRAME_MS;
n += 1;
}
if (n === 6) acc = 0;
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(() => sync());
ro.observe(root);
if (reduce) {
stopDemo();
snap();
render();
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
ro.disconnect();
};
}, []);
return (
<main className={styles["stage"]}>
<div ref={rootRef} className={styles["pm"] + " is-demo"} data-demo="1">
<p className={styles["pm__live"]} data-r="live" aria-live="off" aria-atomic="true"></p>
<i className={styles["pm__ring"]} data-r="ring"></i>
<div className={styles["pm__card"]} data-r="card"><i className={styles["pm__sun"]}></i><i className={styles["pm__hill"]}></i><b className={styles["pm__ttl"]}>미리보기</b></div>
<button className={styles["pm__anchor"]} data-r="anchor" type="button" aria-expanded="false" aria-label="이미지 미리보기 열기"></button>
</div>
</main>
);
}08 · 앵커에서 번지는 노이즈 가장자리 원형 마스크 — 측정 누적면적 33.885% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function noise2(x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const ux = fx * fx * (3 - 2 * fx);
const uy = fy * fy * (3 - 2 * fy);
const a = hash(ix, iy);
const b = hash(ix + 1, iy);
const c = hash(ix, iy + 1);
const d = hash(ix + 1, iy + 1);
return a + (b - a) * ux + (c - a) * uy + (a - b - c + d) * ux * uy;
}
function mask(r, extra) {
const pts = [];
for (let i = 0; i < POINTS; i += 1) {
const a = (i / POINTS) * Math.PI * 2;
const n = noise2(Math.cos(a) * NOISE_FREQ + 7 + p * 0.8, Math.sin(a) * NOISE_FREQ + 3);
const rr = Math.max(0, r * (1 + NOISE_AMP * n) + extra);
pts.push((ox + Math.cos(a) * rr).toFixed(1) + "px " + (oy + Math.sin(a) * rr).toFixed(1) + "px");
}
return "polygon(" + pts.join(",") + ")";
}
09한 앵커 둘레에서 서로 밀어내며 자리 잡는 팝오버 고리
한 앵커 둘레 타원에 칩을 각도로 나눠 놓고 PULL 가속으로 끌어당기며, 사각형이 겹치면 겹침이 얕은 축으로 서로를 밀어낸다. 한 점에서 퍼지는 빠른 실행 메뉴에 맞는다. 렌더 측정은 누적면적 18.6%, 움직인 프레임 15/23 이다.

<main class="stage">
<div class="pm is-demo" id="pm" data-demo="1">
<p class="pm__live" data-r="live" aria-live="off" aria-atomic="true"></p>
<div class="pm__btns"><button class="pm__btn" data-r="again" type="button">다시 펼치기</button><button class="pm__btn" data-r="more" type="button">개수 바꾸기</button></div>
<i class="pm__hub" data-r="hub"></i>
<i class="pm__chip" data-r="chip">1</i><i class="pm__chip" data-r="chip">2</i><i class="pm__chip" data-r="chip">3</i><i class="pm__chip" data-r="chip">4</i><i class="pm__chip" data-r="chip">5</i><i class="pm__chip" data-r="chip">6</i><i class="pm__chip" data-r="chip">7</i>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 한 앵커 둘레에 같은 각도 간격으로 목표를 잡고, 사각형이 겹치면 서로 밀어내는 힘을 더해 몇 프레임 만에 겹침을 푼다.
@include stage($stage-ink);
.pm {
position: relative;
box-sizing: border-box;
overflow: hidden;
width: min(100%, 440px);
height: min(252px, calc(100vh - 2 * clamp(8px, 4vw, 24px)));
border-radius: $r-card;
background: rgba($subject-cream, .07);
color: $subject-cream;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.pm b { font-weight: 800; font-variant-numeric: tabular-nums; }
.pm__live {
position: absolute;
left: $sp-3;
top: $sp-2;
margin: 0;
z-index: 9;
color: $tick-mute;
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.pm button { font-family: inherit; }
.pm button:focus-visible { outline: 3px solid $subject-pink; outline-offset: 2px; }
.pm__btns {
position: absolute;
right: $sp-3;
top: $sp-2;
z-index: 8;
display: flex;
gap: $sp-1;
}
.pm__btn {
height: 24px;
padding: 0 $sp-3;
border: 0;
border-radius: $r-pill;
background: rgba($subject-cream, .14);
color: $subject-cream;
font-size: 12px;
line-height: 16px;
font-weight: 700;
cursor: pointer;
}
.pm__hub {
position: absolute;
left: 0;
top: 0;
width: 28px;
height: 28px;
border-radius: 50%;
background: $subject-sky;
box-shadow: 0 0 0 4px rgba($subject-sky, .3);
}
.pm__chip {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
height: 26px;
border-radius: $r-pill;
background: $subject-pink;
color: $subject-ink;
font-style: normal;
font-size: 12px;
line-height: 26px;
font-weight: 800;
text-align: center;
box-shadow: $shadow-press;
}
.pm__chip:nth-of-type(odd) { background: $subject-lilac; }
.pm__chip:nth-of-type(3n) { background: $subject-mint; }
@media (prefers-reduced-motion: reduce) {
.pm__btn { transition: none; }
}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);
}
.pm {
position: relative;
box-sizing: border-box;
overflow: hidden;
width: min(100%, 440px);
height: min(252px, 100vh - 2 * clamp(8px, 4vw, 24px));
border-radius: 16px;
background: rgba(255, 247, 230, 0.07);
color: #fff7e6;
font-size: 12px;
line-height: 16px;
font-weight: 600;
user-select: none;
}
.pm b {
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.pm__live {
position: absolute;
left: 12px;
top: 8px;
margin: 0;
z-index: 9;
color: #9a93a6;
font-variant-numeric: tabular-nums;
white-space: nowrap;
pointer-events: none;
}
.pm button {
font-family: inherit;
}
.pm button:focus-visible {
outline: 3px solid #ff9ebb;
outline-offset: 2px;
}
.pm__btns {
position: absolute;
right: 12px;
top: 8px;
z-index: 8;
display: flex;
gap: 4px;
}
.pm__btn {
height: 24px;
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;
cursor: pointer;
}
.pm__hub {
position: absolute;
left: 0;
top: 0;
width: 28px;
height: 28px;
border-radius: 50%;
background: #6ec8ff;
box-shadow: 0 0 0 4px rgba(110, 200, 255, 0.3);
}
.pm__chip {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
height: 26px;
border-radius: 999px;
background: #ff9ebb;
color: #17141a;
font-style: normal;
font-size: 12px;
line-height: 26px;
font-weight: 800;
text-align: center;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.pm__chip:nth-of-type(odd) {
background: #b9a0ff;
}
.pm__chip:nth-of-type(3n) {
background: #4cd4a6;
}
@media (prefers-reduced-motion: reduce) {
.pm__btn {
transition: none;
}
}(function () {
const root = document.getElementById("pm");
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
let acc = 0;
let clock = 0;
const offs = [];
const live = root.querySelector("[data-r=live]");
function q(sel) {
return root.querySelector(sel);
}
function clamp(v, lo, hi) {
return Math.max(lo, Math.min(hi, v));
}
function seg(t, a, b) {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el, type, fn) {
el.addEventListener(type, fn);
offs.push(() => 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");
let shown = "";
function say(s) {
if (s === shown)
return;
shown = s;
live.textContent = s;
}
function makeRng(seed) {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function local(e) {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function sync() {
if (reduce) {
snap();
render();
}
}
// 기법: 팝오버를 앵커 둘레에 같은 각도 간격으로 목표를 잡고 PULL 로 끌어당기되, 사각형이 겹치면 얕게 겹친 축으로 REPEL 만큼 서로 밀어낸다. 겹침이 풀리고 속도가 SETTLE 아래로 내려가면 자리가 잡힌 것이다.
const REPEL = 0.6;
const RADIUS = 72;
const SETTLE = 0.12;
const ANGLE_OFFSET = -1.2;
const PULL = 0.03;
const DAMP = 0.82;
const CHIP_H = 26;
const MAX_N = 7;
const CYCLE_MS = 3200;
const hub = q("[data-r=hub]");
const chips = Array.from(root.querySelectorAll("[data-r=chip]"));
const againBtn = q("[data-r=again]");
const moreBtn = q("[data-r=more]");
const cw = [];
const xs = [];
const ys = [];
const vxs = [];
const vys = [];
let count = 6;
let age = 0;
let restAt = -1;
let clearAt = -1;
let cycle = -2;
let ov = 0;
let diam = 0;
let spawned = false;
for (let i = 0; i < MAX_N; i += 1) {
cw.push(58 + ((i * 17) % 26));
xs.push(0);
ys.push(0);
vxs.push(0);
vys.push(0);
}
function spawn(seed) {
const rng = makeRng(seed + 11);
const w = root.clientWidth;
const h = root.clientHeight;
for (let i = 0; i < MAX_N; i += 1) {
const a = ANGLE_OFFSET + (i / count) * Math.PI * 2;
xs[i] = w / 2 + Math.cos(a) * 20 + (rng() - 0.5) * 10;
ys[i] = h / 2 + Math.sin(a) * 14 + (rng() - 0.5) * 10;
vxs[i] = 0;
vys[i] = 0;
}
age = 0;
restAt = -1;
clearAt = -1;
spawned = true;
}
function advance() {
const w = root.clientWidth;
const h = root.clientHeight;
const rx = Math.min(RADIUS * 1.45, w / 2 - 48);
const ry = Math.min(RADIUS, h / 2 - 24);
for (let i = 0; i < count; i += 1) {
const a = ANGLE_OFFSET + (i / count) * Math.PI * 2;
vxs[i] += (w / 2 + Math.cos(a) * rx - xs[i]) * PULL;
vys[i] += (h / 2 + Math.sin(a) * ry - ys[i]) * PULL;
}
ov = 0;
for (let i = 0; i < count; i += 1) {
for (let j = i + 1; j < count; j += 1) {
const dx = xs[j] - xs[i];
const dy = ys[j] - ys[i];
const gx = (cw[i] + cw[j]) / 2 - Math.abs(dx);
const gy = CHIP_H - Math.abs(dy);
if (gx <= 0 || gy <= 0)
continue;
ov += 1;
if (gx < gy) {
const f = gx * REPEL * 0.25 * (dx >= 0 ? 1 : -1);
vxs[i] -= f;
vxs[j] += f;
}
else {
const f = gy * REPEL * 0.25 * (dy >= 0 ? 1 : -1);
vys[i] -= f;
vys[j] += f;
}
}
}
let fast = 0;
let lo = 99999;
let hi = -99999;
for (let i = 0; i < count; i += 1) {
vxs[i] *= DAMP;
vys[i] *= DAMP;
xs[i] += vxs[i];
ys[i] += vys[i];
fast = Math.max(fast, Math.hypot(vxs[i], vys[i]));
lo = Math.min(lo, xs[i] - cw[i] / 2);
hi = Math.max(hi, xs[i] + cw[i] / 2);
}
diam = hi - lo;
age += 1;
if (clearAt < 0 && ov === 0)
clearAt = age;
if (restAt < 0 && ov === 0 && fast < SETTLE)
restAt = age;
}
function tick(ms) {
if (demo) {
const c = Math.floor((ms - 200) / CYCLE_MS);
if (c !== cycle && ms >= 200) {
cycle = c;
spawn(c);
}
}
if (!spawned)
spawn(0);
advance();
}
function snap() {
if (!spawned)
spawn(0);
for (let k = 0; k < 160; k += 1)
advance();
}
function render() {
const w = root.clientWidth;
const h = root.clientHeight;
hub.style.transform = "translate3d(" + (w / 2 - 14).toFixed(1) + "px," + (h / 2 - 14).toFixed(1) + "px,0)";
for (let i = 0; i < MAX_N; i += 1) {
const on = i < count;
chips[i].style.visibility = on ? "visible" : "hidden";
chips[i].style.width = cw[i] + "px";
chips[i].style.transform = "translate3d(" + (xs[i] - cw[i] / 2).toFixed(1) + "px," + (ys[i] - CHIP_H / 2).toFixed(1) + "px,0)";
}
say("overlap " + ov + " clear " + (clearAt < 0 ? "-" : clearAt + "f") + " rest " + (restAt < 0 ? "-" : restAt + "f") + " d " + Math.round(diam));
}
on(againBtn, "click", () => {
stopDemo();
spawn(age + count);
sync();
});
on(moreBtn, "click", () => {
stopDemo();
count = 4 + ((count - 3) % 4);
spawn(count);
sync();
});
const FRAME_MS = 1000 / 60;
function frame(ts) {
if (t0 < 0) {
t0 = ts;
last = ts;
}
acc += Math.min(100, ts - last);
last = ts;
let n = 0;
while (acc >= FRAME_MS && n < 6) {
clock += FRAME_MS;
tick(clock);
acc -= FRAME_MS;
n += 1;
}
if (n === 6)
acc = 0;
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(() => sync());
ro.observe(root);
if (reduce) {
stopDemo();
snap();
render();
}
else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
ro.disconnect();
};
})();// OrbitPopoverRing - vanilla 판(09-pm-orbit-popover-ring) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./OrbitPopoverRing.module.scss";
export default function OrbitPopoverRing() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let demo = !reduce;
let raf = 0;
let t0 = -1;
let last = -1;
let acc = 0;
let clock = 0;
const offs: Array<() => void> = [];
const live = root.querySelector("[data-r=live]") as HTMLElement;
function q<T extends Element>(sel: string): T {
return root.querySelector(sel) as T;
}
function clamp(v: number, lo: number, hi: number): number {
return Math.max(lo, Math.min(hi, v));
}
function seg(t: number, a: number, b: number): number {
const c = clamp((t - a) / (b - a), 0, 1);
return c * c * (3 - 2 * c);
}
function on(el: EventTarget, type: string, fn: (e: any) => void): void {
el.addEventListener(type, fn);
offs.push(() => 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");
let shown = "";
function say(s: string): void {
if (s === shown) return;
shown = s;
live.textContent = s;
}
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function local(e: PointerEvent): { x: number; y: number } {
const r = root.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
}
function sync(): void {
if (reduce) {
snap();
render();
}
}
// 기법: 팝오버를 앵커 둘레에 같은 각도 간격으로 목표를 잡고 PULL 로 끌어당기되, 사각형이 겹치면 얕게 겹친 축으로 REPEL 만큼 서로 밀어낸다. 겹침이 풀리고 속도가 SETTLE 아래로 내려가면 자리가 잡힌 것이다.
const REPEL = 0.6;
const RADIUS = 72;
const SETTLE = 0.12;
const ANGLE_OFFSET = -1.2;
const PULL = 0.03;
const DAMP = 0.82;
const CHIP_H = 26;
const MAX_N = 7;
const CYCLE_MS = 3200;
const hub = q<HTMLElement>("[data-r=hub]");
const chips = Array.from(root.querySelectorAll("[data-r=chip]")) as HTMLElement[];
const againBtn = q<HTMLElement>("[data-r=again]");
const moreBtn = q<HTMLElement>("[data-r=more]");
const cw: number[] = [];
const xs: number[] = [];
const ys: number[] = [];
const vxs: number[] = [];
const vys: number[] = [];
let count = 6;
let age = 0;
let restAt = -1;
let clearAt = -1;
let cycle = -2;
let ov = 0;
let diam = 0;
let spawned = false;
for (let i = 0; i < MAX_N; i += 1) {
cw.push(58 + ((i * 17) % 26));
xs.push(0);
ys.push(0);
vxs.push(0);
vys.push(0);
}
function spawn(seed: number): void {
const rng = makeRng(seed + 11);
const w = root.clientWidth;
const h = root.clientHeight;
for (let i = 0; i < MAX_N; i += 1) {
const a = ANGLE_OFFSET + (i / count) * Math.PI * 2;
xs[i] = w / 2 + Math.cos(a) * 20 + (rng() - 0.5) * 10;
ys[i] = h / 2 + Math.sin(a) * 14 + (rng() - 0.5) * 10;
vxs[i] = 0;
vys[i] = 0;
}
age = 0;
restAt = -1;
clearAt = -1;
spawned = true;
}
function advance(): void {
const w = root.clientWidth;
const h = root.clientHeight;
const rx = Math.min(RADIUS * 1.45, w / 2 - 48);
const ry = Math.min(RADIUS, h / 2 - 24);
for (let i = 0; i < count; i += 1) {
const a = ANGLE_OFFSET + (i / count) * Math.PI * 2;
vxs[i] += (w / 2 + Math.cos(a) * rx - xs[i]) * PULL;
vys[i] += (h / 2 + Math.sin(a) * ry - ys[i]) * PULL;
}
ov = 0;
for (let i = 0; i < count; i += 1) {
for (let j = i + 1; j < count; j += 1) {
const dx = xs[j] - xs[i];
const dy = ys[j] - ys[i];
const gx = (cw[i] + cw[j]) / 2 - Math.abs(dx);
const gy = CHIP_H - Math.abs(dy);
if (gx <= 0 || gy <= 0) continue;
ov += 1;
if (gx < gy) {
const f = gx * REPEL * 0.25 * (dx >= 0 ? 1 : -1);
vxs[i] -= f;
vxs[j] += f;
} else {
const f = gy * REPEL * 0.25 * (dy >= 0 ? 1 : -1);
vys[i] -= f;
vys[j] += f;
}
}
}
let fast = 0;
let lo = 99999;
let hi = -99999;
for (let i = 0; i < count; i += 1) {
vxs[i] *= DAMP;
vys[i] *= DAMP;
xs[i] += vxs[i];
ys[i] += vys[i];
fast = Math.max(fast, Math.hypot(vxs[i], vys[i]));
lo = Math.min(lo, xs[i] - cw[i] / 2);
hi = Math.max(hi, xs[i] + cw[i] / 2);
}
diam = hi - lo;
age += 1;
if (clearAt < 0 && ov === 0) clearAt = age;
if (restAt < 0 && ov === 0 && fast < SETTLE) restAt = age;
}
function tick(ms: number): void {
if (demo) {
const c = Math.floor((ms - 200) / CYCLE_MS);
if (c !== cycle && ms >= 200) {
cycle = c;
spawn(c);
}
}
if (!spawned) spawn(0);
advance();
}
function snap(): void {
if (!spawned) spawn(0);
for (let k = 0; k < 160; k += 1) advance();
}
function render(): void {
const w = root.clientWidth;
const h = root.clientHeight;
hub.style.transform = "translate3d(" + (w / 2 - 14).toFixed(1) + "px," + (h / 2 - 14).toFixed(1) + "px,0)";
for (let i = 0; i < MAX_N; i += 1) {
const on = i < count;
chips[i].style.visibility = on ? "visible" : "hidden";
chips[i].style.width = cw[i] + "px";
chips[i].style.transform = "translate3d(" + (xs[i] - cw[i] / 2).toFixed(1) + "px," + (ys[i] - CHIP_H / 2).toFixed(1) + "px,0)";
}
say("overlap " + ov + " clear " + (clearAt < 0 ? "-" : clearAt + "f") + " rest " + (restAt < 0 ? "-" : restAt + "f") + " d " + Math.round(diam));
}
on(againBtn, "click", () => {
stopDemo();
spawn(age + count);
sync();
});
on(moreBtn, "click", () => {
stopDemo();
count = 4 + ((count - 3) % 4);
spawn(count);
sync();
});
const FRAME_MS = 1000 / 60;
function frame(ts: number): void {
if (t0 < 0) {
t0 = ts;
last = ts;
}
acc += Math.min(100, ts - last);
last = ts;
let n = 0;
while (acc >= FRAME_MS && n < 6) {
clock += FRAME_MS;
tick(clock);
acc -= FRAME_MS;
n += 1;
}
if (n === 6) acc = 0;
render();
raf = requestAnimationFrame(frame);
}
const ro = new ResizeObserver(() => sync());
ro.observe(root);
if (reduce) {
stopDemo();
snap();
render();
} else {
raf = requestAnimationFrame(frame);
}
return () => {
cancelAnimationFrame(raf);
offs.forEach((f) => f());
ro.disconnect();
};
}, []);
return (
<main className={styles["stage"]}>
<div ref={rootRef} className={styles["pm"] + " is-demo"} data-demo="1">
<p className={styles["pm__live"]} data-r="live" aria-live="off" aria-atomic="true"></p>
<div className={styles["pm__btns"]}><button className={styles["pm__btn"]} data-r="again" type="button">다시 펼치기</button><button className={styles["pm__btn"]} data-r="more" type="button">개수 바꾸기</button></div>
<i className={styles["pm__hub"]} data-r="hub"></i>
<i className={styles["pm__chip"]} data-r="chip">1</i><i className={styles["pm__chip"]} data-r="chip">2</i><i className={styles["pm__chip"]} data-r="chip">3</i><i className={styles["pm__chip"]} data-r="chip">4</i><i className={styles["pm__chip"]} data-r="chip">5</i><i className={styles["pm__chip"]} data-r="chip">6</i><i className={styles["pm__chip"]} data-r="chip">7</i>
</div>
</main>
);
}09 · 한 앵커 둘레에서 서로 밀어내며 자리 잡는 팝오버 고리 — 측정 누적면적 18.613% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
ov = 0;
for (let i = 0; i < count; i += 1) {
for (let j = i + 1; j < count; j += 1) {
const dx = xs[j] - xs[i];
const dy = ys[j] - ys[i];
const gx = (cw[i] + cw[j]) / 2 - Math.abs(dx);
const gy = CHIP_H - Math.abs(dy);
if (gx <= 0 || gy <= 0)
continue;
ov += 1;
if (gx < gy) {
const f = gx * REPEL * 0.25 * (dx >= 0 ? 1 : -1);
vxs[i] -= f;
vxs[j] += f;
}
else {
const f = gy * REPEL * 0.25 * (dy >= 0 ? 1 : -1);
vys[i] -= f;
vys[j] += f;
}
}
}
값이 팝오버를 바꾸는 곳 — 스프링 강성·화살표 보간·척력
값 셋이 눈에 띄게 다르게 움직인다. 아래 숫자는 브라우저 렌더가 아니라 각 장면의 소스를 가짜 DOM 에서 직접 실행해 60Hz 틱으로 센 값이다.
강성 0.14 에서 0.32 로 — 빨라지지만 더 넘친다
02 의 STIFFNESS 를 올리면 열림이 시작된 뒤 0.95 에 닿는 때가 150ms 에서 100ms 로 당겨진다. 되돌리는 힘이 커진 만큼 목표를 지나치는 폭도 늘어 최대 scale 이 1.09 에서 1.26 이 되고, 흔들림이 1 의 0.02 안으로 가라앉기까지 걸리는 시간은 317ms 에서 367ms 로 늘어난다.
보간 0.15 에서 0.4 로 — 꼬리가 앵커를 거의 놓치지 않는다
04 의 ARROW_LERP 를 올리면 시연 2400ms 동안 화살표 끝과 앵커 중심의 최대 거리가 49px 에서 13px, 평균이 28.5px 에서 8.0px 로 줄어든다. 몸통의 BODY_LERP 가 0.35 라서 0.4 에서는 꼬리와 몸통이 거의 같은 속도가 되고 둘이 따로 움직이는 차이가 사라진다.
척력 0.6 에서 1.4 로 — 무대 크기에 따라 결과가 갈린다
09 의 REPEL 을 올리면 처음 겹친 11쌍이 풀리는 프레임이 440×252 무대에서는 7 에서 5 로 줄어든다. 폭 272px 의 좁은 무대에서는 같은 변경이 7 에서 11 로 늘고 자리가 잡히는 프레임도 50 에서 53 으로 늦어진다. 고리 전체 폭은 값과 상관없이 281px(좁은 무대 248px)로 같으니 지름은 RADIUS 가 정한다.
아홉 장면을 마지막으로 렌더해 얻은 측정은 아래 표에 모았다. 이 숫자는 480×300 캡처 24장에서 나왔다. 면적이 가장 큰 장면은 05 의 54.5%이고 가장 작은 장면은 07 의 18.3%이며, 움직인 프레임이 가장 적은 장면은 08 의 14/23 이다.
| 항목 | 누적면적 | 평균 강도 | 움직인 프레임 |
|---|---|---|---|
| 01 충돌 뒤집기 | 52.1% | 150.9 | 23/23 |
| 02 스프링 열림 | 29.8% | 84.4 | 16/23 |
| 03 안전 삼각형 | 34.0% | 76.4 | 18/23 |
| 04 화살표 보간 | 36.3% | 63.8 | 23/23 |
| 05 고무줄 끌기 | 54.5% | 170.4 | 23/23 |
| 06 중첩 계단 | 23.1% | 56.7 | 16/23 |
| 07 강조 알약 | 18.3% | 126.1 | 19/23 |
| 08 노이즈 마스크 | 33.9% | 70.7 | 14/23 |
| 09 팝오버 고리 | 18.6% | 82.9 | 15/23 |
어디서 깨지는가 — 팝오버가 겹겹일 때·좁은 화면·움직임 줄이기 설정
무대가 좁으면 가로 후보가 사라진다
01 의 가로 후보는 POP_W 116 에 GAP 12 를 더한 128px 이 필요한데, 폭 294px 무대에서 앵커가 한가운데 있으면 좌우 여유가 125px 이라 위와 아래만 후보로 남는다.
버티는 폭이 없으면 방향이 오갈 수 있다
FLIP_BIAS 를 0 으로 두면 지금 쪽이 최고 점수보다 조금이라도 낮아지는 순간 방향을 바꾸는 코드가 되어, 두 점수가 엇비슷한 자리를 앵커가 지날 때 방향이 오갈 수 있다. 이 부분은 조건식을 읽고 내린 추론이고 0 으로 돌려서 확인하지는 않았다.
스프링이 넘치면 패널이 목표보다 커진다
02 의 오버슛은 최대 1.09 라서 폭 156px 패널이 잠깐 약 170px 까지 커지고, PAD 는 목표 크기 1 기준의 위치만 막는다. 변형 기준점은 440 폭에서 슬롯마다 ORIGIN_X 62.4·78.0·93.6, ORIGIN_Y 는 늘 -24 이고, 패널이 가장자리에 밀려도 기준점은 앵커에 남는다.
삼각형은 6틱 전 위치에서 시작한다
03 의 꼭짓점은 앵커가 아니라 포인터의 6틱 전 위치라서 멈춰 있으면 삼각형이 포인터에서 시작한다. 시연의 포인터는 최고 약 267px/s 로 지나가는데 SPEED_MIN 을 60 에서 400 으로 올리면 속도가 모자라 항목 2 가 바로 열려, 전환이 1517ms 한 번에서 483ms 와 1517ms 두 번으로 늘어난다. 닫힘은 포인터가 메뉴 왼쪽으로 나갈 때만 일어난다.
화살표 끝은 몸통 안에서만 움직인다
04 의 끝은 몸통 안 14~154px 로 잘리므로 앵커가 몸통 밖으로 더 나가면 끝이 몸통 모서리에 붙는다. 앵커 범위를 SHIFT_MAX 로 묶은 것이 그래서다.
늘어난 팝오버가 무대 밖으로 8px 잘린다
05 에서 허용 범위를 넘은 거리가 10·40·100·300px 일 때 늘어나는 길이는 4.6·12.3·18.6·23.9px 로 28px 에 가까워지며 눕는다. LAG 28 이 EDGE_PAD 20 보다 8 커서, 최대로 늘어나면 팝오버 8px 가 무대 밖으로 나가 잘린다.
밝기 필터는 글자까지 어둡게 한다
06 은 깊이에 따라 filter: brightness() 를 쓰므로 팝오버 안 글자도 같이 어두워지고 3단은 0.80 이다. 폭 294px 에서는 단 사이 가로 간격 stepX 가 37px 로 줄어 계단이 좁아진다.
알약은 목표 높이를 넘었다 돌아온다
07 에서 알약이 높이 32 에서 40 으로 갈 때 최대 41.9 까지 늘었다가 돌아오고, 위치가 4 에서 100 으로 갈 때는 최대 122.8 로 변화량의 약 24% 를 넘는다. 둘 다 소스를 직접 실행해 센 값이다.
노이즈 마스크는 매 프레임 문자열을 만든다
08 은 hash() 가 −1~1 을 돌려주므로 꼭짓점 반지름이 그 프레임 기준(r)의 0.76배에서 1.24배 사이를 오간다. 그래서 목표 radius 를 가장 먼 모서리 거리의 1.05배에 1 - NOISE_AMP 를 나눈 값으로 잡아, 움푹 들어간 자리에도 구석이 마지막까지 남지 않게 했다. 매 프레임 48점짜리 문자열을 두 레이어 만들며, 열림 비율 p 가 1 이 되면 마스크를 풀어 비용을 멈춘다.
좁은 무대에서는 칩이 더 쉽게 겹친다
09 의 목표 타원 가로 반지름은 min(RADIUS * 1.45, w / 2 - 48) 이라 폭 272px 에서는 88px 로 줄어, 440px 의 104.4px 보다 인접 칩이 가까워진다.
아홉 장면을 통째로 받으려면 아래 카드의 압축을 비밀번호 get7ygpn 로 열면 된다. 소스와 리액트 판이 함께 들어 있다.
변형 세 가지와 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 스프링 강성 바꾸기 | 02 STIFFNESS 0.14 → 0.32 | 강성을 0.14 에서 0.32 로 올리면 열림 시작 뒤 95% 에 닿는 데 걸리는 시간이 150ms 에서 100ms 로 줄지만, 목표를 넘는 폭이 9% 에서 26% 로 커지고 흔들림이 가라앉기까지는 317ms 에서 367ms 로 길어진다. |
| 화살표 보간 바꾸기 | 04 ARROW_LERP 0.15 → 0.4 | 보간 계수를 0.15 에서 0.4 로 올리면 화살표 끝과 앵커 중심 사이의 최대 거리가 49px 에서 13px 로, 평균이 28.5px 에서 8px 로 줄어 꼬리가 앵커를 거의 놓치지 않는다. 몸통의 BODY_LERP(0.35)와 거의 같아져 꼬리와 몸통이 따로 움직이는 차이도 줄어든다. |
| 밀어내는 힘 바꾸기 | 09 REPEL 0.6 → 1.4 | 척력을 0.6 에서 1.4 로 올리면 처음 겹친 11쌍이 풀리는 데 걸리는 프레임이 440×252 무대에서는 7 에서 5 로 줄지만 272×174 에서는 7 에서 11 로 늘고, 자리가 잡히는 프레임도 50 에서 53 으로 늦어진다. 고리 전체 폭은 둘 다 같다(281px, 좁은 무대 248px) — 지름은 RADIUS 가 정한다. |
세 변형의 숫자는 위에서 밝힌 대로 소스를 가짜 DOM 에서 직접 실행해 잰 값이다.
조작은 아홉 장면 모두 <button> 이나 포인터로 가능하고, keydown 은 09 를 뺀 여덟 장면에 있다. 01·04·05 는 앵커에 포커스를 두고 방향키로 옮기고(01·05 는 네 방향, 04 는 좌우), 07 은 위·아래 방향키로 항목 포커스를 옮기며 맨 끝에서 처음으로 돈다. Esc 로 닫히는 것은 02·03·06·08 넷뿐이고, 02·08 은 닫은 뒤 앵커로 포커스를 돌려주며 06 은 그 단을 연 버튼으로 돌려주고 03 은 닫기만 한다. aria-expanded 도 02·03·06·08 만 갱신한다.
setPointerCapture 와 touch-action: none 은 끌기가 있는 01·04·05 에만 있다. 다른 여섯 장면은 iframe 안에서 터치 스크롤을 막지 않는다. 상태 줄 aria-live 는 아홉 장면 모두 한 줄씩 두었고 시연 중에는 off, 사용자가 만지거나 움직임 줄이기 설정이 켜지면 polite 가 된다.
prefers-reduced-motion 이 켜지면 아홉 장면 모두 시연과 애니메이션 루프를 시작하지 않고 결과 배치를 한 번 그린다. 01 은 뒤집힌 위치, 02·08 은 열린 상태, 04·05 는 오른쪽 끝, 06 은 3단 전부 열린 상태, 09 는 160틱을 미리 돌려 자리가 잡힌 상태로 멈춘다.
css popover animation 만으로 충분한가?
열릴 때 크기와 투명도가 변하는 효과는 02 의 스프링이나 08 의 마스크처럼 몇 줄이면 되지만, 팝오버가 화면 끝에서 어느 쪽으로 뜨는지는 앵커와 네 변의 거리를 알아야 정해진다. 그래서 01·04·05 는 움직임보다 먼저 자리를 구하는 계산이 코드의 중심이다.
HTML 의 popover 속성을 쓸지 <div> 를 직접 옮길지는 열림 상태를 누가 관리하느냐의 선택이고, 위치를 어떻게 정하는지는 따로 있는 규칙이다. 이 편은 그 규칙 쪽을 아홉 가지로 풀었다. 접히는 단이 많아지면 06, 항목이 여럿이면 07, 칩이 겹치면 09 처럼 장면 하나가 한 가지 골칫거리를 맡는다.
css popover on hover 예제를 가져다 쓰기 전에 자주 묻는 질문
css popover anchor positioning 없이 위치를 직접 계산해도 되나?
된다. 아홉 장면이 그렇게 돌아간다. 앵커의 좌표와 무대 크기에서 translate3d 값을 구하고, ResizeObserver 가 폭이 바뀔 때 다시 그리게 한다. 위치는 nx·ny 같은 정규화 좌표나 매 렌더 clientWidth 에서 다시 계산해 옛 픽셀 값이 남지 않는다.
css popover menu 를 07 처럼 만들면 키보드로도 되나?
된다. 항목마다 pointerenter 와 focus 가 알약의 목표를 정하고 위·아래 방향키가 포커스를 옮기며, Tab·Enter·Space 는 막지 않아서 브라우저 기본 동작이 그대로 쓰인다.
라이브러리나 외부 요청이 필요한가?
필요 없다. 아홉 파일에는 script src·import·fetch 가 없고 한 장면의 index.html 과 style.css 만 옮겨도 돌아간다. 화면 문구는 zip 의 로케일판마다 이미 그 언어로 바뀌어 들어 있어서 따로 불러올 파일이 없다.









