Skip to content
AtomicReps

Your ResizeObserver measures a width the browser already has

A lesson from Modern CSS: Features That Replace JavaScript. Play it above, or read it through below.

Level I · CSS that replaces JavaScript
  • A container cannot query itself.

    The common read: container-type marks an element as queryable, so a @container rule styles that element too once it is wide enough.

    Four elements get the layout. The four replies each resolve the condition against the thread, which is 600px wide, and the thread resolves it against nothing at all. A @container rule with no name is evaluated against the nearest ANCESTOR query container of the element being styled, and no element is an ancestor of itself, so the search for the thread's container starts at the thread's parent and walks up.

    There is no query container above the thread, so there is nothing eligible to evaluate the condition against, and the declarations never apply to the thread. Measured in Chromium, Firefox and WebKit: getComputedStyle(thread).gridTemplateColumns returns "none" while all four replies return "48px 552px", and adding one query container above the thread turns the thread's own value into "48px 552px" and takes the count to five of five, without changing the rule, the selector or a single declared width.

    This problem never came up with media queries: the thing a media query asks about is the viewport, the viewport is above every element on the page, and so "the element I am styling" and "the box I am asking about" could not be the same element. With container queries both boxes are in your tree, and when one element does both jobs, the rule does not style that element and reports no error.

    Nothing throws, nothing warns, the console stays empty in all three engines, and the rule is present in the CSSOM as a live CSSContainerRule the whole time. Delete container-type from the thread altogether and all five elements go unstyled, again with no error, because no element has an eligible container.

    So the element that carries container-type is not a candidate for the rules it enables, and the fix is a change to the element tree. Give the styled thing a parent, put container-type on the parent, and let the rule target the child.

  • container-type is a sizing decision, not a label.

    The common read: container-type is a label like a data attribute, so adding it to anything costs nothing.

    Add container-type: inline-size to an element whose width came from its own content, and the content stops counting: the element shrinks to the width of its padding and border, which is zero when it has neither.

    Measured in Chromium, Firefox and WebKit, one declaration apart: a status tag with inline-size: fit-content reads 133.563px wide in Chromium and WebKit and 133.783px in Firefox, and the same tag with container-type: inline-size added reads 0 in all three, in a run where nothing else changed. Inline-size containment is the reason.

    The browser is being told that this element's inline size can be computed in isolation, ignoring the child elements, which is the guarantee a query container has to provide, because a box whose width depends on its contents cannot be asked its width by those same contents without a circular dependency. Every browser supports container queries and the containment they require.

    You add the declaration to a component root, and the accurate one-line version is that

    So where you put the declaration is a sizing decision, not a labelling one. Put container-type on an element whose width already comes from the layout (a column, a rail, a grid area, a slot), and it costs you nothing you were using. Put it on a shrink-to-fit box and you have removed the content-driven sizing that gave it a width.

  • The browser already measured the container. Delete the observer.

    The common read: CSS cannot know how wide a component's slot is, so a component that adapts to its slot needs a ResizeObserver and a width class.

    The observer exists to answer one question, and the browser already knows the answer.

    Before container queries, the observer was the only way to ask. A component that had to change shape by its own width, rather than by the window's, had no CSS to express that in, so you observed the box, compared the number to a breakpoint, toggled a class, and then maintained that code: unobserve on teardown, re-observe when the row is recycled, and a class toggle inside the callback that changes the layout the observer just measured. Every line of that code manages a number the layout engine computed anyway.

    The replacement declares the same 400px breakpoint against the slot the row sits in, not the row itself, and runs no JavaScript: container-type: inline-size on the slot, and one @container rule holding the wide layout. The two widths are equal when the row fills the slot and has no padding or border of its own. The class disappears, the observer disappears, and the component behaves the same in a 320px rail and a 900px main column with no code path deciding which slot it is in.

    One part of the observer version cannot be translated, because of the rule this lesson opened with. The observer watched the row and toggled the class ON THE ROW, because JavaScript has no rule against an element asking about itself. The CSS version cannot do that, so the containment goes on the slot and the rule targets the row inside it.

    If your component owns its own outermost element and nothing above it is yours, you need one more element in the tree, and that element is the cost of deleting the observer. Add the wrapper, or put the containment on the layout element that already positions your component.

  • @container is a condition, not a scope.

    The common read: An @container block is scoped to the container I set up, so its rules only reach elements inside that container.

    The block is not a region of the page and it is not attached to the container you were thinking of: it is a condition re-evaluated for every element the selector matches, against that element's own nearest ancestor query container. The rail's conversation and the main column's conversation are the same selector under the same rule, and they get different answers because they have different ancestors.

    This is the behaviour the specification defines. The same behaviour is why a rule written against one 900px slot applies in every 900px slot in the document, and why nothing in the stylesheet records which one you meant.

    container-name records which container you meant. Name the slot, put the name in the query, and the condition stops considering any other container: with container-name: rail on the rail and the query written @container rail (min-width: 700px), both conversations go unstyled, because the only container the rule will now look at is 320px wide.

    Change the threshold to 300px and the rail's conversation is styled while the main column's stays untouched at 900px, which shows that the name decides the match rather than the size. Both of those were measured in Chromium, Firefox and WebKit and both were identical in all three.

    Name the container as soon as a second one exists anywhere in the document, and treat the unnamed form as a convenience for a page with exactly one. The unnamed form is not wrong and it is not slower, but nothing in it says which container it is meant for.

  • cqi is one percent of the container. vw is relative to the viewport, never the container.

    The common read: cqi is vw with a different name, so a component sized in cqi still tracks the window.

    A preview bar sized 10cqi is in a 360px rail. The window is 1400px wide. Which number does the browser use, and what happens to it when the window drops to 768px?

    The browser uses 36px, and the value does not change. Container query units resolve against the nearest query container the same way the condition does, so 10cqi is one tenth of the rail's inline size and the window is not part of the calculation.

    Measured at three viewport widths in Chromium, Firefox and WebKit, the 10cqi bar is 36 at 1400px, 36 at 1024px and 36 at 768px, while a 10vw bar beside it in the same rail reads 140 in all three engines at 1400px, then 102.391 and 76.797 in Chromium and WebKit while Firefox reads 102.4 and 76.8. Neither unit is the better one, and a component that wants to grow with the window should still use vw.

    The two units differ in which box they measure: vw measures the window wherever the component is placed, which is how a card built for a full-width hero ends up with 140px of padding inside a 360px rail.

    So size the parts of a component that belong to the component in cqi, and keep viewport units (vw, vh) for the things that belong to the window, such as full-bleed sections and full-height layouts. The test is one question you can ask about any declaration: if somebody drops this component into a narrower slot tomorrow, should this number move?

  • :has() reads the state, not the markup.

    The common read: The checked attribute never changes, so CSS cannot see the click and something has to toggle a class.

    :checked was never about the attribute, and :has() re-runs whenever the thing it is looking at changes, so the row restyles on the click with nothing loaded on the page. The attribute never changes, which is the true half of the belief and the reason people keep holding it: input.getAttribute("checked") returns null after the click, and the same rule written against [checked] stays at "0px" through the identical click, measured in the same run.

    The element's checkedness changed, and :checked matches on that. :has() then applies that match to the ancestor, which is the part that had no CSS expression until recently and is the reason the state was ever lifted. :has() ships in every browser.

    :has() makes the second pattern this lesson is about unnecessary. The state was hoisted for one reason: a parent needed a class when something inside it changed, and only React knew when that was. The parent no longer needs the class, and React no longer needs to know. The rules that used to select on .is-selected select on :has(:checked) instead, the bulk-actions bar appears with .conversation-list:has(.conversation__select:checked), and the component keeps whatever state it has for reasons that concern behaviour.

    Other pseudo-classes you already use work the same way inside :has(): :invalid on a field styles the field group, :focus-visible on an input styles the row, and :empty on a list styles the panel around it. Take the state out of the component when the only consumer of that state was a class name, and leave it alone when something other than a stylesheet reads it.

  • Put container-type on the slot and :has() on the ancestor you already have.

    The common read: The component that has to adapt is the component that gets container-type, so I put the declaration on the card and write the query inside the card's own file.

    So which element carries container-type when the component is the thing that has to adapt, and the component is all you own?

    The component's outermost element can carry it only when every @container rule in the component targets elements inside that element. The element that carries the containment is never styled by the rules it enables, so a card that declares containment on its root and then writes an @container rule for .card itself sees no change on the root, and no error.

    Set it up the other way around: the layout declares the containment on the slots it creates, because a slot is a box whose width already comes from the layout and therefore has nothing to lose by being contained, and the component writes @container rules against a named container it expects to find above it. Where you own no slot, add one element and say so in the component's own file. The wrapper is not a hack: a query about a box you do not own needs that extra element.

    The element whose width the query measures is the one element the query cannot style.

    :has() needs no such arrangement, because the ancestor it needs is the one you already have. Between them the two features cover both things a component has to respond to: the width of the slot it is placed in, and the state of the elements inside it.

    So when you find a class name in your own repo that exists only to carry one of those two facts, delete the code that sets the class, write the container query or the :has() rule instead, and accept the one wrapper element that the slot-width case sometimes needs.