Combining @container Queries with Motion States

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

When a responsive component crosses a container breakpoint, animations frequently reset abruptly, frames drop, and the Cumulative Layout Shift score spikes. This page explains exactly why that happens inside the browser’s rendering pipeline and gives you a step-by-step pattern to prevent it.


Container resize: unsynchronized vs synchronized rendering paths Left side shows the broken path: container resize triggers Recalculate Style, then Update Layout Tree, then the compositor is forced to promote a new layer mid-transition, causing jank. Right side shows the fixed path: will-change promotes the layer before the breakpoint fires, so Recalculate Style runs alone on the main thread while the compositor interpolates transform and opacity independently. Without synchronization With synchronization Container resize event Recalculate Style (main thread) Update Layout Tree (main thread) Layer promoted mid-transition will-change pre-promotes layer Recalculate Style only (main thread) Compositor interpolates independently Smooth 60 fps — no jank

Problem framing

Developers observe sudden animation drops, flickering, or visual pops when a parent container crosses a breakpoint. The component simultaneously recalculates its own intrinsic size and tries to interpolate an animation state — two tasks that compete for the same DOM subtree. The result is dropped frames and unexpected CLS spikes, both measurable in the Performance panel.

Root cause: layout invalidation collides with animation interpolation

When a container’s inline-size changes, the browser invalidates the style tree and schedules a synchronous layout pass via Update Layout Tree. If CSS transitions are bound directly to @container breakpoints without first isolating the animated element on its own composite layer, the compositor cannot cache the animation frames. The main thread must recalculate geometry mid-transition. This is the same layout thrashing pattern that affects @keyframes bound to layout-triggering properties, applied here at the breakpoint boundary.

The secondary trigger is state desynchronization: @container fires on the style recalculation tick, but an in-flight CSS transition is already running at 60 fps on the compositor thread. Crossing the breakpoint resets the transition’s start state, discarding the in-progress frame and producing the visual pop.

Decision matrix: choose the right synchronization strategy

Scenario Animated properties Correct approach
Scale / fade on breakpoint transform, opacity will-change + @container sets state
Entry from display: none opacity, transform transition-behavior: allow-discrete + @starting-style
Layout-affecting resize width, height Do not animate — use @container to swap classes, let layout reflow complete first
Rapid breakpoint crossing Any compositor-safe Ensure transition duration > resize debounce; single transition per property
Scroll-driven response Any Use animation-timeline: scroll()@container does not read scroll position

Step-by-step resolution

  1. Pre-promote before the breakpoint fires. Add will-change: transform, opacity to the animated element in its base styles, not inside the @container rule. This allocates a GPU layer before any breakpoint fires, so layer promotion never happens mid-transition.

  2. Use @container only to set discrete state. Assign CSS custom properties inside the @container rule to represent the new state. Keep transform and opacity values reading from those custom properties at the component level, not set directly inside @container.

  3. Handle entry from display: none explicitly. Use transition-behavior: allow-discrete paired with @starting-style so the compositor owns the initial frame of the entry animation. Without this, browsers discard the first animation frame on discrete property changes.

  4. Restrict transitions to compositor-safe properties. Every animated property must stay on transform or opacity. Animating width, margin, or padding inside a @container rule forces a layout recalculation on every frame — the root cause of jank described above. See the full list of compositor-safe properties and their GPU optimization strategies.

  5. Gate all motion behind prefers-reduced-motion. Remove will-change and transition for users who prefer reduced motion; the GPU memory cost of persistent composite layers is unjustified when no animation runs.

Production code pattern

/* ─── 1. Base component ──────────────────────────────────────────────────
   Define the container context and pre-promote to a composite layer.
   will-change here — NOT inside @container — ensures GPU layer exists
   before any breakpoint fires, preventing mid-transition promotion.      */
.motion-card {
  container-type: inline-size;
  container-name: card;

  /* Pre-allocate GPU layer; browser compositor owns transform + opacity */
  will-change: transform, opacity;

  /* Transitions target only compositor-safe properties */
  transition:
    transform 0.35s cubic-bezier(0.25, 0.1, 0.25, 1),
    opacity   0.25s ease,
    display   0.35s allow-discrete; /* allow-discrete enables display transitions */
}

/* ─── 2. @starting-style entry ───────────────────────────────────────────
   Defines the FROM state for display:block entries so the compositor
   can interpolate from frame 0 without a discrete jump.                  */
@starting-style {
  .motion-card {
    opacity:   0;
    transform: scale(0.95) translateY(8px);
  }
}

/* ─── 3. Container-driven state ──────────────────────────────────────────
   @container only assigns discrete state; it does NOT set transition
   targets directly. This keeps the style recalculation pass lightweight —
   one property write triggers the already-running compositor thread.     */
@container card (min-width: 400px) {
  .motion-card {
    transform: scale(1.04);
    opacity:   1;
  }
}

@container card (min-width: 640px) {
  .motion-card {
    transform: scale(1.08) translateY(-4px);
    opacity:   1;
  }
}

/* ─── 4. Accessibility gate ──────────────────────────────────────────────
   Remove transitions and will-change entirely. The GPU layer cost is
   pointless without animation, and transforms can cause vestibular issues. */
@media (prefers-reduced-motion: reduce) {
  .motion-card {
    will-change: auto;        /* release GPU memory */
    transition:  none;
  }

  @container card (min-width: 400px) {
    .motion-card { transform: none; }
  }

  @container card (min-width: 640px) {
    .motion-card { transform: none; }
  }
}

Rendering Impact: transform and opacity run on the compositor thread. @container triggers a single Recalculate Style task on the main thread — no layout invalidation, no paint, provided no geometry properties are animated.

Verification checklist

DevTools: profiling container-driven transitions

Open Chrome DevTools → Performance, click Record, resize the container past the breakpoint, then stop. In the flame chart look for:

  • Recalculate Style on the main thread — one short task is expected at the breakpoint boundary.
  • Update Layout Tree — if this appears alongside your animation frames, a layout-triggering property is being animated. Remove it from transition.
  • Composite Layers — should run on its own thread, entirely detached from main-thread tasks above.

In the Rendering tab, enable Paint Flashing. Green overlays during the animation mean the compositor is re-painting rather than compositing — a signal that will-change is missing or set too late.

Constraints and trade-offs

  • will-change memory cost. Each promoted layer consumes GPU memory proportional to the element’s rendered dimensions. Do not apply will-change to dozens of elements simultaneously; promote only the element that will animate.
  • Browser support. @container has been supported since Chrome 105, Firefox 110, and Safari 16. @starting-style requires Chrome 117, Firefox 129, Safari 17.5. Gate with @supports (container-type: inline-size) and @supports (selector(:is(x))) respectively, or test @starting-style support separately.
  • @container inside @keyframes is invalid. Browsers silently ignore @container rules nested inside @keyframes. State must always flow from @container downward to the component, never the reverse.
  • ResizeObserver is not a substitute. Manually firing CSS transitions from a ResizeObserver callback forces a double-layout: once when the observation fires, and once when the browser processes the resulting style mutation. The native @container approach avoids both extra passes.
  • Transition interruption on rapid crossing. If the container crosses two breakpoints faster than the transition duration, the in-flight animation is discarded and replaced. Either shorten the duration or debounce the container’s size changes at the component level.

FAQ

Can I use @container queries to drive scroll-linked animations?

No. @container evaluates based on the container’s inline-size, not scroll position. For scroll-driven motion, use the scroll-driven animation patterns approach with animation-timeline: scroll() or animation-timeline: view() to keep rendering entirely on the compositor.

Why does my container transition cause a layout shift?

If the animated property affects geometry (width, height, margin), the layout engine recalculates box positions for every frame and CLS spikes. Restrict transition to transform and opacity. If you need a size change, let @container change a class that triggers the new size, allow the layout reflow to complete as a single discrete step, and then animate visual feedback separately via transform.

How do I prevent animation resets when a breakpoint is crossed rapidly?

Use transition-behavior: allow-discrete for discrete property changes. Ensure transition duration exceeds the minimum window between breakpoint events (typically the resize-observer observation interval, around 50–100 ms). Avoid applying multiple concurrent transitions to the same property — the last state wins and prior in-flight transitions are discarded.