Anchored position behavior

Used to calculate the position of a "floating" element that is anchored to another element.

The getAnchoredPosition behavior and useAnchoredPosition hook are used to calculate the position of a "floating" element that is anchored to another DOM element. This is useful for implementing overlay UI, such as dialogs, popovers, tooltips, toasts, and dropdown-style menus.

At a high level, the getAnchoredPosition algorithm will attempt to find the most suitable position for the floating element based on the passed-in settings, its containing element, and the size and position of the anchor element. Specifically, the calculated position should try to ensure that the floating element, when positioned at the calculated coordinates, does not overflow or underflow the container's bounding box.

Settings for this behavior allow the user to customize several aspects of this calculation. See PositionSettings below for a detailed description of these settings.

Positioning algorithm

When calculating the position of the floating element, the algorithm relies on different measurements from three separate elements:

  1. The floating element's width and height
  2. The anchor element's x/y position and its width and height
  3. The floating element's clipping container (for x/y position, width and height, and border sizes)

The public API only asks for the first two elements; the floating element's container is discovered via DOM traversal.

Finding the floating element's clipping container

The returned anchored position calculation is relative to the floating element's closest positioned ancestor. To find this ancestor, we try to check parents of the floating element until we find one that has a position set to anything other than static and use that element's bounding box as the container. If we can't find such an element, we will try to use document.body.

Once we have found the appropriate relative ancestor, we attempt to find the floating element's clipping container. The clipping container is an element that: 1) has overflow set to something other than visible, and 2) is either an ancestor of the relative ancestor, or is itself the relative ancestor. Again, if we cannot locate such an element, we will use document.body as the clipping container.

Once we have the clipping container, its bounding box is used as the viewport for the position calculation (see the next section). If the clipping container ends up being document.body, we take one additional step, allowing the clipping rectangle to be at least as tall as the window. This is done because the body element doesn't take up the full window size by default, but we still want to allow the entire space to be used as the viewport for the position calculation. It may be a good idea to ensure that this clipping container element also contains the anchor element and is scrollable. This will ensure that if scrolled, the anchor and floating element will move together.

Positioning and overflow

With the positions and sizes of the above DOM elements, the algorithm calculates the (x, y) coordinate for the floating element. Then, it checks to see if, based on the floating element's size, if it would overflow the bounds of the container. If it would, it does one of two things:

A) If the overflow happens in the same direction as the anchor side (e.g. side is 'outside-bottom' and the overflowing portion of the floating element is the bottom), try to find a different side, recalculate the position, and check for overflow again. If we check all four sides and don't find one that fits, revert to the bottom side, in hopes that a scrollbar might appear. B) Otherwise, adjust the alignment offset so that the floating element can stay inside the container's bounds.

For a more in-depth explanation of the positioning settings, see PositionSettings below.

Demo

Deploy Storybook to see a live demo of anchoredPosition.

Usage

const settings = {
side: 'outside-right',
align: 'center',
alignmentOffset: 10,
anchorOffset: -10,
} as Partial<PositionSettings>
const float = document.getElementById('floatingElement')
const anchor = document.getElementById('anchorElement')
const {top, left} = getAnchoredPosition(float, anchor, settings)
float.style.top = `${top}px`
float.style.left = `${left}px`

API

The getAnchoredPosition function takes the following arguments.

NameTypeDefaultDescription
floatingElementElementThis is an Element that is currently rendered on the page. getAnchoredPosition needs to be able to measure this element's width and height.
anchorElementElementThis is an Element that the floating element will be "anchored" to. In other words, the calculated position of the floating element will be based on this element's position and size.
settingsPositionSettings{}Settings to customize the positioning algorithm. See below for a description of each setting.

PositionSettings interface

PositionSettings is an object with the following interface

NameTypeDefaultDescription
sideAnchorSide"outside-bottom"Sets the side of the anchor element that the floating element should be pinned to. This side is given by a string starting with either inside or outside, followed by a hyphen, followed by either top, right, bottom, or left. Additionally, "inside-center" is an allowed value.

The first part of this string, inside or outside, determines whether the floating element should be attempted to be placed "inside" the anchor element or "outside" of it. Using inside is useful for making it appear that the anchor contains the floating element, and it can be used for implementing a dialog that is centered on the screen. The outside value is more common and can be used for tooltips, popovers, menus, etc.

The second part of this string determines the edge on the anchor element that the floating element will be anchored to. If side is "inside-center", then the floating element will be centered in the X-direction (while align is used to position it in the Y-direction).
alignAnchorAlignment"start"Determines how the floating element should align with the anchor element. If set to "start", the floating element's first edge (top or left) will align with the anchor element's first edge. If set to "center", the floating element will be centered along the axis of the anchor edge. If set to "end", the floating element's last edge will align with the anchor element's last edge.
anchorOffsetnumber4*The number of pixels between the anchor edge and the floating element. Positive values move the floating element farther from the anchor element (for outside positioning) or further inside the anchor element (for inside positioning). Negative values have the opposite effect.
alignmentOffsetnumber4**An additional offset, in pixels, to move the floating element from the aligning edge. Positive values move the floating element in the direction of center-alignment. Negative values move the floating element away from center-alignment. When align is "center", positive offsets move the floating element right (top or bottom anchor side) or down (left or right anchor side).
allowOutOfBoundsbooleanfalseIf false, when the above settings result in rendering the floating element wholly or partially off-screen, attempt to adjust the settings to prevent this. Only applies to outside positioning.

First, attempt to flip to the opposite edge of the anchor if the floating element is getting clipped in that direction. If flipping results in a similar clipping, try moving to the adjacent sides.

Once we find a side that does not clip the overlay in its own dimension, check the rest of the sides to see if we need to adjust the alignment offset to fit in other dimensions.

If we try all four sides and get clipped each time, settle for overflowing and use the bottom side, since the ability to scroll is most likely in this direction.

* If side is set to "inside-center", this defaults to 0 instead of 4.

** If using outside positioning, or if align is set to "center", this defaults to 0 instead of 4.

AnchorSide

AnchorSide can be any of the following strings:

'inside-top', 'inside-bottom', 'inside-left', 'inside-right', 'inside-center', 'outside-top', 'outside-bottom', 'outside-left', 'outside-right'

AnchorAlignment

AnchorAlignment can be any of the following strings:

'start', 'center', 'end'

Best practices

As discussed above, the positioning algorithm needs to first measure the size of three different elements. Therefore, all three of these elements (anchor element, floating element, and the floating element's closest positioned container) must be rendered at the time getAnchoredPosition is called. To avoid a frame where the floating element is rendered at the (0, 0) position, give it a style of visibility: hidden until its position is returned at set. This allows the element to be measured without showing up on the page.

A note on performance

Every time getAnchoredPosition is called, it causes a reflow because it needs to query the rendering engine for the positions of 3 elements: the anchor element, the floating element, and the closest ancestor of the floating element that is positioned. Therefore, this function should not be called until it is needed (e.g. an overlay-style menu is invoked and displayed).

useAnchoredPosition hook (private to @primer/react)

The useAnchoredPosition hook is used to provide anchored positioning data for React components. The hook returns refs that must be added to the anchor and floating elements, and a position object containing top and left. This position is tracked as state, so the component will re-render whenever it changes. It is the responsibility of the consumer to apply the top and left styles to the floating element in question.

Using your own refs

The useAnchoredPosition hook will return two refs for the anchor element and the floating element, which must be added to their respective JSX. If you would like to use your own refs, you can pass them into the hook as part of the settings object (see the interface below).

Recalculating position

Like other hooks such as useCallback and useEffect, this hook takes a dependencies array. If defined, the position will only be recalculated when one of the dependencies in this array changes. Otherwise, the position will be calculated when the component is first mounted, but never again.

useAnchoredPosition usage

export const AnchoredPositionExample = () => {
const {floatingElementRef, anchorElementRef, position} = useAnchoredPosition({side: 'outside-bottom', align: 'center'})
return (
<div>
<Box
position="absolute"
top={position?.top ?? 0}
left={position?.left ?? 0}
width={150}
height={150}
ref={floatingElementRef as React.RefObject<HTMLDivElement>}
>
Floating element
</Box>
<Box borderWidth='1px' borderStyle='solid' borderColor='border.default' borderRadius={2} width={400} height={75} ref={anchorElementRef as React.RefObject<HTMLDivElement>}>
Anchor Element
</Box>
</div>
)
}

useAnchoredPosition

NameTypeDefaultDescription
settingsAnchoredPositionHookSettingsundefinedOptional settings to control how the anchored position is calculated. See below.
dependenciesReact.DependencyListundefinedDependencies to determine when to re-calculate the position. If undefined or [], only calculate the position once.

Return value

NameTypeDescription
floatingElementRefReact.RefObject<Element>This ref must be added to the floating element JSX
anchorElementRefReact.RefObject<Element>This ref must be added to the anchor element JSX
position{top: number, left: number}The calculated position

AnchoredPositionHookSettings interface

AnchoredPositionHookSettings is an object with an interface that extends PositionSettings (see above). Additionally, it adds the following properties:

NameTypeDefaultDescription
floatingElementRefReact.RefObject<HTMLElement>undefinedIf provided, this will be the ref used to access the element that will be used for the floating element. Its size measurements are needed by the underlying useAnchoredPosition behavior. Otherwise, this hook will create the ref for you and return it. In both cases, the ref must be provided to the floating element's JSX.
anchorElementRefReact.RefObject<HTMLElement>undefinedIf provided, this will be the ref used to access the element that will be used for the anchor element. Its position and size measurements are needed by the underlying useAnchoredPosition behavior. Otherwise, this hook will create the ref for you and return it. In both cases, the ref must be provided to the anchor element's JSX.