Container queries, and the component that stopped needing a prop

A card component with a variant prop taking default or compact, passed at eighteen call sites, whose only purpose was to tell the card how much horizontal space it had. A sidebar added in June rendered every card in it wrongly, because nobody had passed the prop.

The symptom

<!-- 18 call sites, and the prop is a fact about the
     surroundings rather than about the card -->
<ProductCard :product="p" variant="compact" />
<ProductCard :product="p" />
<ProductCard :product="p" variant="compact" />

<!-- and the new sidebar, June -->
<aside class="sidebar">
  <ProductCard :product="p" />   <!-- 280px wide. broken. -->
</aside>
/* what the media query was doing, and why it could not work */
@media (max-width: 60rem) {
  .card { grid-template-columns: 1fr; }
}

/* the viewport is 1600px. the sidebar is 280px.
   the media query says: plenty of room. */

The media query is answering a question about the window and the component is asking a question about itself. Those coincide when a component spans the page and diverge the moment anything is placed in a column.

Why it happens

Until 2023 there was no way to ask how much space a component had. The prop is the workaround, and it works by making the parent responsible for knowing something the child should be able to observe.

The fix

Establishing a container

/* the parent declares itself measurable */
.card-slot {
  container-type: inline-size;
  container-name: card;
}

/* and the card responds to it */
.card { display: grid; grid-template-columns: 1fr; gap: var(--space-s); }

@container card (min-width: 30rem) {
  .card { grid-template-columns: 8rem 1fr; }
}

Naming the container is optional and worth doing: an unnamed query matches the nearest ancestor with containment, which changes meaning when somebody adds a container between them. Naming it makes the relationship explicit and survives refactoring of the tree.

The containment that inline-size actually implies

container-type: inline-size
  applies: layout, style, and INLINE-SIZE containment.
  the element's inline size no longer depends on its
  contents — it is determined by its parent.

  which is what you want for a column, and what breaks
  an element that was sizing itself to its content.

container-type: size
  containment on BOTH axes. requires an explicit height,
  or the element collapses to zero.

we used inline-size everywhere. `size` was tried once,
on a card that needed a block-direction query, and
collapsed the entire section.

The collapse from size containment is the mistake everybody makes once and it looks like a rendering bug rather than a configuration error. Block-direction queries are rarely what you actually want; the case we had was better solved by a grid row.

Container query units

@container card (min-width: 30rem) {
  .card__title {
    /* 4% of the container's inline size, clamped */
    font-size: clamp(1rem, 4cqi, 1.5rem);
  }
}

/* cqi — inline size. cqb — block. cqmin/cqmax.
   the surprise: they resolve against the nearest
   QUERY CONTAINER, not the nearest ancestor. an
   element with no container ancestor falls back to
   the small viewport size. */

The fallback to viewport units when there is no container is the behaviour that produces confusing results during a partial migration — a component using cqi outside a container silently sizes against the window. Establishing the container is not optional once the units are in use.

Deleting the prop

$ git show --stat
 20 files changed, 34 insertions(+), 96 deletions(-)

 components/ProductCard.vue      | 18 +---
 components/ProductCard.css      | 22 ++++--
 # and 18 call sites, one line shorter each

$ grep -rc 'variant="compact"' src/ | awk -F: '{s+=$2} END {print s}'
0

The component’s public API is one prop smaller and it no longer has a prop that describes its environment, which is the change that matters. Every future placement is correct by construction, including the ones nobody has thought of.

What containment breaks

a dropdown inside the card escaped its container and
was clipped.

why: container-type creates a containing block for
absolutely positioned descendants — the same as
position: relative. an absolutely positioned menu that
had been escaping to the page now stops at the card.

the fix: the popover moved to the top layer, which it
should have been in already.

and the general warning: applying container-type to a
wrapper changes positioning for everything inside it.

Fallbacks, and deciding not to write any

support at the time of writing: ~91% of our traffic,
measured from the analytics we already had.

the 9%: mostly Safari 15 on iOS, and one enterprise
customer on a locked browser.

what they get without container queries: the mobile
layout everywhere — one column, full-width image. it
is correct, it is not what a wide card should look
like, and nobody reported it.

@supports (container-type: inline-size) was written and
then deleted: the fallback WAS the base stylesheet.

Writing the base styles as the narrow layout and the container query as the enhancement means the fallback is free and needs no feature query. That ordering is the whole strategy and it is the same one that made mobile-first media queries work.

Verifying it worked

$ npx backstop test
  Passed: 38   Failed: 3
  # all three: the sidebar cards, which now render
  # in the compact layout. the intended change.

$ npx backstop approve

# and the case that could not be tested before
$ npx playwright test cards-in-narrow-column.spec.ts
  ✓ card in a 280px column uses one column
  ✓ card in a 900px main uses two columns
  ✓ the same component, same props, both cases

The last test is the one worth having: the same component with identical props rendering differently based only on where it is. That assertion was not expressible before, because the difference was a prop.

What this costs

A layout model half the team has not read about, and one that changes positioning behaviour as a side effect. The dropdown clipping took an afternoon to diagnose because nothing about “the menu is cut off” points at a container-type declaration on an ancestor.

It also moves a decision from the call site to the stylesheet, which is right and is less discoverable. A developer wondering why a card looks different in two places now has to know that containers exist, rather than reading a prop — and the prop, for all its faults, was visible in the markup.