Learn

/

Container Queries

Container Queries

5 patterns

@container vs @media: when components should own their own responsiveness instead of relying on the viewport. You'll hit this when a reusable component looks wrong after being placed in a narrower sidebar or modal.

Avoid
/* Card adapts to viewport width */
.card {
  display: flex;
  flex-direction: column;
}

@media (min-width: 600px) {
  .card {
    flex-direction: row;
  }
}
/* Card adapts to viewport width */
.card {
  display: flex;
  flex-direction: column;
}

@media (min-width: 600px) {
  .card {
    flex-direction: row;
  }
}

Prefer
/* Card adapts to its own container */
.card-wrapper {
  container-type: inline-size;
}

.card {
  display: flex;
  flex-direction: column;
}

@container (min-width: 400px) {
  .card {
    flex-direction: row;
  }
}
/* Card adapts to its own container */
.card-wrapper {
  container-type: inline-size;
}

.card {
  display: flex;
  flex-direction: column;
}

@container (min-width: 400px) {
  .card {
    flex-direction: row;
  }
}
Why avoid

Using @media means the card always switches to row layout at 600px viewport width, even when it's in a narrow sidebar where row layout doesn't fit. The component's layout should depend on how much space *it* has, not the screen.

Why prefer

A card in a sidebar might be 300px wide even on a 1440px screen. @container queries let the card respond to its *own* available space, not the viewport. This makes the component truly reusable across different layout contexts.

MDN: Container queries
Avoid
/* Missing container context */
.sidebar {
  width: 300px;
}

@container (min-width: 250px) {
  .sidebar .widget {
    display: grid;
    grid-template-columns: 1fr 1fr;
  }
}
/* Missing container context */
.sidebar {
  width: 300px;
}

@container (min-width: 250px) {
  .sidebar .widget {
    display: grid;
    grid-template-columns: 1fr 1fr;
  }
}

Prefer
.sidebar {
  width: 300px;
  container-type: inline-size;
}

@container (min-width: 250px) {
  .sidebar .widget {
    display: grid;
    grid-template-columns: 1fr 1fr;
  }
}
.sidebar {
  width: 300px;
  container-type: inline-size;
}

@container (min-width: 250px) {
  .sidebar .widget {
    display: grid;
    grid-template-columns: 1fr 1fr;
  }
}
Why avoid

The @container rule is silently ignored because no ancestor declares itself as a container. This is the most common container query mistake: the query looks correct but nothing happens because the containment context is missing.

Why prefer

@container queries only work when an ancestor has container-type set. Without it, the query has no container to measure and the styles won't apply. inline-size is the most common value since it tracks the container's width.

MDN: container-type
Avoid
.page {
  container-type: inline-size;
}

.sidebar {
  container-type: inline-size;
}

/* Which container does this query? */
@container (min-width: 500px) {
  .card { flex-direction: row; }
}
.page {
  container-type: inline-size;
}

.sidebar {
  container-type: inline-size;
}

/* Which container does this query? */
@container (min-width: 500px) {
  .card { flex-direction: row; }
}

Prefer
.page {
  container-type: inline-size;
  container-name: page;
}

.sidebar {
  container-type: inline-size;
  container-name: sidebar;
}

@container sidebar (min-width: 300px) {
  .card { flex-direction: row; }
}
.page {
  container-type: inline-size;
  container-name: page;
}

.sidebar {
  container-type: inline-size;
  container-name: sidebar;
}

@container sidebar (min-width: 300px) {
  .card { flex-direction: row; }
}
Why avoid

Without naming, @container matches the nearest container ancestor. If the .card moves from the sidebar to the page, it suddenly queries the page's width instead. This is a subtle bug that's hard to debug because the CSS didn't change.

Why prefer

When multiple ancestors have container-type, an unnamed @container query matches the *nearest* container ancestor. Named containers (container-name + @container name) make the intent explicit and prevent surprises when components are moved between layouts.

MDN: container-name
Avoid
.hero-title {
  font-size: 5vw;
}
.hero-title {
  font-size: 5vw;
}

Prefer
.hero {
  container-type: inline-size;
}

.hero-title {
  font-size: clamp(1.5rem, 5cqi, 3rem);
}
.hero {
  container-type: inline-size;
}

.hero-title {
  font-size: clamp(1.5rem, 5cqi, 3rem);
}
Why avoid

5vw scales with the viewport, so the text is the same size whether the hero is full-width or in a half-width column. Container query units (cqi) let the font scale with the component's actual width.

Why prefer

cqi (container query inline) scales relative to the container's width, not the viewport. Combined with clamp(), the text scales fluidly within the hero's bounds and respects min/max limits, even if the hero is in a sidebar or modal.

MDN: Container query units
Avoid
function ProductCard() {
  const ref = useRef<HTMLDivElement>(null);
  const [wide, setWide] = useState(false);

  useEffect(() => {
    const obs = new ResizeObserver(([entry]) => {
      setWide(entry.contentRect.width > 400);
    });
    if (ref.current) obs.observe(ref.current);
    return () => obs.disconnect();
  }, []);

  return (
    <div ref={ref}>
      <Stack direction={wide ? "row" : "column"}>
        <ProductImage />
        <ProductDetails />
      </Stack>
    </div>
  );
}
function ProductCard() {
  const ref = useRef<HTMLDivElement>(null);
  const [wide, setWide] = useState(false);

  useEffect(() => {
    const obs = new ResizeObserver(([entry]) => {
      setWide(entry.contentRect.width > 400);
    });
    if (ref.current) obs.observe(ref.current);
    return () => obs.disconnect();
  }, []);

  return (
    <div ref={ref}>
      <Stack direction={wide ? "row" : "column"}>
        <ProductImage />
        <ProductDetails />
      </Stack>
    </div>
  );
}

Prefer
function ProductCard() {
  return (
    <Box sx={{ containerType: "inline-size" }}>
      <Stack
        sx={{
          flexDirection: "column",
          "@container (min-width: 400px)": {
            flexDirection: "row",
          },
        }}
      >
        <ProductImage />
        <ProductDetails />
      </Stack>
    </Box>
  );
}
function ProductCard() {
  return (
    <Box sx={{ containerType: "inline-size" }}>
      <Stack
        sx={{
          flexDirection: "column",
          "@container (min-width: 400px)": {
            flexDirection: "row",
          },
        }}
      >
        <ProductImage />
        <ProductDetails />
      </Stack>
    </Box>
  );
}
Why avoid

ResizeObserver triggers a state update and re-render on every resize frame. This causes layout thrashing because the browser calculates layout, JavaScript reads it, updates state, React re-renders, and the browser recalculates layout. Container queries handle this entirely in CSS.

Why prefer

CSS container queries replace the need for ResizeObserver + state for layout changes. No JavaScript runs on resize, no re-renders, no SSR issues. MUI's sx prop supports @container queries directly as nested selectors.

MDN: Container queries