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
):
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).
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.
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.
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!).
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.
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>
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.
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 position
ed). This ties in with Problem 3 in terms of DOM context — offsetParent
s 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.
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 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.
- 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:
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.
- 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:
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.
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 theoffsetParent
for fixed elements, when it should benull
(per the spec) - Edge and IE always report
.scrollTop
as0
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)
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!".
Great article! I couldn't have explained it better.
Great and detailed article ♥️
Great article.
Regards