Yihang Ho bio photo

Yihang Ho

Coder

Twitter Github Email

I was recently working on fixing an implementation of a JavaScript/CSS tooltip. I picked up a lot about positioning DOM nodes using JavaScript. This article will begin by introducing common positioning techniques used in CSS, follow by positioning in JavaScript and the problems/complications that we face. Finally, and hopefully, we develop some appreciation for the trusty jQuery in the age of React/Vue.

Positioning in CSS

Without any CSS applied, the layout process is pretty straightforward - inline elements are placed next to each other while block elements are placed on top of each other. We can think of this as the "intrinsic" positions and dimensions of the DOM nodes. Now, obviously, this is not enough. The trick behind doing most of the positioning is by using the CSS position and one or more of top, left, right, and bottom properties. The position property can be one of relative, absolute, fixed, sticky, or the default value static. We will be exploring only relative and absolute here. You may learn about the others in the documentation for position.

  • relative - When a node is relatively positioned, its final position is calculated relative to its intrinsic position. The browser will still allocate space for this node. Refer to the example below to see relative positioning in action. Relative positioning is useful, for example, when using font icons. Using relative positioning, we can nudge the icons a couple of pixels away from their intrinsic position to better align them with the other DOM nodes. However, relative positioning is most powerful to allow a descendent node to be positioned absolutely.

    See the Pen DOM Positioning - Relative Position by Yihang Ho (@yihangho) on CodePen.

  • absolute - To understand absolute positioning, we need to understand what a positioned element is. Simply put, a positioned element is an element whose position property is not static. When a node is absolutely positioned, its final position is calculated relative to its nearest positioned ancestor (or the document if none of the ancestor is positioned). Furthermore, the browser does not allocate space for this node. Absolute positioning is useful to build components such as the floating action button from Material Design. One common trick when using absolute positioning is to relatively position one of the ancestor to create a separate reference coordinates system. Notice that a relatively positioned element will occupy its intrinsic position when its top, left, right, and bottom properties are all set to 0, which is the default value.

    See the Pen DOM Positioning - Absolute Positioning by Yihang Ho (@yihangho) on CodePen.

    Notice that in this example, there are two positioned ancestors for the absolutely positioned span. However, its position is calculated based on the inner div because that is the span's nearest positioned ancestor.

Positioning in JavaScript

Obviously, JavaScript unlocks a strict superset of capabilities in terms of positioning DOM nodes. Now, in theory, a combination of relative and absolute positioning can position DOM nodes pretty much however we like. However, we bump into some problems:

  1. Sometimes, the desired position of a DOM node is undetermined until runtime. The tooltip example is an example - we do not know where to position the tooltip until the triggering element is rendered.

  2. It is not trivial to position a DOM node relative to the document. Well, if we are in control of everything, we can simply ensure that this DOM node is not an descendent of any positioned element. However, this is practically impossible.

Furthermore, the various coordinates system that exist in a document (e.g., relative to the document, viewport, or the nearest positioned ancestor) can make our math just a little more complicated. We shall get started by attempting to solve the tooltip problem. Formally, we want to have a label hovering near an arbitrary DOM node of our choice (we shall call this node the triggering node). To simplify the problem, we want the label to be centered below the triggering node:

See the Pen DOM Positioning - Final Demo by Yihang Ho (@yihangho) on CodePen.

For simplicity, the label will always be visible. In reality, it will be hidden by default and is shown in response to some user events.

Attempt 1

JavaScript exposes some properties that can help us:

  • offsetTop - This is the top coordinate of a DOM node relative to its positioned ancestor.
  • offsetLeft - This is the left coordinate of a DOM node relative to its positioned ancestor.
  • offsetWidth - Returns the width of a DOM node.
  • offsetHeight - Returns the height of a DOM node.

Our action plan is as follows:

  1. Absolutely positioned the label.
  2. At runtime, calculate the top and left values using offsetLeft and offsetWidth of the triggering node and label.

To be more precise:

const labelLeft = trigger.offsetLeft + trigger.offsetWidth / 2 - label.offsetWidth / 2;
const labelTop = trigger.offsetTop + trigger.offsetHeight;
label.style.left = `${labelLeft}px`;
label.style.top = `${labelTop}px`;

The reason this seems to work is that we are consistently operating under the coordinates system set up by the nearest positioned ancestor.

See the Pen DOM Positioning - Attempt 1 by Yihang Ho (@yihangho) on CodePen.

However, this approach breaks when the triggering node has line breaks in it:

See the Pen DOM Positioning - Attempt 1 (Problem Demo) by Yihang Ho (@yihangho) on CodePen.

In this case, offsetLeft returns the coordinate of the left edge at the word "There". As a result, our math in the previous code snippet will break - it will throw the label too far to the right.

Attempt 2

What we had in the previous attempt was really close to what we want. Now obviously, the correct behavior for the previous edge case depends on how we want the label to appear. In our case, we want it to be centered with the smallest rectangle completely covering the triggering node.

The DOM API exposes the following method that we can use:

  • getBoundingClientRect() - Returns the smallest rectangle completely covering a DOM node. This rectangle is given relative to the viewport.

Now, let's update our snippet a little:

const boundingClientRect = trigger.getBoundingClientRect();
const labelLeft = boundingClientRect.left + boundingClientRect.width / 2 - label.offsetWidth / 2;
const labelTop = boundingClientRect.top + boundingClientRect.height;
label.style.left = `${labelLeft}px`;
label.style.top = `${labelTop}px`;

Careful reader might immediately notice the problem here. getBoundingClientRect returns the rectangle relative to the viewport. However, label.style.left and label.style.right are both relative to the nearest positioned ancestor.

See the Pen DOM Positioning - Attempt 2 (Problem Demo) by Yihang Ho (@yihangho) on CodePen.

Attempt 3

In the previous attempt, we were presented a dilemma. We are given a set of coordinates relative to the viewport, but we need to calculate a coordinate relative to the nearest positioned ancestor. Obviously, we have to do some coordinates conversion. We have three options here:

  1. Document coordinates
  2. Nearest positioned ancestor's coordinates
  3. Viewport coordinates

We are going to go with the first approach. The actions plan is as follows:

  1. Somehow convert the bounding rectangle to the document coordinates.
  2. Compute the coordinates for the label relative to the document.
  3. Convert the coordinates to relative to the nearest positioned ancestor.

MDN provides some hints on how we can achieve (1):

const boundingClientRect = trigger.getBoundingClientRect();
const absoluteLeft = boundingClientRect.left + window.scrollX;
const absoluteTop = boundingClientRect.top + window.scrollY;

(2) is similar to what we did earlier and will be omited for now. To do (3), we need a little bit of math.

Firstly, observe that we can do this conversion by adding the coordinates by a pair of numbers. If we can calculate what these numbers are, we are done. The way we do this is by looking at the coordinates of the triggering node under these two coordinates and determine their differences. Notice that we used offsetLeft and offsetTop earlier. They are relative to the nearest positioned ancestor. Also, we can use the same trip as (1) to get the absolute coordinates. Piecing these gives us the following:

function getAbsoluteBoundingRect(elem) {
  const boundingClientRect = elem.getBoundingClientRect();

  return {
    left: boundingClientRect.left + window.scrollX,
    top: boundingClientRect.top + window.scrollY,
    width: boundingClientRect.width,
    height: boundingClientRect.height
  };
}

const triggerBoundingRect = getAbsoluteBoundingRect(trigger);

// targetLeft and targetTop are relative to the document
const targetLeft = triggerBoundingRect.left + triggerBoundingRect.width / 2 - label.offsetWidth / 2;
const targetTop = triggerBoundingRect.top + triggerBoundingRect.height;

const labelBoundingRect = getAbsoluteBoundingRect(label);
const deltaX = label.offsetLeft - labelBoundingRect.left;
const deltaY = label.offsetTop - labelBoundingRect.top;

// targetRelativeLeft and targetRelativeTop are relative to the nearest positioned ancestor
const targetRelativeLeft = targetLeft + deltaX;
const targetRelativeTop = targetTop + deltaY;

label.style.left = `${targetRelativeLeft}px`;
label.style.top = `${targetRelativeTop}px`;

These math can get old pretty quickly when we have to do the same thing over and over again. Thankfully, jQuery can do this for us!

  • offset() - Gets or sets the position of a node relative to the document.

Internally, offset does the same conversion to get or set the position of the node. Our code is then simplified to

const $trigger = jQuery(trigger);
const $label = jQuery(label);

const triggerOffset = $trigger.offset();
const labelOffset = $label.offset();

const labelLeft = triggerOffset.left + trigger.offsetWidth / 2 - label.offsetWidth / 2;
const labelTop = triggerOffset.top + trigger.offsetHeight;

$label.offset({ left: labelLeft, top: labelTop });

See the Pen DOM Positioning - Final Demo by Yihang Ho (@yihangho) on CodePen.

Conclusion

On first read, it is laughable that we have to go to such great lengths to achieve such a simple task. One might be tempted to blame this on the design of the DOM API. Sure, exposing more APIs might help, but ultimately, most of the complexities are necessary when dealing with different coordinates systems. This also answers whether or not libraries like jQuery is still relevant in the React/Vue age. Indeed, not only is jQuery still relevant, it nicely complements modern JavaScript UI libraries to perform low-level DOM manipulations.

Disclosure

The final working solution is very much inspired by Bootstrap. The solution to convert between the coordinates systems is inspired by jQuery offset. Both of these are highly readable. I encourage at least a cursory reading to get a sense of the complexity of the problem we are trying to solve here. As a matter of fact, what is presented here is still not the full solution - we have to worry about how existing CSS rules can affect the label dimension as it enters the DOM tree, compatibility with older browsers, and so on.