DEV Community

Cover image for Container Queries: Another Polyfill
Mads Stoumann
Mads Stoumann

Posted on • Edited on

Container Queries: Another Polyfill

I love container queries — I have been waiting for them for years.

But, alas, until all browsers have implemented them, we have to rely on polyfills to make them work.

While other polyfills work just fine, I needed something that didn't require postCSS or a specific syntax – and more tailored to a project, I'm currently working on.

So I decided to make my own polyfill, and ended up with a script, that's just 502 bytes gzipped:

if(!("CSSContainerRule"in window)){const e=(e,s)=>e.reduce((e,t,c)=>s(t)?c:e,-1),s=new ResizeObserver(s=>{for(let t of s){const s=t.target,c=s.__cq,n=e(c.bp,e=>e<=t.contentRect.width);n!==s.index?(s.style.cssText=c.css.filter((e,s)=>s<=n).join(""),c.index=n):-1===n&&s.removeAttribute("style")}});[...document.styleSheets].map(e=>{fetch(e.href).then(e=>e.text()).then(e=>{let t,c=new Set;const n=/@container\s?\(min-width:\s?(?<breakpoint>.*)px\)\s?\{\s?(?<selector>.*)\s?\{\s?(?<css>.*;)\s?\}/gm;for(;t=n.exec(e);)[...document.querySelectorAll(t.groups.selector)].forEach(e=>{e.__cq=e.__cq||{bp:[],css:[],index:-1};const s=t.groups.breakpoint-0,n=t.groups.css,o=e.__cq.bp.findIndex(e=>e===s);o<0?(e.__cq.bp.push(s),e.__cq.css.push(n)):e.__cq.css[o]=e.__cq.css[o].concat(n),c.add(e)});for(let e of c)s.observe(e)})})}
Enter fullscreen mode Exit fullscreen mode

OK, that's completely unreadable, so let's set up the stage with HTML and CSS, before we look at the script!


Setting the stage

In HTML, add this to a new document:

<main>
  <div class="cqw"><div class="cq cq1"></div></div>
  <div class="cqw"><div class="cq cq2"></div></div>
  <div class="cqw"><div class="cq cq3"></div></div>
  <div class="cqw"><div class="cq cq4"></div></div>
</main>
Enter fullscreen mode Exit fullscreen mode

In the <head>-section, add a link to a stylesheet:

<link href="cq.css" rel="stylesheet">
Enter fullscreen mode Exit fullscreen mode

Now, create the cq.css-sheet:

body {
  margin: unset;
}
main { 
  display: flex;
  flex-wrap: wrap;
}
.cq {
  aspect-ratio: var(--asr, 1);
  background-color: var(--bgc, silver);
  width: var(--w, 25vw);
}
.cqw {
  contain: layout inline-size;
}
.cq1 { --bgc: tomato }
.cq2 { --bgc: orange }
.cq3 { --bgc: skyblue }
.cq4 { --bgc: tan; }

@container (min-width: 300px) { .cq { --asr: 2/1; } }
@container (min-width: 300px) { .cq1 { --bgc: indianred; } }
@container (min-width: 300px) { .cq2 { --bgc: darkorange; } }
@container (min-width: 300px) { .cq3 { --bgc: steelblue; } }
@container (min-width: 300px) { .cq4 { --bgc: lavender; } }
@media (min-width: 600px) { .cq { --w: 50vw; } }
@media (min-width: 900px) { .cq { --w: 25vw } }`
Enter fullscreen mode Exit fullscreen mode

Your page should now look like this:

CQ example 2


The Script

First we need to check whether we need the script or not:

if (!('CSSContainerRule' in window))
Enter fullscreen mode Exit fullscreen mode

Next, we'll iterate the stylesheets on the page, grab them (again, but they are cached) with fetch(), convert the result with .text() and return the rules as a string:

[...document.styleSheets].map(sheet => {
  fetch(sheet.href)
    .then(css => css.text())
    .then(rules => { ... }
Enter fullscreen mode Exit fullscreen mode

We'll use regEx to find what we need in that string:

const re = /@container\s?\(min-width:\s?(?<breakpoint>.*)px\)\s?\{\s?(?<selector>.*)\s?\{\s?(?<css>.*;)\s?\}/gm
Enter fullscreen mode Exit fullscreen mode

NOTE: A good place to play around with RegEx, is regex101.com

This expression will return groups of matches entitled breakpoint, selector and css.

Now, let's iterate the matches. For each match, we'll use a querySelectorAll to find the elements in the DOM matching the selector.

On each element, we'll create an object, __cq that will contain an array of breakpoints, the css for each breakpoint, and an index. For each iteration, we'll check whether the object already exists:

let match;
let observe = new Set();
while (match = re.exec(rules)) {
  [...document.querySelectorAll(match.groups.selector)].forEach(elm => {
    elm.__cq = elm.__cq || { bp: [], css: [], index: -1 }
    const bp = match.groups.breakpoint-0;
    const css = match.groups.css;
    const index = elm.__cq.bp.findIndex(item => item === bp);
    if (index < 0) {
      elm.__cq.bp.push(bp);
      elm.__cq.css.push(css);
    }
    else {
      elm.__cq.css[index] = elm.__cq.css[index].concat(css);
    }
    observe.add(elm);
  })
}
Enter fullscreen mode Exit fullscreen mode

A Set() called observe is used to hold the (unique) set of elements, we'll need to observe:

for (let item of observe) RO.observe(item);
Enter fullscreen mode Exit fullscreen mode

RO is a ResizeObserver:

const RO = new ResizeObserver(entries => {
  for (let entry of entries) {
    const elm = entry.target;
    const cq = elm.__cq;
    const lastIndex = findLastIndex(cq.bp, item => item <= entry.contentRect.width);
    if (lastIndex !== elm.index) {
      elm.style.cssText = cq.css.filter((item, index) => index <= lastIndex).join('');
      cq.index = lastIndex;
    }
    else if (lastIndex === -1) elm.removeAttribute('style');
  }
});

Enter fullscreen mode Exit fullscreen mode

It's using a small method called findLastIndex:

const findLastIndex = (items, callback) => items.reduce((acc, curr, index) => callback(curr) ? index : acc, -1);
Enter fullscreen mode Exit fullscreen mode

... and use that to determine which breakpoint (bp) is currently needed, and then sets the style>-attribute of the element to the css from the __cq-object.

Here's the complete script — add this or the minified version above to a <script>-tag on your demo-page:

if (!('CSSContainerRule' in window)) {
  const findLastIndex = (items, callback) => items.reduce((acc, curr, index) => callback(curr) ? index : acc, -1);
  const RO = new ResizeObserver(entries => {
    for (let entry of entries) {
      const elm = entry.target;
      const cq = elm.__cq;
      const lastIndex = findLastIndex(cq.bp, item => item <= entry.contentRect.width);
      if (lastIndex !== elm.index) {
        elm.style.cssText = cq.css.filter((item, index) => index <= lastIndex).join('');
        cq.index = lastIndex;
      }
      else if (lastIndex === -1) elm.removeAttribute('style');
    }
  });

  [...document.styleSheets].map(sheet => {
    fetch(sheet.href)
      .then(css => css.text())
      .then(rules => {
        let match;
        let observe = new Set();
        const re = /@container\s?\(min-width:\s?(?<breakpoint>.*)px\)\s?\{\s?(?<selector>.*)\s?\{\s?(?<css>.*;)\s?\}/gm
        while (match = re.exec(rules)) {
          [...document.querySelectorAll(match.groups.selector)].forEach(elm => {
            elm.__cq = elm.__cq || { bp: [], css: [], index: -1 }
            const bp = match.groups.breakpoint-0;
            const css = match.groups.css;
            const index = elm.__cq.bp.findIndex(item => item === bp);
            if (index < 0) {
              elm.__cq.bp.push(bp);
              elm.__cq.css.push(css);
            }
            else {
              elm.__cq.css[index] = elm.__cq.css[index].concat(css);
            }
            observe.add(elm);
          })
        }
        for (let item of observe) RO.observe(item);
      }
    )
  })
}

Enter fullscreen mode Exit fullscreen mode

Now, when you resize your page, the boxes change aspect-ratio and background-color:

CQ example 2

At 900px the layout returns to it's initial values, and then at 1200px it's back to the updated values.

NOTE: It's mobile first, thus only min-width will work, and only with pixels as it's value, since the contentRect.width of the ResizeObserver returns a value in pixels.

I'm sure there's a ton of stuff that could be optimized or changed/added (error-handling, for instance!) — after all, this is something I cooked up in 3-4 hours!

The Codepen below works best, if you open/edit it on Codepen, and resize the browser:

Thanks for reading!


Cover-image by Pixabay from Pexels

Top comments (4)

Collapse
 
vuongngo profile image
Van Vuong Ngo • Edited

Hi, thanks a lot for sharing. I tried your HTML and CSS w/o your polyfill because "chrome canary" supports container query by enable it in the chrome://flags. I have to modify the css to see a responsive output ... but then when I tried in standard "chrome" browser then your polyfill only handles the first entry of the container query (.cq1).

.cqw {
contain: layout inline-size style;
}

/*
main {
display: flex;
flex-wrap: wrap;
}
*/

@container (min-width: 300px) {
.cq1 { --bgc: indianred; }
.cq2 { --bgc: green; }
.cq3 { --bgc: steelblue; }
.cq4 { --bgc: lavender; }
}

Collapse
 
madsstoumann profile image
Mads Stoumann

Hi,
I just tested in Chrome, Firefox and Safari without any issues? I had to add a height to the .cq-class, as aspect-ratio is not supported in other browsers than Chrome. Please note that the code in the Pen is slightly updated, to support inline <style>-tags.
Best wishes,
Mads

Collapse
 
the_yamiteru profile image
Yamiteru

Can we use this in picture tag in srcset?

Collapse
 
madsstoumann profile image
Mads Stoumann

Good question, but no, not with this polyfill! More here: github.com/w3c/csswg-drafts/issues...