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

Viewed 790

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!

2 Answers

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>

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://stackoverflow.com/a/65155438/6036546

<!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>
Related