02앵커 점에서 스프링으로 부풀어 열리는 팝오버
변형 기준점을 앵커 중심으로 계산하고 크기를 스프링으로 풀어, 목표를 살짝 넘었다가 돌아오며 앵커에서 자라난 것처럼 보인다.
쓰임버튼 옆 빠른 설정 패널
STIFFNESSDAMPINGORIGIN_XORIGIN_Y

<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 포함 · css popover 9가지 — 앵커에서 열리고 가장자리에서 뒤집히는 팝오버
이 데모가 속한 글
css popover 9가지 — 앵커에서 열리고 가장자리에서 뒤집히는 팝오버css popover 가 화면 끝에서 잘리고 화살표가 엉뚱한 곳을 가리키고 여럿이 겹친다 — 01번은 여유 공간 계산, 04번은 화살표 보간, 09번은 척력으로 푼다. 이 글의 데모 전부 보기


