03A Safe Triangle That Delays Closing by Pointer Direction
A triangle is drawn from where the pointer was a few frames ago to the two near corners of the popover, and while the pointer crosses it toward the popover above a minimum speed, touching another item does not switch the popover.
Best forHover-opened sub menus
INTENT_MSTRIANGLE_PADSPEED_MINGRACE

<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">Profile</button><button class="pm__item" data-r="item" type="button" aria-haspopup="true" aria-expanded="false">Alerts</button><button class="pm__item" data-r="item" type="button" aria-haspopup="true" aria-expanded="false">Security</button></div>
<div class="pm__sub" data-r="sub"><b class="pm__ttl">Profile</b><span class="pm__r">Open</span><span class="pm__r">Pin</span><span class="pm__r">Delete</span></div><div class="pm__sub" data-r="sub"><b class="pm__ttl">Alerts</b><span class="pm__r">Open</span><span class="pm__r">Pin</span><span class="pm__r">Delete</span></div><div class="pm__sub" data-r="sub"><b class="pm__ttl">Security</b><span class="pm__r">Open</span><span class="pm__r">Pin</span><span class="pm__r">Delete</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 A Safe Triangle That Delays Closing by Pointer Direction — Measured area 33.965% · moving frames 18/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
Interactions01 Product mockup that turns with the pointer3D Hover Effect CSS: 9 Pointer-Following Scenes
Interactions05 Pointer parallax — the math behind a diorama that gently followsIsometric CSS: 9 True-Projection 3D Dioramas
Interactions01 A Wireframe Polyhedron That Eases Toward the Pointer and Fades With DepthJavaScript 3D Animation: 9 Pointer-Led Models