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.
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
-
Pre-promote before the breakpoint fires. Add
will-change: transform, opacityto the animated element in its base styles, not inside the@containerrule. This allocates a GPU layer before any breakpoint fires, so layer promotion never happens mid-transition. -
Use
@containeronly to set discrete state. Assign CSS custom properties inside the@containerrule to represent the new state. Keeptransformandopacityvalues reading from those custom properties at the component level, not set directly inside@container. -
Handle entry from
display: noneexplicitly. Usetransition-behavior: allow-discretepaired with@starting-styleso the compositor owns the initial frame of the entry animation. Without this, browsers discard the first animation frame on discrete property changes. -
Restrict transitions to compositor-safe properties. Every animated property must stay on
transformoropacity. Animatingwidth,margin, orpaddinginside a@containerrule 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. -
Gate all motion behind
prefers-reduced-motion. Removewill-changeandtransitionfor 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:
transformandopacityrun on the compositor thread.@containertriggers 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 Styleon 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 fromtransition.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-changememory cost. Each promoted layer consumes GPU memory proportional to the element’s rendered dimensions. Do not applywill-changeto dozens of elements simultaneously; promote only the element that will animate.- Browser support.
@containerhas been supported since Chrome 105, Firefox 110, and Safari 16.@starting-stylerequires Chrome 117, Firefox 129, Safari 17.5. Gate with@supports (container-type: inline-size)and@supports (selector(:is(x)))respectively, or test@starting-stylesupport separately. @containerinside@keyframesis invalid. Browsers silently ignore@containerrules nested inside@keyframes. State must always flow from@containerdownward to the component, never the reverse.- ResizeObserver is not a substitute. Manually firing CSS transitions from a
ResizeObservercallback forces a double-layout: once when the observation fires, and once when the browser processes the resulting style mutation. The native@containerapproach 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.
Related
- Container Query Motion Triggers — parent topic covering the full
@containeranimation model - Avoiding Layout Thrashing in CSS Animations — the same forced-layout pattern in
@keyframescontexts - Compositor-Only Property Optimization — complete reference for which properties stay on the compositor thread