Skip to content
AtomicReps

A container cannot query itself

A lesson from Modern CSS: What The Browser Took Back. Play it above, or read it through below.

  • 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. 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 up there, so there is nothing eligible to evaluate the condition against, and the declarations never reach the thread. Measured on the pinned engines: getComputedStyle(thread).gridTemplateColumns returns "none" while all four replies return "48px 552px", and the control that adds 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 never came up with media queries, and that is worth saying plainly rather than treating the reader as careless: 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 collide. Container queries put both jobs in your tree, and the moment they land on one element the rule goes quiet.

    Quiet is the whole problem. Nothing throws, nothing warns, the console stays empty in all three engines, and the rule is sitting in the CSSOM as a live CSSContainerRule the whole time. Take the same fixture and delete container-type altogether and you get the same silence with a wider blast radius: no eligible container anywhere means no styled element anywhere, five of five unchanged, still with an empty console.

    So the element that carries container-type is not a candidate for the rules it enables, and the fix is structural rather than clever. 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 element measures zero.

    Measured on the pinned engines, 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 precisely the permission a query container has to grant, because a box whose width depends on its contents cannot be asked its width by those same contents without the answer chasing itself. Container queries ship in every browser, and so does the containment that pays for them.

    You reach for the declaration on a component root, and the accurate one-line version is that

    Which makes the placement decision a sizing decision, not a bookkeeping 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 deleted the thing that was sizing it.

  • 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.

    It 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 paid the maintenance: unobserve on teardown, re-observe when the row is recycled, and one layout read per entry per frame that has to stay off the write path.

    Every line of it is bookkeeping around a number the layout engine computed anyway. The replacement declares the same breakpoint against the same box and ships nothing to run: container-type: inline-size on the slot, and one @container rule holding the wide layout. 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 it is in.

    One thing does not survive the translation, and it is 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 honest price of the deletion. Add the wrapper, or hand the containment to the layout that already places you.

  • @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.

    That is the feature working, not failing. It is also why a rule written against one 900px slot reaches every 900px slot in the document, and why nothing in the stylesheet records which one you meant.

    container-name is where you record it. 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 is the receipt that the name is doing the selecting rather than the size. Both of those were measured on the pinned engines 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; it is a query with no way to say what it wanted.

  • cqi is one percent of the container. vw has never heard of 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 sits 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?

    36px, and nothing. 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 on the pinned engines, 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 where 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 say vw.

    What changes is which box you are naming: vw names the window from inside a component that has no idea where it was 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 vw for the things that genuinely belong to the window, full-bleed sections and viewport-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 genuinely never moves, which is the true half of the belief and the reason it survives: 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.

    What moved is the element's checkedness, and :checked matches on that. :has() then hands the match up the tree 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.

    Which deletes the second pattern this lesson is about. The state was hoisted for one reason: a parent needed a class when something inside it changed, and only React knew when that was. It does not need the class and React does not need to know. The rules that used to hang off .is-selected hang off :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 are actually about behaviour.

    Every dynamic pseudo-class you already trust works the same way inside it: :invalid on a field styles the field group, :focus-visible on an input styles the row, :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?

    Not the component. The element that carries the containment is never styled by the rules it enables, so a card that declares its own containment and then queries it is a card that has quietly stopped responding to anything, and that is the single most likely way this feature fails on its first day in your codebase.

    Ship it 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. That wrapper is not a hack, it is the price of a query about somebody else's box.

    The element that knows the width is the one element that cannot ask.

    :has() needs no such arrangement, because the ancestor it needs is the one you already have. Between them the two features cover both directions a component has to look: outward at the space it was given, inward at the state it contains. So when you find a class name in your own repo standing in for one of those two lookups, delete the wiring and let the rule ask the question directly, and accept the one wrapper element that the outward direction sometimes costs you.