DEV Community

Cover image for Everything I Know About Positioning Poppers (Tooltips, Popovers, Dropdowns) in UIs
atomiks
atomiks

Posted on

Everything I Know About Positioning Poppers (Tooltips, Popovers, Dropdowns) in UIs

I'm a core dev and maintainer of Popper and creator of Tippy, two popular libraries used to build tooltips, popovers, dropdowns, menus, etc. in web applications. After working on these things for a few years, I would like to share the knowledge I've accumulated during this time. These elements are absolutely everywhere in web applications, so understanding how they work is important for creating a good user experience!

Disclaimer: This isn't truly everything I know, because there are so many nuances and subtleties that are difficult to get across in a single article πŸ˜…. For this article I am focusing on the key problems and solutions to them.

Poppers?

In their very essence, poppers are elements that "pop out" from the normal flow of the document (absolute or fixed position) and float near a reference or target element (such as a button), overlaid on top of the UI. Conceptually, the CSS used to position them is straightforward: you set position: absolute on the element and some top and left coordinates to position it next to the reference element in some placement (top, bottom, left, or right).

However, the calculations performed to receive those coordinates is difficult. While we could naively calculate the coordinates to center it above a button for example, we quickly run into edge cases that a robust solution such as Popper solves for you. For reusable component libraries, this is important, because we don't want the user to manually calculate the coordinates for a popper to make it fit on screen as best as possible every time they add one to their UI. Ideally we just want to call a position() function that will do this hard work automatically.

As an initial step, you'll place the popper somewhere next to the reference element somewhere in your UI. To do this, you'll choose one of 12 placements: four base ones (top, right, bottom, left) or ones with a variation attached (start or end):

Demonstration of 12 placements in clockwise order: top-start, top, top-end, right-start, right, right-end, bottom-end, bottom, bottom-start, left-end, left, left-start

However, doing this without any additional logic is problematic (i.e. if you only used CSS). Let's explore why we need JavaScript for this.

Problem 1: Preventing overflow if the popper will be clipped or overflow the main axis of a boundary

When the reference element is near the edge of the boundary, for example the left edge of the window, and the tooltip is wider than it, we run into a problem: some of the tooltip text gets cut off and is unreadable. If it's positioned on the left, the text will be clipped and unable to be viewed, while on the right, it will cause overflow and require scrolling to view (LTR layout).

Prevent overflow visualization: a button is close to the left edge of the screen, and its tooltip is centered above it β€” but this causes it to be clipped by the viewport boundary on the left because the tooltip is wider than the button.

How do we solve this? We calculate how much it's overflowing the boundary and shift it in view to prevent this. In Popper, this is called the preventOverflow modifier.

Prevent overflow visualization: the tooltip is shifted to the right to be entirely viewable.

Popper also allows you to specify the whitespace or padding between the popper and the boundary. In this example and by default it lies flush with the boundary edge.

Problem 2: Flipping when the popper will be clipped or overflow the alt axis of a boundary

Now we've prevented the left/right overflow β€” but we have another problem.

When the reference element is near the edge of a boundary, for example the top edge of the window, the tooltip will be cut off. This is similar to the prevent overflow issue in the previous problem, but happening on the alternate axis.

Flip visualization: a button is close to the top edge of the viewport, but this causes most of it to be cut off.

However, this time we can't prevent overflow on this axis (y), because if we do so, the popper will overlap the reference element and obscure its contents. Sometimes this may be acceptable but in most cases it is not (though Popper allows you to do this!).

Overlapping

Instead, we want to flip it to a different placement that fits better entirely rather than just shifting it into view. In Popper, this is called the flip modifier.

Flip visualization: the tooltip is flipped to the bottom from the top and fits entirely in view on the y axis.

Solving these two problems is the core of Popper, but far from all of it.

Problem 3: Scrolling containers

Your reference element and popper could be located anywhere in the DOM. For example your reference element and popper element could be in different scrolling containers. Popper needs to handle any DOM context you throw at it.

Take the following HTML:



<div id="scroll">
  Lots of text here
  <button id="reference">My Button</button>
  Lots of text here
</div>

<div id="tooltip">My Centered Tooltip</div>


Enter fullscreen mode Exit fullscreen mode

When the scrolling container is scrolled, the reference element will change its location on the screen. However your tooltip is not "aware" of this happening, and so it won't track the reference element as it's scrolling.

Tooltip detaches by the scroll amount

To solve this, Popper attaches a scroll listener to the container and on each fired scroll event, recalculates the position of the popper. This allows it to stay stuck to the reference element like it should. (This also applies to the main window/html/body).

You may think this would be a performance issue, but calculations showed that even on slow hardware such as low-end mobile phones, this should still be completed within 10ms (frame budget + overhead). Popper is written in an efficient manner to ensure this.

You may also notice that the popper is overflowing the red boundary. In this case, the scrolling container is not a "root" like the viewport, and not a clipping parent of the popper because it's outside of it in the DOM, so we can actually still center it.

Problem 4: offsetParent

Absolutely positioned elements are positioned relative to their offsetParent (one that's positioned). This ties in with Problem 3 in terms of DOM context β€” offsetParents create an issue: getBoundingClientRect() is required for getting the reference element's rect, but it's always relative to the viewport. If the popper is not positioned relative to the viewport, we need to consider its offsetParent when measuring the reference element.

Demonstrating the difference in considering offsetParent vs ignoring it when using getBoundingClientRect.

In the above image, we need to subtract the offsetParent's x and y coordinates from the popper's x and y coordinate offsets, otherwise, we'd end up with the incorrectly-positioned red hachured box below. Reason being, the reference element is closer to the offsetParent's (0, 0) coordinates than the viewport, so the popper will be positioned too far away.

Problem 5: Hiding due to different clipping contexts

The reference element and popper element being in different clipping contexts can pose a problem. The popper can appear "detached" from the reference element, or attached to nothing at all if the reference element is fully clipped and hidden from view.

Popper appears attached to nothing when reference element is clipped from view.

Popper attaches attributes in the following cases:

  • data-popper-escaped: When the popper escapes the reference element's clipping container (it appears detached)
  • data-popper-reference-hidden: When the reference element is hidden from view (it appears attached to nothing)

This allows you to fade out or hide the popper once it can no longer appear to be floating near something.

Problem 6: The arrow

So far, we've talked about the main popper box. But in each of the images shown, there's a little arrow (caret or triangle) that always points to the center of the reference element that is placed outside of the popper box.

While the arrow itself isn't a problem, there are several problems created by it.

  1. The arrow attempts to stay centered relative to the reference at all times, however, there are cases where this is impossible, such as the following:

Ideal vs constrained position of the arrow β€” ideal version goes outside of the popper box which cannot happen.

We need to constrain the arrow within the popper box at all times. This poses some aesthetic issues, so Popper also enables you to hide the arrow in this case if undesirable.

  1. If the reference is "point-like", the arrow needs to change shape β€” interpolate itself using how much it's offset from its ideal position in order to point toward the reference element:

Arrow changing shape so it points toward a very small reference that is smaller than the arrow's own size

Popper provides this data in the form of centerOffset.

Problem 7: Virtual elements

We can't assume we're dealing with a "real" element to position relative to. You should be able to position your popper next to a "virtual" element (one that does not actually exist on the DOM) for usage with mousemove, contextmenu, etc.

Virtual element contextmenu demonstration

Problem 8: Size

Sometimes even the flip modifier won't be adequate, because the popper is intrinsically too large in dimensions to fit within the screen. While there are workarounds for this, such as setting max-width: 100vw for the top/left placements, Popper enables the ability to dynamically resize the popper to fit within the available viewport space when it's at any location on the screen.

Although this is not included by default, it's available as a community package written with a few lines of code, which showcases Popper's powerful extensibility.

Problem 9: Browser bugs

Browsers are inconsistent, duh πŸ˜”πŸ€š! Here's a non-exhaustive list of issues we found while working on the Popper 2 rewrite:

  • Firefox returns <body> as the offsetParent for fixed elements, when it should be null (per the spec)
  • Edge and IE always report .scrollTop as 0 for the <body> element
  • Safari's elastic overscroll causes fixed poppers to translate incorrectly with the overscroll
  • Safari's style.transform updates are laggy and not 1:1 in-sync. Preventing overflow therefore is not 100% smooth. Other browsers aren't perfect either but much better.
  • We use translate3d() on high PPI devices only for performance and translation smoothness, but on low PPI displays with Windows scaling enabled, it can cause blurring issues.

To conclude, I hope you learned a bit more about why Popper exists and the key problems it solves for these elements in our UIs. I also encourage you to start using the library if not already! Popper solves all of these problems elegantly without you needing to reinvent the wheel. We spent hundreds of hours developing the library and ran into, and fixed, tons of edge cases. Take a look at our visual test snapshots!

Top comments (4)

Collapse
 
codypearce profile image
Cody Pearce

Excellent article. The complexity required to create truly robust components is rarely apparent, the more edge cases you fix the more seem to pop up. Going to reference this article whenever someone says "it's just a ${seemingly simple ui} it shouldn't be that difficult!".

Collapse
 
fezvrasta profile image
Federico Zivolo

Great article! I couldn't have explained it better.

Collapse
 
layershifter profile image
Oleksandr Fediashov

Great and detailed article β™₯️

Collapse
 
fr13ndkra profile image
Diego OH

Great article.
Regards