DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Building a popover from scratch: flip, shift, and an arrow that tracks

A popover is the little floating panel that hangs off a button and points back at it — the thing under every rich tooltip, menu, date picker and hover card. It looks trivial until you place one and it runs off the bottom of the screen. So for day 32 of DesignFromZero I built one with no library: a tiny floating engine that reads the anchor, picks a side, flips when it would overflow, shifts to stay in view, and points an arrow back at the anchor. About 120 lines of vanilla JS.

Here is the live version if you want to poke at it: https://dev48v.infy.uk/design/day32-popover.html

Popover, tooltip, dropdown — same math, different manners

These three share one positioning engine. What differs is behaviour. A tooltip is a tiny label you cannot click. A dropdown is a list of choices you navigate with the arrow keys. A popover sits between them: it opens on click (or hover, or focus), holds real interactive content — text, a form field, buttons — and behaves like a small non-modal dialog. Because it can hold focusable content, it owes the user things a tooltip never does: a role, an accessible name, focus moved in and returned, and a proper dismiss story.

It all starts with a rectangle

Everything hangs off getBoundingClientRect(). It hands you the anchor's box in viewport coordinates — pixels from the top-left of the window, scroll already baked in. That matters, because a position:fixed panel lives in the same coordinate space, so the rect's numbers drop straight into the panel's top and left with no conversion. Measure the panel with offsetWidth/offsetHeight while it is rendered (visibility hidden, not display:none, so it still has a size), and you have everything the math needs.

Placement is two independent axes

Keep them separate and the code stays clean. The side — top, bottom, left, right — fixes the main axis: which edge of the anchor the panel hangs off, plus a small gap. A bottom popover sits at anchor.bottom + gap; a top one at anchor.top - gap - panelHeight, because you subtract its own height. The alignment — start, center, end — fixes the cross axis: for a vertical side, center lines the midpoints up. That is the "preferred" placement — where it would sit if the viewport were infinite.

Flip fixes the main axis

The preferred side is a wish, not a promise. If the anchor is near the bottom and you asked for a bottom popover, the panel gets clipped. So before committing, ask whether it fits, and if it does not but the opposite side does, flip:

const opposite = { top:"bottom", bottom:"top", left:"right", right:"left" };
function fits(s){
  if (s === "bottom") return a.top + a.height + GAP + ph <= vh - PAD;
  if (s === "top")    return a.top - GAP - ph >= PAD;
  // ...left / right
}
if (!fits(side) && fits(opposite[side])) side = opposite[side];
Enter fullscreen mode Exit fullscreen mode

This is why menus near the bottom of the screen open upward.

Shift fixes the cross axis

Flipping only ever handles the main axis. A bottom popover centered on a corner anchor still hangs off the left edge, because centering ignores the walls. Shift clamps the cross-axis coordinate into the visible band — and, crucially, remembers how far it moved, because the arrow needs that number:

const min = PAD, max = vw - pw - PAD;
const clamped = Math.max(min, Math.min(left, max));
const shift = clamped - left;   // the arrow depends on this
left = clamped;
Enter fullscreen mode Exit fullscreen mode

Flip plus shift together are the whole reason a good popover never gets clipped.

The arrow is where people slip

The classic bug: pointing the arrow at the panel's centre instead of the anchor's. When nothing shifts they coincide and it looks fine — until a shift slides the panel sideways and the arrow points at empty space. Compute it from the anchor's centre in the panel's own coordinate space: arrow = anchorCenterX - panelLeft. Because panelLeft already includes the shift, the arrow slides the opposite way and keeps aiming true. Then clamp it so a big shift never pushes it onto a rounded corner. Draw it as a CSS border-triangle.

Triggers, dismiss, and staying glued

Three ways to open, one openFor() / close() pair behind them. Click toggles and pairs with outside-click dismissal. Hover has the famous trap where the panel snaps shut before the mouse can reach it — cure it with a short close delay plus a mouseenter on the panel that cancels the pending close, so the pointer can bridge the gap. Focus is the keyboard's version of hover. For an interactive popover, move focus into the panel on a click open, give it role="dialog" and aria-expanded on the trigger, and return focus to the anchor on close. Dismiss on outside mousedown and on Escape. And since a fixed panel is placed once but the anchor moves, recompute on scroll (capture phase, to catch nested scrollers) and resize.

The platform is catching up: the native Popover API gives you top-layer and light-dismiss for free, and CSS anchor-positioning does flip and shift in pure CSS. Support is still uneven, which is why floating-ui exists — it absorbs the long tail of scroll containers, iframes and transforms. But the core is this: read the rect, flip, shift, arrow, repeat.

Top comments (0)