Categories CSS javascript

calculating coordinates for position:absolute

Post date December 17, 2020

You know what custom selects, dropdown menus and tooltips have in common? They all spawn elements, that may be overlapped by other elements if z-index management is done wrong. Why? Let’s have a look.

z-index property

There are CSS properties that are beginner friendly – like color or font-size. You can apply them to any element any see them work their magic. But there are also some properties, that cause headaches when you first encounter them, like z-index. The reason for it is z-index‘s dependency on context. If you need to understand the problem better read this article about z-index. If you know the problem already you know where I’m heading – how to write a component, that will conditionally display another DOM element on top of all the other elements and not fall into overlapping pitfall?

Simplest solution

The simplest solution, that in most cases gets the job done, is keeping the trigger (the element, that is always visible and can make the other element – popup – visible when interacted with) and the popup together. They need another wrapping element to make absolute positioning work properly, but that’s the only thing to know about in that case. The HTML code looks like this:

<div class="wrapper">
  <button class="trigger">Click me</button>
  <div class="popup">My visibility is toggled on interaction with "Click me" button.</div>
</div>

and CSS to display the popup under the trigger:

.wrapper {
  position: relative;
}

.popup {
  position: absolute;
  left: 0;
  right: 0;
  top: 100%;
}

Because the popup has position: absolute wrapper’s dimensions will be calculated based only on trigger’s dimensions. Specifying left: 0 and right: 0 will make the popup as wide as the wrapper and top: 100% will position the popup right under wrapper’s bottom.

No matter where the elements are on the screen and if the user resizes the browser window or uses zoom – it’s rock solid solution and will just work – the elements will stay positioned correctly. The only thing, that can go wrong, is placing the widget too low on the website and overflowing the window so that popup content with get cut off.

This solution has also an advantage when you think about tabindex management – after activating the popup it will be placed right after the trigger in the tab sequence and does not need any additional javascript to work.

So if it’s developed and deployed in the environment we have in control – it’s the easiest and most bulletproof solution.

But what if we are not fully in control? For example while designing a component library?

Detached popup

To make sure our popup gets rendered on top we can detach it from the trigger, which means, that the triggers keeps its original position in DOM and popup gets appended right before the closing body tag.

This will solve the z-index problem but case two new. Firstly, the position will need to be calculated in javascript and secondly, the tab sequence management is now our problem.

Positioning detached elements

To position our popup we need to know where the trigger is on the screen. That’s a perfect use case for getBoundingClientRect function:

const trigger = document.querySelector('.trigger');
const rect_trigger = trigger.getBoundingClientRect();

Now we can access properties liketrigger.getBoundingClientRect().left , that will help us position the popup:

  • left – how far to the left from the screen’s left edge is the element’s top left corner (in pixels)
  • top – how far to the bottom from the screen’s top edge is the element’s top left corner (in pixels)
  • width – in pixels
  • height – in pixels

We could use these values directly to position an element with position:fixed, because both position: fixed and getBoundingClientRect return distance between the element and viewport boundaries. I used to think it’s a good idea for some time until I got really irritated by the glitches on scrolling. Because whenever you scroll the position of the trigger on the screen changes and the popup stays pinned in one spot until you move it specifying new coordinates listening for the scroll event. There’s a better way.

Let’s make the assumption, that once the trigger’s position gets calculated, for the time of interaction with the popup it will most probably stay the same. With that assumption we can calculate how far from the top left corner of the page we want to place our popup. And after we position it once it will be positioned, so we will see no glitches on scrolling. So, let’s see how to run the calculations.

Calculating distance from document’s top left corner

We have already found out how to get the position of the trigger on the screen with getBoundingClientRect. Now we need to adjust it using the scroll position – how many pixels to the bottom and right the user has already scrolled. The properties containing that information are window.pageYOffset and window.pageXOffset. We can use them to place the popup right under the trigger this way:

const popup = document.querySelector('.popup');
const trigger = document.querySelector('.trigger');
const rect_trigger = trigger.getBoundingClientRect();

let top = rect_trigger.top + window.pageYOffset + rect_trigger.height;
let left = rect_trigger.left + window.pageXOffset;

popup.style.setProperty('top', top + 'px');
popup.style.setProperty('left', left + 'px');

Now we may want to make sure the popup always stays visible on the screen. We need to remember it is absolutely positioned and if it overflows the parent, the overflowing part gets cut off and is useless to the user. To get the bottom most coordinates the user can reach we use document.body.scrollHeight:

const = popup_rect = popup.getBoundingClientRect();
if (top + popup_rect.height > document.body.scrollHeight) {
    top = rect_trigger.top - popup_rect.height;
}

This will make sure the whole content of the popup can be read if a user is willing to scroll. However if the contents of the popup are short (like in a tooltip) we may want to make sure they do not require any scrolling and if the trigger is too far to the bottom on th screen, display the popup above instead of below it. To know if we will overflow screen’s bottom edge we will need to know far the user has scrolled already (window.pageYOffset) and how tall the viewport is (window.innerHeight):

if (top + popup_rect.height > window.pageYOffset + window.innerHeight) {
    top = rect_trigger.top - popup_rect.height;
}

The calculations for the X-axis are similar. Moreover, if we are working with responsive design and we never scroll horizontally, these calculations are not even necessary.

Managing tab sequence

The problem with detached popup is website’s tab sequence. After interacting with trigger element keyboard users would normally not be navigated into the popup in this case. They would have to tab through all the other elements in the DOM to reach the popup. That’s clunky.

How much of a problem that is depends on the nature of the popup. In case of toggletip implemented according to Heydon Pickering’s proposition using live region (role="status") it doesn’t really matter where the popup element is in the DOM. Or if you have a look at W3C input with combobox where the focus stays on trigger element the whole time, while only visual focus moves into the popup. For other widgets, like expandable menus, it would be a problem if the focus management were not implemented correctly, so for those widgets I would stick with the simple solution of keeping then together in the DOM.

Conclusion

Absolute positioning elements on the screen can be challenging if you think of all the edge cases and possible overflows, however if you go through it once, you learn a valuable lesson, that will help you a lot in many future situations. It worth it!

Leave a Reply

Your email address will not be published. Required fields are marked *