GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

CSS Popover: 9 Ways to Anchor, Flip and Spring

A css popover can clip at screen edges, point its arrow the wrong way and overlap its neighbors — scenes 01, 04 and 09 fix those with free-space math, arrow

Open All Nine Popovers Right on This Page

Auto-plays · click a tile to jump to its section · all nine in one zip

CSS Popover: 9 Ways Where Anchor Space, Springs and Vectors Place It Right

A popover is a small panel that opens from an anchor such as a button or a dot. HTML has a popover attribute that lets the browser handle opening and closing, but the nine scenes on this page use neither that attribute nor CSS anchor positioning. Each one moves a single <div> with a translate3d value that JavaScript computes, because the point here is to watch where the panel lands, how far it gets pushed and how it steps aside when neighbors collide.

Every scene sits on a stage up to 440px wide and 252px tall, a translucent plate that shrinks to roughly 294×174 in a 320px-wide viewport. The four edges of that plate play the part of the screen edges, so they are not the real viewport. The render capture is 480×300, a separate number, and time advances in fixed 1/60-second ticks, so the motion looks the same at any refresh rate.

How a scene chases its target differs from one to the next. Scenes 02, 07 and 09 keep a velocity variable, which makes them true springs. Scenes 01, 03, 04, 05 and 06 close a fixed fraction of the gap on every tick, which is exponential easing, while scene 08 advances linearly and bends the progress with an ease-out curve. Finding the target differs too: a free-space score in 01, a triangle test in 03, a noisy polygon in 08 and an overlap test in 09. None of the nine files pulls in a library, makes an outside request, calls Math.random or uses a CSS @keyframes rule.

If your bubble gets cut off at the edge of the screen, open 01 first. For a css popover on hover, such as a submenu, start with 03 and 07, and for a card the reader drags around, start with 05. Neighbors on this site are 9 CSS Tooltip Bubbles — one line, copy-paste, Context Menu UI: 9 Right-Click Menu Patterns and 9 CSS Dropdown Menu Animation—No Library, Pure CSS.

Code for Each Technique Behind CSS Popover Anchor Positioning

Each snippet below is a contiguous run of lines from the scene source, 25 lines or fewer, cut at function boundaries. Constant declarations are left out of the snippets and their values are given in the text, so open the files in the zip when you want the whole scene.

01Collision Flipping That Measures Free Space on Four Sides

choose() scores each of the four sides by taking the free space around the anchor and subtracting the popover size and the 12px gap, and it changes side only when the current score drops below 0 or falls more than 24 under the best one. It suits help bubbles near screen edges, and when the side flips the popover glides across the anchor instead of jumping, because it eases toward the new target by 0.28 per tick. Scene 01 rendered at 52.1% cumulative area with 23/23 frames moving.

Collision Flipping That Measures Free Space on Four Sides
Open in a new tab

01 · Collision Flipping That Measures Free Space on Four Sides — Measured area 52.147% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

02A Popover That Springs Open from the Anchor Point

Each tick updates the size with (vel + (target - size) * STIFFNESS) * DAMPING, so the size overshoots the target of 1 slightly and comes back, and the transform origin sits at the anchor center so the panel looks grown from the anchor. This fits a quick settings panel beside a button. Scene 02 rendered at 29.8% cumulative area with 16/23 frames moving.

A Popover That Springs Open from the Anchor Point
Open in a new tab

02 · A Popover That Springs Open from the Anchor Point — Measured area 29.803% · moving frames 16/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

03A Safe Triangle That Delays Closing by Pointer Direction

A triangle runs from the pointer position six ticks ago to the two near corners of the open panel, and while the pointer crosses it toward the panel at SPEED_MIN or faster, touching another item does not switch the panel until INTENT_MS (260ms) has passed. It fits hover-opened submenus. Scene 03 rendered at 34.0% cumulative area with 18/23 frames moving.

A Safe Triangle That Delays Closing by Pointer Direction
Open in a new tab

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 · See the password

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

04An Arrow That Keeps Pointing at the Anchor While the Body Shifts

The body stops at the edge padding EDGE_PAD and gets shifted, but the arrow tip chases the anchor x by ARROW_LERP (0.15) per tick, so even a body shifted by up to 72px keeps its tail aimed at the anchor. It fits input hints on narrow screens. Scene 04 rendered at 36.3% cumulative area with 23/23 frames moving.

An Arrow That Keeps Pointing at the Anchor While the Body Shifts
Open in a new tab

04 · An Arrow That Keeps Pointing at the Anchor While the Body Shifts — Measured area 36.272% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

05A Popover That Follows by Inertia and Resists Like Rubber at the Edge

Dragging the anchor makes the popover trail it at 0.14 per tick, and the distance past the allowed rectangle goes through rubber(), which squeezes it toward 28px so the popover stretches like a rubber band. It fits movable note cards. Scene 05 rendered at 54.5% cumulative area with 23/23 frames moving.

A Popover That Follows by Inertia and Resists Like Rubber at the Edge
Open in a new tab

05 · A Popover That Follows by Inertia and Resists Like Rubber at the Edge — Measured area 54.481% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

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)];
    }

06Nested Popovers Whose Shadow and Size Step Up with Each Depth

Every extra level of depth trims the size by 0.07 and the brightness by 0.1 and deepens the shadow by 7px. It fits settings inside settings, with level 1 at size 1.00, shadow 7px and brightness 1.00 and level 3 at 0.86, 21px and 0.80. Scene 06 rendered at 23.1% cumulative area with 16/23 frames moving.

Nested Popovers Whose Shadow and Size Step Up with Each Depth
Open in a new tab

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 · See the password

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)";
        }

07A Highlight Pill That Springs Between Menu Items

The pill's y and h each chase the hovered item's position and height with their own velocity spring (PILL_STIFF 0.18, PILL_DAMP 0.72), and a band of light sweeps across it for 420ms whenever the item changes. It fits account dropdown menus. Scene 07 rendered at 18.3% cumulative area with 19/23 frames moving.

A Highlight Pill That Springs Between Menu Items
Open in a new tab

07 · A Highlight Pill That Springs Between Menu Items — Measured area 18.318% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

08A Circular Mask with a Noisy Edge Blooming from the Anchor

On opening, the radius of a clip-path: polygon() circle spreading from the anchor point is shaken at every angle by 2D value noise of ±24%, so the content is revealed along an uneven edge like spreading ink. It fits image preview popups. Scene 08 rendered at 33.9% cumulative area with 14/23 frames moving.

A Circular Mask with a Noisy Edge Blooming from the Anchor
Open in a new tab

08 · A Circular Mask with a Noisy Edge Blooming from the Anchor — Measured area 33.885% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

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(",") + ")";
    }

09A Popover Ring That Pushes Apart and Settles Around One Anchor

The chips are placed at evenly spaced angles on an ellipse around one anchor and pulled toward their spots by an acceleration PULL (0.03), and when two rectangles overlap they push each other apart along the shallower axis of the overlap. It fits quick-action menus radiating from one point. Scene 09 rendered at 18.6% cumulative area with 15/23 frames moving.

A Popover Ring That Pushes Apart and Settles Around One Anchor
Open in a new tab

09 · A Popover Ring That Pushes Apart and Settles Around One Anchor — Measured area 18.613% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

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

Where the Values Change the Popover — Spring Stiffness, Arrow Interpolation, Repulsion

Three values change how a scene moves most visibly. The figures in this part were not read off a browser render; they come from running each scene's source against a stub DOM with 60Hz ticks.

Stiffness 0.14 to 0.32 — quicker, but it overshoots more

Raising STIFFNESS in 02 brings the time to reach 0.95 after opening starts down from 150ms to 100ms. The stronger restoring force also carries the size further past the target, so the peak scale goes from 1.09 to 1.26, and the time to settle within 0.02 of 1 grows from 317ms to 367ms.

Interpolation 0.15 to 0.4 — the tail rarely loses the anchor

Raising ARROW_LERP in 04 cuts the largest distance between the arrow tip and the anchor center over the 2400ms demo from 49px to 13px and the average from 28.5px to 8.0px. The body's BODY_LERP is 0.35, so at 0.4 tail and body move at nearly the same speed and the gap between them disappears.

Repulsion 0.6 to 1.4 — the result depends on the stage size

Raising REPEL in 09 shortens the time to clear the 11 pairs that overlap at the start from 7 frames to 5 on the 440×252 stage. On a stage 272px wide the same change goes from 7 frames to 11, and settling slips from 50 frames to 53. The ring's total width stays 281px (248px on the narrow stage) whatever the value, so RADIUS sets the diameter.

Here is every scene's number from the final render, taken over 24 captures at 480×300: scene 05 covers the most area at 54.5%, scene 07 the least at 18.3%, and scene 08 moves in the fewest frames, 14/23.

Scene Cumulative area Mean intensity Frames moving
01 Collision flip 52.1% 150.9 23/23
02 Spring open 29.8% 84.4 16/23
03 Safe triangle 34.0% 76.4 18/23
04 Arrow interpolation 36.3% 63.8 23/23
05 Rubber drag 54.5% 170.4 23/23
06 Nested steps 23.1% 56.7 16/23
07 Highlight pill 18.3% 126.1 19/23
08 Noise mask 33.9% 70.7 14/23
09 Popover ring 18.6% 82.9 15/23

Where It Breaks — Stacked Popovers, Narrow Screens, Reduced-Motion Settings

A narrow stage removes the sideways candidates

A sideways candidate in 01 needs POP_W 116 plus GAP 12, which is 128px, but on a 294px stage with the anchor in the middle each side has only 125px free, so just above and below stay in the running.

With no holding margin the side can flip back and forth

Setting FLIP_BIAS to 0 turns the rule into a flip the moment the current side scores even slightly below the best, so an anchor crossing a spot where two scores are close could make the side go back and forth. That is an inference from reading the condition; I did not run it with 0 to confirm.

A spring that overshoots makes the panel bigger than its target

The 02 overshoot peaks at 1.09, so the 156px panel briefly grows to about 170px, and PAD only guards the position for a target size of 1. On the 440px stage the transform origin is ORIGIN_X 62.4, 78.0 and 93.6 for the three slots, ORIGIN_Y is always -24, and the origin stays on the anchor even when the panel is pushed by an edge.

The triangle starts from where the pointer was six ticks ago

The corners in 03 come from the pointer's position six ticks back, not from the anchor, so a resting pointer gets a triangle that starts at the pointer. The demo pointer passes at about 267px/s at most, and raising SPEED_MIN from 60 to 400 puts the threshold above that top speed, so the pointer never counts as heading for the panel, item 2 opens at once, and the switches go from a single one at 1517ms to two, at 483ms and 1517ms. Closing happens only when the pointer leaves toward the left of the menu.

The arrow tip moves only inside the body

The tip in 04 is clamped to 14 to 154px within the body, so when the anchor goes further past the body the tip sticks to the body's corner. SHIFT_MAX limits the anchor range for that reason.

A stretched popover gets cut off by 8px at the stage edge

In 05, dragging 10, 40, 100 and 300px past the allowed range stretches the popover by 4.6, 12.3, 18.6 and 23.9px, leveling off toward 28px. LAG (28) is 8 more than EDGE_PAD (20), so at full stretch 8px of the popover sits outside the stage and is clipped.

The brightness filter darkens the text too

Scene 06 uses filter: brightness() for depth, so the text inside the popover darkens along with it, down to 0.80 at level 3. At 294px wide the horizontal step stepX shrinks to 37px and the staircase gets tight.

The pill overshoots its target height

When the pill in 07 goes from height 32 to 40 it stretches to 41.9 at the peak before coming back, and when its position goes from 4 to 100 it reaches 122.8, passing the change by about 24%. Both values come from running the source directly.

The noise mask builds strings on every frame

Scene 08 swings the edge between 0.76 and 1.24 times the base radius, so the target radius is set to far * 1.05 / (1 - NOISE_AMP), where far is the distance to the farthest corner, and even the shortest point of the edge still clears every corner. Each frame it builds a 48-point string for two layers, and once the opening ratio p reaches 1 it drops the mask and the cost stops.

Chips collide more easily on a narrow stage

The horizontal radius of the target ellipse in 09 is min(RADIUS * 1.45, w / 2 - 48), which is 88px on a 272px stage against 104.4px on 440px, so neighboring chips sit closer.

To take all nine scenes at once, open the zip card below with the password get7ygpn; it holds the sources and the React versions.

Three Variants and Accessibility

Name Value Feel
Change the spring stiffness 02 STIFFNESS 0.14 → 0.32 Raising stiffness from 0.14 to 0.32 cuts the time to reach 95% after opening starts from 150ms to 100ms, but the overshoot grows from 9% to 26% and the wobble takes 367ms instead of 317ms to settle.
Change the arrow interpolation 04 ARROW_LERP 0.15 → 0.4 Raising the interpolation factor from 0.15 to 0.4 shrinks the largest distance between the arrow tip and the anchor center from 49px to 13px and the average from 28.5px to 8px, so the tail rarely loses the anchor. It also nears the body's BODY_LERP (0.35), so the tail and body move less independently.
Change the repulsion force 09 REPEL 0.6 → 1.4 Raising repulsion from 0.6 to 1.4 cuts the frames needed to clear the 11 pairs that overlap at the start from 7 to 5 on the 440×252 stage, but raises them from 7 to 11 on 272×174, and settling slips from 50 to 53 frames. The ring's total width is the same in both (281px, 248px on the narrow stage) because RADIUS sets the diameter.

As noted above, these three variant figures were measured by running the source against a stub DOM.

Every scene works with a <button> or the pointer, and keydown handling exists in eight scenes, all except 09. In 01, 04 and 05 you focus the anchor and move it with the arrow keys (four directions in 01 and 05, left and right in 04), and 07 moves item focus with Up and Down, wrapping from the last item to the first. Esc closes only 02, 03, 06 and 08: 02 and 08 return focus to the anchor, 06 returns it to the button that opened that level, and 03 only closes. Only those same four update aria-expanded.

setPointerCapture and touch-action: none appear only in the draggable 01, 04 and 05, so the other six scenes do not block touch scrolling inside the iframe. Every scene has one aria-live status line, which stays off during the demo and becomes polite once someone touches the scene or the reduced-motion setting is on.

With prefers-reduced-motion on, none of the nine starts its demo or animation loop and each paints its end layout once. That means the flipped position in 01, the open state in 02 and 08, the right end in 04 and 05, all three levels open in 06, and in 09 a ring pre-run for 160 ticks until it has settled.

Is CSS Popover Animation Alone Enough?

Fading and scaling in as a panel opens takes only a few lines. Which side the popover opens on, though, depends on the distances from the anchor to four edges, so in 01, 04 and 05 the heart of the code is the calculation that finds the spot, ahead of any motion.

Choosing between the HTML popover attribute and moving a <div> yourself decides who manages the open state, and how the position is chosen is a separate set of rules. This page works through that second set in nine ways, with one scene per headache: 06 for many levels, 07 for many items and 09 for chips that overlap.

Frequently Asked Questions Before Reusing a CSS Popover Example

Can I place a popover by hand instead of css popover anchor positioning?

Yes, and the nine scenes do exactly that. They turn the anchor's coordinates and the stage size into a translate3d value, and a ResizeObserver redraws when the width changes. The position is recomputed from normalized coordinates such as nx and ny or from clientWidth on every render, so no old pixel value lingers.

Can a css popover on hover menu like scene 07 work from the keyboard?

Yes. On every item, pointerenter and focus both set the pill's target, Up and Down move the focus, and Tab, Enter and Space are left alone so the browser's default behavior still applies.

Do these scenes need a library or an outside request?

No. The nine files contain no script src, import or fetch, and a scene's index.html and style.css alone are enough to run it.

Enter the archive password

The password is inside this article. You will find it as you read.