loading...
Verkko­kauppa.com

Container Queries And Element Resize Detection As We Enter 2020

merri profile image Vesa Piittinen ・11 min read

The idea behind container queries is seemingly simple: instead of having a media query that targets the whole viewport, target a single container element instead.

The simplicity of this idea is deceiving. While it seems simple for a case where you have a container with a set width, in CSS you're not limited to a condition like that. Instead, you'd have to take care of cases such as container element's size being determined by it's children. Which means that you can easily create infinite loops, circularity where child's size is adjusted by parent's size which is adjusted by child's size which is adjusted again by parent's size and so forth.

So far this problem has not been solved and thus we have no CSS standard and you can't find container queries over at Can I use despite having numerous JS libraries tackling the issue and even large and detailed proposals.

@media screen and (max-width: 499px) {
    .element { /* styles in mobile */ }
}
@media screen and (min-width: 500px) and (max-width: 999px) {
    .element { /* styles in tablet */ }
}

Then, why do we need container queries? It is likely that even 90% of use cases where a media query is now used would be better solved by a container query. A common issue with media query is that adding anything extra to the view, such as a sidebar, can cause a mismatch of earlier media query rules and you have to override the previous rules by adding some kind of indication that "hey, we have a sidebar of width X, increase used widths in media queries by X so that our element looks pretty when alongside the sidebar".

And working with that kind of logic in CSS is awful!

/* one way to solve the issue, using SCSS for some sanity... */
@media screen and (max-width: 499px) {
    .container[data-sidebar="off"] > .element { /* styles in mobile */ }
}
@media screen and (max-width: #{499px + $sidebarMobileWidth}) {
    .container[data-sidebar="on"] > .element { /* styles in mobile */ }
}
@media screen and (min-width: 500px) and (max-width: 999px) {
    .container[data-sidebar="off"] > .element { /* styles in tablet */ }
}
@media screen and (min-width: #{500px + $sidebarTabletWidth}) and (max-width: #{999px + $sidebarTabletWidth}) {
    .container[data-sidebar="on"] > .element { /* styles in tablet */ }
}

Now imagine if sidebar also has fluid width and some min-width rules in addition... or if you had far more breakpoints where deeper child elements adjusted their size as more space becomes available!

With container queries we wouldn't have this issue as the element sizing would be based on a container that would otherwise follow regular CSS rules in its own sizing. No need for workarounds via element attributes and no duplicated rules in CSS.

Do-It-Yourself Container Queries in JavaScript

As far as standards go we haven't got anything besides media queries to work with in CSS, however JavaScript world is a different story. A recent development has been ResizeObserver API which has support in Chrome, Firefox and Samsung Internet and there is a polyfill available for other browsers.

ResizeObserver is not the only way! There has been a hack that allows detecting resize events from an empty child page that has been sized via CSS to match the size of a container element. The idea is to have the container element with position other than static and then size a child <object data="about:blank" type="text/html" /> via position: absolute to be equal in size of it's parent. To make it invisible we can use clip: rect(0 0 0 0). The great part of this method is huge browser support as you don't need to worry about polyfilling anything.

Finally, the most typical implementation has been to listen for window resize events. This is not a perfect solution though as elements can resize even with no change in viewport size. This has been mostly used because there has been no knowledge of an alternative.

Let's go through how you can do it yourself with the two more viable options! And if you're not working with React, don't worry: there is information below that is valuable even without React-knowledge and we'll go through all the other non-DIY options as well! :)

DIY: ResizeObserver API

The first thing I want to point about this option is that always, when possible, you should use only one instance. In React world it seems fairly typical for people to create fully self-contained components, meaning that each component instance also creates all other things it uses. For performance reasons it is better to have as few ResizeObserver instances as possible!

componentDidMount() {
    // no re-use :(
    this.observer = new ResizeObserver(this.resize)
    this.observer.observe(this.element)
}

componentWillUnmount() {
    this.observer.disconnect()
}

// or in hooks
useEffect(() => {
    if (!element) return
    // no re-use :(
    const observer = new ResizeObserver(onResize)
    observer.observe(element)
    return () => {
        observer.disconnect()
    }
}, [element, onResize])

Instead you should create a single listener that is able to call related callbacks. This is easily achievable using WeakMap!

const callbackMap = new WeakMap()

function manageCallbacks(entries) {
    for (let entry of entries) {
        const callback = callbackMap.get(entry.target)
        if (callback) callback(entry.contentRect)
    }
}
// Babel changes `global` to `window` for client-side code
const observer = 'ResizeObserver' in global && new ResizeObserver(manageCallbacks)

// ... in component, assumes it is impossible for `this.element` reference to change
componentDidMount() {
    callbackMap.set(this.element, this.resize)
    observer.observe(this.element)
}

componentWillUnmount() {
    observer.unobserve(this.element)
    callbackMap.delete(this.element)
}

// probably a safer way to go, iirc React calls `ref` functions with `null` on unmount
getRef(el) {
    if (this.el === el) return
    if (this.el) {
        observer.unobserve(this.el)
        callbackMap.delete(this.el)
    }
    if (el) {
        callbackMap.set(el, this.resize)
        observer.observe(el)
    }
    this.el = el
}

The latter is also better option in that this.resize handler will receive a contentRect that has .width and .height directly available.

While the above is rather React-centric, I hope non-React devs do catch the API itself!

DIY: about:blank page inside object/iframe

With this method there are a couple of gotchas that one must be aware of, as this is a hack:

  1. Parent container must have position other than static.
  2. <object /> element must be hidden visually AND interactively.
  3. <object /> will mess up with some CSS by existing within the container, most likely :first-child or :last-child.
  4. Container should not have border or padding.

Taking all of the above into account the final CSS and HTML needed would look like this:

/* use clip, pointer-events and user-select to remove visibility and interaction */
object[data="about:blank"] {
    clip: rect(0 0 0 0);
    height: 100%;
    left: 0;
    pointer-events: none;
    position: absolute;
    top: 0;
    user-select: none;
    width: 100%;
}
<div style="position:relative">
    <object aria-hidden="true" data="about:blank" tabindex="-1" type="text/html"></object>
    <!-- here would be the elements that would be sized according to container -->
</div>

But it has to be noted it doesn't make much sense to serve this kind of client-only logic in HTML render, thus adding <object /> only in the browser via JavaScript makes much more sense than serving it in HTML. The biggest problem is that we need to wait for object.onload to trigger. The code for it:

object.onload = function() {
    const object = this
    function complete() {
        // wait for contentDocument to become available if not immediately here
        if (!object.contentDocument) setTimeout(complete, 50)
        else setElement(object.contentDocument.defaultView)
    }
    complete()
}

Here setElement would be a function which receives the element that you can listen to for resize events by using addEventListener. Most of the rest is all regular DOM manipulation with document.createElement and the like :)

How about no DIY?

Like for everything in the JavaScript world, there are a lot of solutions to go with on npm! The following list first puts focus on React-only solutions, after which you can find some solutions that work by extending CSS (with the help of JS, of course).

react-sizeme (8.2 kB minzipped)

This appears to be the most popular element size detection component out there. While quite performant, it's size is a weakness: 8 kB is a lot of stuff! And it still only gives you the size of the element: you still have to add your own logic if you want to set element className based on your breakpoints, for example.

react-measure (3.9 kB minzipped)

The next in popularity we can find react-measure which uses ResizeObserver. It provides more than just width and height, allowing you to get all the measurements of an element you might need. It's own size is also half compared to react-sizeme.

Other ResizeObserver based solutions

These React hooks are not popular, but both are minimalistic. react-element-size only focuses on providing width and height, nothing more. react-use-size provides a few more features.

Core weakness regarding their total size is the forced inclusion of a polyfill, although this is not unique to these hooks. It would be better if the polyfill wouldn't be included and be delegated as user developer's problem, as people might use service like polyfill.io to optimize the delivery of their polyfills. This is a case where library authors should forget about developer friendliness on a matter and just instruct devs to include polyfill whichever way suits them best, and not force a polyfill.

Another problem these hooks have is that they do not re-use ResizeObserver, instead making a new observer instance for each tracked element.

react-resize-aware (0.61 kB minzipped)

This tiny hook uses <iframe /> with about:blank and thus adds extra element into the HTML, forcing to include position: relative or equivalent style to a container element. Besides that it does just what is needed to provide width and height information. This is a very good option if you don't mind calculating matches to breakpoints on your own!

styled-container-query (5.6 kB minzipped)

As the first true Container Queries solution on the list we find an extension for Styled Components. This means you get a JS-in-CSS solution with :container pseudo selectors and you're allowed to write with no boilerplate!

As of writing this the downside of this library is that it has some performance issues, but I brought them up and I hope the library author gets them sorted out :)

Also, using objects and props callback support are not supported which takes a bit away from the usefulness of this solution. If you have knowledge about Styled Components and have time to help I'd suggest to go ahead and improve this one as the idea is great!

react-use-queries (0.75 kB minzipped)

Similar to react-resize-aware this hook has the same weakness of adding extra listener element to the DOM. The main difference between these utilities is that instead of width and height you can give a list of media queries. You can also match anything for output, not just strings, having a lot of power especially if you want or need to do more than just classNames.

As an advantage over react-resize-aware you have far less events triggering as react-use-queries makes use of matchMedia listeners instead of a resize event.

As final note: this one is by me :)

Non-React "write as CSS" solutions

I'd probably consider CSS Element Queries and CQ Prolyfill if I had to choose. Of these CSS Element Queries doesn't extend existing CSS at all and you don't need a post-processor, while CQ uses :container selector that feels very native CSS-like.

In comparison EQCSS seems like a syntax that won't get implemented, and Container Query seems like a lot of work to get into actual use - which might be partially due to how it's documentation is currently structured, giving a complete but heavy feel.

Ones to avoid

These have a little popularity, but the other options are simply better.

  • react-element-query: 8.7 kB and is now badly outdated, having had no updates in over two years, and is based on window resize event. The syntax is also opined towards breakpoints instead of queries so you get lot of code for a very few features.
  • remeasure: at 7.3 kB I'd pick react-measure over this one if I needed to have other measurements than width and height.
  • react-bounds: 7.9 kB and no updates in three years. Uses element-resize-detector like react-sizeme does.
  • react-component-query: 5.0 kB and depends on react-measure, you end up with less code implementing your own based on react-measure.
  • react-container-query: 6.5 kB only to get strings for className.
  • react-queryable-container: 1.9 kB but uses window resize event, thus avoid.

Further reading

Want to help?

In summary a typical issue with proposals and specs so far has been that they attempt to tackle too many things, having too many features without solving the core issue of circularity that would make implementing a standard into CSS a reality. I'd argue having more of this is something we don't need. Rather, solving the main issue requires someone to be able to dig in to the inner workings of CSS and browsers.

If you want to have a go at this check out WICG's Use Cases and Requirements for “Container Queries” as going through those can help greatly in shaping what really needs to be accomplished.

My tip for the interested: forget about all current syntaxes and media queries, instead try to find what is common and what is the need, as the real solution for those might be very different from measurement of sizes. Why? Because so far as a community all we've done is to bang our heads to the wall of circularity.

I hope circularity and browser render logic issues can eventually be figured out so that we get Container Queries, or a good alternative native CSS standard!

Posted on by:

merri profile

Vesa Piittinen

@merri

Web Front End Specialist who doesn't want to identify as Full Stack, but knows how to get stuff done. Loves perf and minimalism. HTML + CSS + Web Standards over JS. UX over DX. Hates div disease.

Verkko­kauppa.com

Finland's most popular e-commerce site.

Discussion

markdown guide