GODRICH
#css애니메이션#버튼#로딩#전환효과#스크롤#대시보드#실전ui#디자인트렌드

css popover 9가지 — 앵커에서 열리고 가장자리에서 뒤집히는 팝오버

css popover 가 화면 끝에서 잘리고 화살표가 엉뚱한 곳을 가리키고 여럿이 겹친다 — 01번은 여유 공간 계산, 04번은 화살표 보간, 09번은 척력으로 푼다.

아홉 가지 팝오버를 화면에서 바로 열어 본다

자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip

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 이다.

네 방향 여유 공간을 재서 자리를 고르는 충돌 뒤집기
새 창에서 열기

01 · 네 방향 여유 공간을 재서 자리를 고르는 충돌 뒤집기 — 측정 누적면적 52.147% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

GAPEDGE_PADARROW_SIZEFLIP_BIAS
    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 이다.

앵커 점에서 스프링으로 부풀어 열리는 팝오버
새 창에서 열기

02 · 앵커 점에서 스프링으로 부풀어 열리는 팝오버 — 측정 누적면적 29.803% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

STIFFNESSDAMPINGORIGIN_XORIGIN_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;
    }

03포인터 방향으로 닫힘을 미루는 안전 삼각형

6틱 전 포인터 위치에서 열린 패널의 왼쪽 두 모서리로 삼각형을 긋고, 포인터가 SPEED_MIN 이상의 속도로 오른쪽 삼각형 안을 지나는 동안에는 다른 항목에 닿아도 INTENT_MS 260ms 가 지나기 전엔 전환하지 않는다. 호버로 열리는 서브 메뉴에 맞는다. 렌더 측정은 누적면적 34.0%, 움직인 프레임 18/23 이다.

포인터 방향으로 닫힘을 미루는 안전 삼각형
새 창에서 열기

03 · 포인터 방향으로 닫힘을 미루는 안전 삼각형 — 측정 누적면적 33.965% · 움직인 프레임 18/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

INTENT_MSTRIANGLE_PADSPEED_MINGRACE
    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 이다.

몸통이 밀려도 앵커를 가리키는 화살표
새 창에서 열기

04 · 몸통이 밀려도 앵커를 가리키는 화살표 — 측정 누적면적 36.272% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

ARROW_LERPEDGE_PADARROW_SIZESHIFT_MAX
    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 이다.

끌면 관성으로 따라오고 가장자리에서 고무줄처럼 버티는 팝오버
새 창에서 열기

05 · 끌면 관성으로 따라오고 가장자리에서 고무줄처럼 버티는 팝오버 — 측정 누적면적 54.481% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

FOLLOW_LERPRUBBEREDGE_PADLAG
    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 이다.

깊이가 한 칸 늘 때마다 그림자와 크기가 계단으로 변하는 중첩 팝오버
새 창에서 열기

06 · 깊이가 한 칸 늘 때마다 그림자와 크기가 계단으로 변하는 중첩 팝오버 — 측정 누적면적 23.144% · 움직인 프레임 16/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

DEPTH_STEPSHADOW_LIFTSCALE_STEPSTAGGER
        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 이다.

메뉴 항목 사이를 스프링으로 미끄러지는 강조 알약
새 창에서 열기

07 · 메뉴 항목 사이를 스프링으로 미끄러지는 강조 알약 — 측정 누적면적 18.318% · 움직인 프레임 19/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

PILL_STIFFPILL_DAMPSHEEN_WITEM_H
    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 이다.

앵커에서 번지는 노이즈 가장자리 원형 마스크
새 창에서 열기

08 · 앵커에서 번지는 노이즈 가장자리 원형 마스크 — 측정 누적면적 33.885% · 움직인 프레임 14/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

BLOOM_MSNOISE_AMPNOISE_FREQRING
    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 이다.

한 앵커 둘레에서 서로 밀어내며 자리 잡는 팝오버 고리
새 창에서 열기

09 · 한 앵커 둘레에서 서로 밀어내며 자리 잡는 팝오버 고리 — 측정 누적면적 18.613% · 움직인 프레임 15/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기

REPELRADIUSSETTLEANGLE_OFFSET
        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 의 로케일판마다 이미 그 언어로 바뀌어 들어 있어서 따로 불러올 파일이 없다.

압축 비밀번호를 입력하세요

비밀번호는 이 글 본문 안에 있습니다. 읽다 보면 나옵니다.