How can I get the containing block of a "fixed" positioned element with javascript?
Asked Answered
T

2

13

Let's say we have the following setup:

#header {
    background-color: #ddd;
    padding: 2rem;
}
#containing-block {
    background-color: #eef;
    padding: 2rem;
    height: 70px;
    transform: translate(0, 0);
}
#button {
    position: fixed;
    top: 50px;
}
<div id="header">header</div>
<div id="containing-block">
    containing-block
    <div>
      <div>
        <div>
          <button id="button" onclick="console.log('offsetParent', this.offsetParent)">click me</button>
        </div>
      </div>
    </div>
</div>

where the button has fixed position and the containing-block has a transform property in place.

This might come as a surprise, but the button is positioned relative to the #containing-block, not the viewport (as one would expect when using fixed). That's because the #containing-block element has the transform property set. See https://developer.mozilla.org/en-US/docs/Web/CSS/position#fixed for clarification.

Is there an easy way to find out which is the containing block of the button? Which is the element top: 50px is calculated in respect to? Assume you don't have a reference to the containing block and you don't know how many levels up it is. It may even be the documentElement if there are no ancestors with transform, perspective or filter properties set.

For absolute or relative positioned elements, we have elem.offsetParent which gives us this reference. However, it is set to null for fixed elements.

Of course, I could look up the dom and find the first element that has a style property of transform, perspective or filter set, but this seems hacky and not future proof.

Thanks!

Toucan answered 20/3, 2020 at 17:21 Comment(1)
On Firefox, offsetParent is returning the right value. Chrome and other browsers are reporting null but it seems to be a bug. I filed a bug report bugs.chromium.org/p/chromium/issues/detail?id=1086125Cahra
R
3

Known behavior and spec compliant. spec should probably be changed though.
https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent

I've included a few workarounds from various libraries.

Workaround taken from dom-helpers (seems to be most consistent and use of offsetParent to traverse means it should only ever really traverse once or twice.):
https://github.com/react-bootstrap/dom-helpers/blob/master/src/offsetParent.ts

// taken from popper.js
function getStyleComputedProperty(element, property) {
  if (element.nodeType !== 1) {
    return [];
  }
  // NOTE: 1 DOM access here
  const window = element.ownerDocument.defaultView;
  const css = window.getComputedStyle(element, null);
  return property ? css[property] : css;
}

getOffsetParent = function(node) {
  const doc = (node && node.ownerDocument) || document
  const isHTMLElement = e => !!e && 'offsetParent' in e
  let parent = node && node.offsetParent

  while (
    isHTMLElement(parent) &&
    parent.nodeName !== 'HTML' &&
    getComputedStyle(parent, 'position') === 'static'
  ) {
    parent = parent.offsetParent
  }

  return (parent || doc.documentElement)
}
#header {
    background-color: #ddd;
    padding: 2rem;
}
#containing-block {
    background-color: #eef;
    padding: 2rem;
    height: 70px;
    transform: translate(0, 0);
}
#button {
    position: fixed;
    top: 50px;
}
<div id="header">header</div>
    <div id="containing-block">
        containing-block
        <div>
          <div>
            <div>
              <button id="button" onclick="console.log('offsetParent', getOffsetParent(this),this.offsetParent)">click me</button>
            </div>
          </div>
        </div>
    </div>

Workaround code taken from jQuery source. Doesn't deal with non-element, nor TABLE TH TD, but it's jQuery. https://github.com/jquery/jquery/blob/master/src/offset.js

// taken from popper.js
function getStyleComputedProperty(element, property) {
  if (element.nodeType !== 1) {
    return [];
  }
  // NOTE: 1 DOM access here
  const window = element.ownerDocument.defaultView;
  const css = window.getComputedStyle(element, null);
  return property ? css[property] : css;
}

getOffsetParent = function(elem) {
  var doc = elem.ownerDocument;
  var offsetParent = elem.offsetParent || doc.documentElement;
  while (offsetParent &&
    (offsetParent !== doc.body || offsetParent !== doc.documentElement) &&
    getComputedStyle(offsetParent, "position") === "static") {

    offsetParent = offsetParent.parentNode;
  }
  return offsetParent;
}
#header {
    background-color: #ddd;
    padding: 2rem;
}
#containing-block {
    background-color: #eef;
    padding: 2rem;
    height: 70px;
    transform: translate(0, 0);
}
#button {
    position: fixed;
    top: 50px;
}
<div id="header">header</div>
    <div id="containing-block">
        containing-block
        <div>
          <div>
            <div>
              <button id="button" onclick="console.log('offsetParent', getOffsetParent(this),this.offsetParent)">click me</button>
            </div>
          </div>
        </div>
    </div>

Workaround code taken from popper.js. Doesn't seem to get doc.body right. The only one that specifically deals with TH TD TABLE. dom-helpers should work just because it uses offsetParent to traverse. https://github.com/popperjs/popper-core/blob/master/src/dom-utils/getOffsetParent.js

var isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined' && typeof navigator !== 'undefined';

const isIE11 = isBrowser && !!(window.MSInputMethodContext && document.documentMode);
const isIE10 = isBrowser && /MSIE 10/.test(navigator.userAgent);
function isIE(version) {
  if (version === 11) {
    return isIE11;
  }
  if (version === 10) {
    return isIE10;
  }
  return isIE11 || isIE10;
}

function getStyleComputedProperty(element, property) {
  if (element.nodeType !== 1) {
    return [];
  }
  // NOTE: 1 DOM access here
  const window = element.ownerDocument.defaultView;
  const css = window.getComputedStyle(element, null);
  return property ? css[property] : css;
}

function getOffsetParent(element) {
  if (!element) {
    return document.documentElement;
  }

  const noOffsetParent = isIE(10) ? document.body : null;

  // NOTE: 1 DOM access here
  let offsetParent = element.offsetParent || null;
  // Skip hidden elements which don't have an offsetParent
  while (offsetParent === noOffsetParent && element.nextElementSibling) {
    offsetParent = (element = element.nextElementSibling).offsetParent;
  }

  const nodeName = offsetParent && offsetParent.nodeName;

  if (!nodeName || nodeName === 'BODY' || nodeName === 'HTML') {
    return element ? element.ownerDocument.documentElement : document.documentElement;
  }

  // .offsetParent will return the closest TH, TD or TABLE in case
  // no offsetParent is present, I hate this job...
  if (['TH', 'TD', 'TABLE'].indexOf(offsetParent.nodeName) !== -1 && getStyleComputedProperty(offsetParent, 'position') === 'static') {
    return getOffsetParent(offsetParent);
  }

  return offsetParent;
}
#header {
    background-color: #ddd;
    padding: 2rem;
}
#containing-block {
    background-color: #eef;
    padding: 2rem;
    height: 70px;
    transform: translate(0, 0);
}
#button {
    position: fixed;
    top: 50px;
}
<div id="header">header</div>
<div id="containing-block">
    containing-block
    <div>
      <div>
        <div>
          <button id="button" onclick="console.log('offsetParent', getOffsetParent(this))">click me</button>
        </div>
      </div>
    </div>
</div>
Risinger answered 26/5, 2020 at 13:45 Comment(0)
Z
0

I recently crafted what I feel to be a rather elegant workaround to this not-so-little, long-standing quirk. I've designed a CustomElement that can automatically detect if it has been used inside of a containing block and, if so, shift itself from it's current location in the DOM to the end of the body element.

Credit to this answer to a similar question for pointing me in the right direction. https://mcmap.net/q/908915/-direct-native-javascript-or-jquery-method-to-get-containing-block-of-an-element

<!DOCTYPE html>
<title> Breakout Fixed </title>
<script type="module">
  customElements.define(
    'breakout-fixed',
    
    class BreakoutFixed extends HTMLElement {
      constructor() {
        super();
        this.attachShadow({ mode : 'open' });
        this.shadowRoot.innerHTML = this.template;
      }
      
      get template() {
        return `
          <style> :host { position: fixed; } </style>
          <slot></slot>
        `; 
      }
      
      breakout() {
        const el = this;

        if (this.fixed !== true) {
          window.addEventListener('resize', el.fix);
          this.fixed = true;
        }
        
        if (el.parentNode == document.body) { return; }

        function shift() {
          getContainingBlock(el) &&
            document.body.append(el);
        }

        function getContainingBlock(node) {
          if (node.parentElement) {
            if (node.parentElement == document.body) {
              return document.body;
            } else if (testNode(node.parentElement) == false) {
              return getContainingBlock(node.parentElement);
            } else { return node.parentElement; }
          } else { return null; }
          function testNode(node) {
            let test; let cs = getComputedStyle(node);
            test = cs.getPropertyValue('position'); if ([
              'absolute', 'fixed'
            ].includes(test)) { return true; }
            test = cs.getPropertyValue('transform');   if (test != 'none')  { return true; }
            test = cs.getPropertyValue('perspective'); if (test != 'none')  { return true; }
            test = cs.getPropertyValue('perspective'); if (test != 'none')  { return true; }
            test = cs.getPropertyValue('filter');      if (test != 'none')  { return true; }
            test = cs.getPropertyValue('contain');     if (test == 'paint') { return true; }
            test = cs.getPropertyValue('will-change'); if ([
              'transform', 'perspective', 'filter'
            ].includes(test)) { return true; }
            return false;
          }
        }
      }
      
      connectedCallback() {
        this.breakout();
      }
    }
  );
</script>
<style>
  body { background: dimgrey; }
  
  #container {
    height: 300px;
    width: 50%;
    background: dodgerblue;
    transform: scale(2);
  }
  
  div#test {
    position: fixed;
    right: 0;
    bottom: 0;
    padding: 1rem;
    background: red;
  }
  
  breakout-fixed {
    top: 0; right: 0;
    padding: 1rem;
    background: limegreen;
    transform: scale(3);
    transform-origin: top right;
  }
</style>
<div id="container">
  <div id="test"> This element will be fixed to it's containing block. </div>
  <breakout-fixed>
    <div> This element will be fixed to the viewport. </div>
  </breakout-fixed>
</div>
Zitella answered 8/4, 2021 at 22:2 Comment(2)
There is a typo in the will-change check, filer instead of filterPolygamous
Good catch, thanks! I've updated the code and also added a window resize handler in case media query changes cause a containing block to be created (for example using scale() in a responsive design). There are probably other cases I haven't covered but this should be a good base for anyone looking to overcome this strange browser behavior.Zitella

© 2022 - 2024 — McMap. All rights reserved.