06深さが一段増えるごとに影と大きさが階段状に変わる入れ子ポップオーバー
ポップオーバーの中でさらに開くポップオーバーの深さを数え、影の高さを上げ、大きさと明るさを一段ずつ下げて上下関係を見せる。
使いどころ設定の中の設定メニュー
DEPTH_STEPSHADOW_LIFTSCALE_STEPSTAGGER

<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 オリジナル · SCSS と React は zip に同梱 · css popover 9パターン — アンカーから開き端で反転するポップオーバー
このデモが属する記事
css popover 9パターン — アンカーから開き端で反転するポップオーバーcss popover は画面の端で切れ、矢印が違う方を指し、複数が重なる — 01番は空きの計算、04番は矢印の補間、09番は反発で解く。九つの場面を画面で開き、コードと測定値まで確かめられます。 この記事のデモをすべて見る


