CSS has has!

Written by Vincent Bruijn

The CSS feature :has() is a very powerful addition to selectors. This article is about the evolution of feature detection code that is production ready!

A developer told me he was unable to properly detect the support for CSS :has() and hence wouldn’t want to use it. The context was a fix for an issue by which a parent element should have a different size based on the presence of a certain child element. This sounded to me the perfect situation for the use of :has(), so I didn’t want to agree with him immediately.

I started out with a nice solution, but it was quite verbose, and while implementing it, I discussed the feature detection code with a different developer, which lead to a follow up implementation. This in turn made me realize I could approach the feature detection in a different way too, which eventually lead to a simple end result that is production ready.

The following code samples basically show the evolution of a feature detection script. I know some are blunt or simplistic, and would I have better read MDN I would have turned to the end solution immediately, but it is always nice to reflect on your own line of thought.

(function () {
  const s = document.createElement('style');
  s.textContent = `.supportsHas:has(p):after {content: "hasHas";}`;
  document.head.appendChild(s);

  const supportsHas = document.createElement('div');
  supportsHas.classList.add('supportsHas');

  const p = document.createElement('p');
  supportsHas.appendChild(p);

  document.body.appendChild(supportsHas);

  if (getComputedStyle(document.body, 'after')?.content === '"hasHas"') {
    console.log('supports :has()');
  } else {
    console.log('does not suport :has()');
  }

  document.head.removeChild(s);
  document.body.removeChild(supportsHas);
})();

The above sample (exhibit A) is an IIFE (Immediately Invoked Function Expression), which can be wrapped around any of the following pieces of code, but is not a necessity. Within the IIFE, we create a style element containing CSS that makes use of :has() on an :after pseudo-element, adding content to it via the content property. This style element is injected into the document.head.

Afterwards I create a DOM snippet that would match the CSS from the style element and inject that into the DOM too. Using getComputedStyle, I retrieve the current state of the DOM and check whether the content property indeed contains hasHas.

Eventually we clean up the DOM, as we know whether :has() is supporeted or not and we do not need the injected code any more. This code is quite extensive, it’s a bit too much. It works, but we can do better.

After discussion of the above code, a developer told me I’d better query the :after pseudo element to read its content property, instead of using getComputedStyle, as the latter causes a reflow / recalculation. But document.querySelector cannot be used for querying pseudo elements, so this was a no-go. Though his suggestion actually lead me to the following code (exhibit B), which is way simpler:

(function () {
  window.supportsCSSHas = false;
  try {
    document.querySelector('body:has(div)');
    console.log('supports :has()');
    window.supportsCSSHas = true;
  } catch {
    console.log('does not support :has()');
  }
})();

Before actually querying the DOM, the browser will verify the contents of the query supplied, in order to see if it is proper CSS. If a browser does not support :has(), the query will be invalid and the browser will throw an error. Relying on try {} catch {} here makes it simple to silently detect the :has() feature.

Doing a little code golfing on the above leads to the following minimal detection code (exhibit C):

(function (w, d, p) {
  w[p] = !1;
  try {
    d.querySelector('html:has(head)');
    w[p] = !0;
  } catch {}
})(window, document, '_supportsCSSHas');

Using html:has(head) is the most robust, as all browsers will create a minmal DOM tree even when the response of a URL is empty.

When you use above code sample exhibit C in combination with recent CSS @supports addition, one can make a robust feature detection and fallback solution (exhibit D):

@supports selector(A:has(b)) {
  html:has(body) {
    color: #fff; /* or some other styling of course*/
  }
}

The essential part of exhibit D is its first line: one can check the support of a selection feature by wrapping your selector in the selector() function using placeholder elements (kind of generics).

(function (d, c) {
  try {
    d.querySelector('html:has(head)') && d.documentElement.classList.add(c);
  } catch {}
})(document, 'hasHas');
/* this CSS will by applied in Browsers that do support :has() */
@supports selector(A:has(b)) {
  html:has(body) {
    color: #fff; /* or some other styling of course */
  }
}

/* this is the CSS for browsers that do not support :has() */
html.hasHas body {
  color: #fff;
}

In conclusion: since not all browsers support :has(), it is good to rely on feature detection. By discussing my intial solution I came to new insights about the approach to follow and ended up with a production ready solution.