06Nested Popovers Whose Shadow and Size Step Up with Each Depth
The depth of each popover opened inside another is counted, shadow height rises while size and brightness step down, so the stacking order reads at a glance.
Best forSettings inside settings
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">Settings</button>
<div class="pm__pop" data-r="lv"><b class="pm__ttl">Settings</b><button class="pm__open" data-r="op" type="button" aria-expanded="false">Alerts</button></div>
<div class="pm__pop" data-r="lv"><b class="pm__ttl">Alerts</b><button class="pm__open" data-r="op" type="button" aria-expanded="false">Sound</button></div>
<div class="pm__pop" data-r="lv"><b class="pm__ttl">Sound</b><span class="pm__txt">Volume, vibrate</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 Nested Popovers Whose Shadow and Size Step Up with Each Depth — Measured area 23.144% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · CSS Popover: 9 Ways to Anchor, Flip and Spring
The post this demo lives in
CSS Popover: 9 Ways to Anchor, Flip and SpringA css popover can clip at screen edges, point its arrow the wrong way and overlap its neighbors — scenes 01, 04 and 09 f See all demos in this post
If you like this effect - similar demos
Interactions07 Floating Cards Whose Shadow Grows With Their Height9 CSS 3D Background Animation Depth Layers
Interactions04 Card Stack With Perspective DepthCard Swipe UI: 9 Physics-Based Deck Patterns
Interactions01 A Wireframe Polyhedron That Eases Toward the Pointer and Fades With DepthJavaScript 3D Animation: 9 Pointer-Led Models