Avoiding Resize Loops in Queried Containers

Part of Container Query Motion Triggers in Modern View Transitions & Scroll APIs.

The problem

A sidebar collapses by animating its width from 280px to 72px. The navigation inside is a query container: below 160px it switches to icon-only mode, hiding labels. As the width passes 160px during the animation, the labels vanish, the items become narrower, and on some layouts the sidebar’s content-based width shrinks further — then, on the way back open, the reverse happens and the navigation flickers between modes for a few frames around the threshold. In another component a card’s container query adds padding at wide sizes, which makes the card wider, which keeps the query matched even after the card is dragged narrower.

Container queries were designed to prevent infinite loops, but animation can still produce oscillation and sticky states.

Root cause analysis: containment breaks cycles, not feedback

Size containment prevents true cycles. A query container with container-type: inline-size has inline-size containment: its inline size is determined without looking at its contents. So styles applied by a query inside it cannot change the container’s own inline size, and the browser cannot be forced into an infinite loop. This is a deliberate design constraint of container queries.

But ancestors are not contained. If the container’s parent sizes itself from content — a flex item with width: auto, a grid track sized auto or min-content — then styles inside the container can change the parent’s size, which changes the container’s size on the next layout. The loop is broken across frames rather than within one, and it shows up as flicker when an animation is sweeping the size near a breakpoint.

Animating the queried size re-evaluates queries every frame. A width transition on the container triggers layout and query evaluation on each frame. Near a threshold, tiny rounding differences can toggle the query on consecutive frames, especially when the query’s styles change the parent’s content size.

Queries that change the queried size are fragile. Padding, borders or gaps applied by a query to an element whose size feeds back into its own container’s parent create the sticky state: once matched, the layout keeps the size above the threshold.

The general rule from the parent topic applies: queries should trigger motion, and motion should not change the queried dimension.

The feedback path that causes flickerContainment stops the container sizing from its contents; an uncontained parent does not.The feedback path that causes flickerWidthtransitionon sidebarContainercrosses 160pxQuery hideslabelsParent shrinksto contentuncontainedContainerre-crossesthreshold
Containment stops the container sizing from its contents; an uncontained parent does not.

Step-by-step resolution

Stabilising a queried componentBreak the feedback path, then smooth the threshold.Stabilising a queried component1Find which element's size the query reads and which element animates.Often the same box, or parent and child2Give the animating parent a definite size that does not depend on the container's contents.No content-based feedback3Animate the sidebar width only between explicit values, never auto.Predictable sweep4Drive the icon-only mode from a state attribute that flips once, not from the sweeping width.No threshold flicker5Use scale or clip on a child for purely visual collapse effects.No layout per frame6Under reduced motion, switch sizes and modes instantly.
Break the feedback path, then smooth the threshold.

Production code pattern

.shell {
  display: grid;
  grid-template-columns: var(--sidebar-w) 1fr;   /* definite track: not sized from content */
}
.shell           { --sidebar-w: 280px; }
.shell[data-nav="collapsed"] { --sidebar-w: 72px; }

/* Register so the track width interpolates. */
@property --sidebar-w { syntax: "<length>"; inherits: true; initial-value: 280px; }
.shell { transition: --sidebar-w var(--motion-move-duration) var(--motion-move-easing); }

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

/* Mode follows STATE, not the sweeping width: no flicker mid-transition. */
.shell[data-nav="collapsed"] .nav__label {
  opacity: 0;
  transition: opacity var(--duration-100) linear;
}
.shell:not([data-nav="collapsed"]) .nav__label {
  opacity: 1;
  transition: opacity var(--duration-200) linear var(--motion-move-duration);  /* after expanding */
}

/* Size query only for layouts that are not driven by the collapse animation. */
@container nav (inline-size < 120px) {
  .nav__badge { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .shell, .nav__label { transition: none; }
}

Rendering Impact: layout per frame for the sidebar width. The track width is animated deliberately and costs layout; the fix removes the extra work of queries toggling and parents re-sizing mid-sweep. For a compositor-only collapse, overlay the sidebar and animate translate instead.

The key change is that the labels hide based on data-nav, which flips once at the start of the collapse, rather than on a container width that the animation sweeps through. The size query that remains only hides a badge in genuinely narrow placements, and nothing it changes can affect the grid track, because the track width is a definite custom property.

Query-driven mode against state-driven mode during a collapseSame sidebar animation; different trigger for the icon-only layout.Query-driven mode against state-driven mode during a collapse@container nav (inline-size < 160px)Labels toggle as width crosses 160pxParent content size shifts mid-sweepTwo or three frames of flickerOscillates near threshold[data-nav=collapsed] on the shellLabels fade once at collapse startTrack width is a definite valueQuery kept for static narrow layoutsStable
Same sidebar animation; different trigger for the icon-only layout.

Hysteresis for genuinely size-driven modes

Some modes must depend on size — a card that switches layout when a user resizes a panel by dragging. Dragging sweeps slowly across a threshold and can flicker the same way. Two techniques help. Use different thresholds for entering and leaving a mode by storing the current mode in an attribute from a ResizeObserver that only switches to compact below 300px and back to regular above 340px. Or debounce the mode switch so it applies after resizing pauses. Both move the decision out of a pure query, which is the trade-off: queries are declarative but memoryless, and hysteresis needs memory.

Verification checklist

Constraints and trade-offs

  • Animating grid tracks or widths is still layout per frame; overlays with transforms are cheaper.
  • Registered custom properties that inherit cost style work on descendants each frame.
  • State-driven modes duplicate the breakpoint in script or markup.
  • ResizeObserver-based hysteresis runs after layout and can lag one frame behind.
  • Container size containment can change intrinsic sizing behaviour of the container itself.

Frequently asked questions

Can container queries cause infinite layout loops?

Not within a container: size containment means the container’s queried size does not depend on its contents. Feedback through uncontained ancestors can still cause flicker across frames.

Why does my component flicker when its container animates past a breakpoint?

Query evaluation toggles styles as the size sweeps through the threshold, and those styles can change an ancestor’s content size. Drive the mode from state during the animation.

Should I animate the width of a query container?

Avoid animating the queried dimension. Animate a definite parent size or use transforms on an overlay, and keep mode switches independent of the sweep.

How do I add hysteresis to a container query?

Queries have no memory, so store the mode in an attribute and switch it with different enter and exit thresholds from a ResizeObserver.