Container Query Motion Triggers
Part of Modern View Transitions & Scroll APIs.
Container query motion triggers let a component’s animation state respond directly to its own available space rather than to the global viewport. Where scroll-driven animation patterns tie progress to scroll position, container-driven motion ties state changes to the computed inline size of the nearest containment ancestor — making components genuinely self-contained and reusable across any layout context.
What container query motion triggers solve
The browser’s @container at-rule lets a descendant element match styles based on the measured size of a named containment ancestor rather than the viewport. This means an animated card inside a sidebar can enter a compact state as the sidebar narrows — without any JavaScript resize listener and without any media query that would break when the same card is dropped into a full-width grid.
The rendering problem @container solves is style desynchronisation: before CSS containment, the only way to adapt component animation states to available space was to read offsetWidth in JavaScript, compare against thresholds, and imperatively toggle classes — a pattern that forces forced-synchronous layout on every resize event. CSS containment moves size evaluation entirely into the browser’s Style phase, keeping the resulting transitions on the compositor thread when they are restricted to compositor-only properties.
Execution model
When a container’s inline-size changes (due to a grid reflow, a sidebar open/close, or a flex reflow), the browser schedules a Style pass on the main thread. During this pass, it re-evaluates every @container rule whose named or anonymous context matches the resized ancestor. The matched rules update CSS custom properties or discrete property values. From that point, any transition bound to transform or opacity runs entirely on the compositor thread — off the main thread, at 60 fps even under heavy layout activity.
Any @container rule that touches geometry properties (width, height, margin, padding, top) forces a Layout phase after the Style phase, cascading into a Paint phase if backgrounds or borders change. Keep container-driven motion restricted to transform and opacity to stay on the fast path.
Containment context is established per-element; an element must declare container-type: inline-size (or container-type: size) to become a valid evaluation target. Without that declaration, descendant @container rules are silently inert.
Property and API reference
| Property / feature | Accepted values | Compositing tier | Notes |
|---|---|---|---|
container-type |
inline-size | size | normal |
Style (main thread) | Required on the ancestor; normal disables containment |
container-name |
Any <custom-ident> |
Style | Allows named queries; required when containers are nested |
@container condition |
(width > …), (min-width: …), style queries (--var: value) |
Style | Style queries (querying custom property values) are separate from size queries |
transition on transform / opacity |
Duration, easing, delay | Compositor | Stays off the main thread; 60 fps capable |
transition on width / height |
Duration, easing, delay | Layout + Paint | Forces main-thread recalculation every frame |
@starting-style |
Initial property values for entry animations | Compositor (if transform/opacity) | Works inside @container rules to animate entry on first match |
@supports (container-type: inline-size) |
— | — | Gates the entire container motion layer for legacy browser safety |
Annotated code examples
Example 1 — basic container state trigger
Intent: assign an animation duration and scale via @container so the transition fires whenever the container crosses 400 px.
/* 1. Establish containment context on the wrapper */
.motion-container {
container-type: inline-size;
/* container-name: card; — add if nesting containers */
}
/* 2. Assign CSS custom property + transform in the wide state */
@container (width > 400px) {
.motion-trigger {
--anim-dur: 0.4s;
transform: scale(1.05);
}
}
/* 3. Reset to the compact state */
@container (width <= 400px) {
.motion-trigger {
--anim-dur: 0.2s;
transform: none;
}
}
/* 4. Bind the transition to the custom property */
.motion-trigger {
transition: transform var(--anim-dur, 0.3s) ease-out;
/* transform stays on the compositor thread */
}
/* 5. Reduced-motion fallback */
@media (prefers-reduced-motion: reduce) {
.motion-trigger {
transition: none;
transform: none;
}
}
Rendering Impact:
composite— transition interpolation runs on the compositor thread because onlytransformis animated.
Example 2 — CSS custom property state machine with entry animation
Intent: use @starting-style inside a @container rule to animate component entry without JavaScript.
.card-container {
container-type: inline-size;
container-name: card;
}
.card {
/* Pre-promote to avoid mid-resize layer creation */
will-change: transform, opacity;
transition:
transform 0.35s cubic-bezier(0.25, 0.1, 0.25, 1),
opacity 0.25s ease;
}
@container card (min-width: 320px) {
.card {
transform: none;
opacity: 1;
}
/* Entry: fires only when the element first matches this container state */
@starting-style {
.card {
transform: translateY(8px);
opacity: 0;
}
}
}
@container card (max-width: 319px) {
.card {
transform: scale(0.97);
opacity: 0.85;
}
}
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
will-change: auto;
transform: none;
opacity: 1;
}
}
Rendering Impact:
composite—@starting-stylewithtransformandopacitystays entirely on the compositor thread.
Example 3 — containment isolation for performance-critical grids
Intent: use contain: strict to prevent animated child components from causing parent reflows in a dense card grid.
.grid-item {
/* strict = layout + style + paint + size containment */
contain: strict;
/* will-change tells the compositor to pre-allocate a layer */
will-change: transform;
container-type: inline-size;
}
@container (width > 280px) {
.grid-item__label {
--label-scale: 1;
transform: scale(var(--label-scale));
opacity: 1;
}
}
@container (width <= 280px) {
.grid-item__label {
--label-scale: 0.9;
transform: scale(var(--label-scale));
opacity: 0.6;
}
}
.grid-item__label {
transition: transform 0.2s ease, opacity 0.15s ease;
}
@media (prefers-reduced-motion: reduce) {
.grid-item__label {
transition: none;
transform: none;
opacity: 1;
}
.grid-item {
will-change: auto;
}
}
Rendering Impact:
composite—contain: strictcreates an independent rendering context; grid reflows cannot escape the containment boundary.
DevTools workflow
Use this sequence to confirm that container-driven transitions stay on the compositor thread and do not cause unexpected style recalculations.
-
Open Chrome DevTools → Elements panel. Look for the
containerbadge on any element withcontainer-typeset. Hover to highlight the containment boundary. If the badge is absent, thecontainer-typedeclaration is missing or overridden. -
Elements → Computed. Expand the
@containersection (Chrome 112+). It lists every active container rule and whether the condition is currently matched. This is the fastest way to confirm the query syntax is correct. -
Performance panel → Record. Resize the container (drag a panel splitter or use a device toolbar breakpoint). Stop recording and expand the flame chart. Look for Recalculate Style blocks. Their duration should be a few milliseconds. If you see Update Layout Tree directly after each style recalculation, the
@containerrule is touching a geometry property — switch it to a CSS custom property ortransform. -
Rendering tab → Paint Flashing. Enable it, then resize the container. Green flashes should appear only on elements whose
backgroundorborderchanges. If the animated element itself flashes repeatedly, the transition is triggering paint — addwill-change: transformto promote it to its own layer before the transition fires. -
Rendering tab → Layer Borders. Orange borders indicate composited layers. The animated element should acquire one before the container crosses a breakpoint. If the layer border appears only during the transition,
will-changeis absent and the browser is doing a mid-animation layer promotion — the most common source of first-frame jank. -
Firefox DevTools → Style Editor → @container section. Firefox 110+ displays active container rules as a collapsible group. Useful for verifying named container resolution when multiple ancestors have
container-nameset.
Failure modes and fixes
Problem: @container rule has no effect. Root cause: The ancestor element lacks a container-type declaration. Without it, no containment context exists and the query is silently ignored. Fix: Add container-type: inline-size to the intended ancestor. Confirm with the container badge in Chrome DevTools Elements panel.
Problem: First frame of the transition is janky even though only transform is animated. Root cause: The element is not pre-promoted; the compositor creates a new layer mid-transition, causing a one-frame paint. Fix: Set will-change: transform on the element unconditionally so the layer is allocated before any @container condition is matched.
Problem: Animations conflict when the same element is targeted by both a @media query and a @container rule. Root cause: Specificity is equal; the last rule in source order wins, and the winner can change unexpectedly on resize. Fix: Scope one mechanism to state variables (custom properties) and the other to the actual transition declaration so they never set the same property directly.
Problem: Component animations break during React or Vue re-renders. Root cause: Framework reconciliation replaces DOM nodes, resetting will-change layers and in-flight transitions. Fix: Use stable DOM keys so the animated element is never unmounted and remounted. For entry animations, rely on @starting-style rather than JavaScript class toggling to avoid race conditions with the reconciler.
Problem: contain: strict hides overflow content. Root cause: size containment (part of strict) means the element’s children cannot influence its size, so overflow is clipped. Fix: Switch to contain: layout style paint (omitting size) when the element’s children must determine its height.
Accessibility and reduced-motion
Container-driven animations fire in response to layout changes, which can happen frequently during page load, responsive reflows, or user-initiated panel resizing. This makes them higher-risk than click-triggered transitions for users who are sensitive to motion.
Apply @media (prefers-reduced-motion: reduce) as a hard override at the end of every @container block:
@media (prefers-reduced-motion: reduce) {
/* One rule to cover every container-driven animated element */
.motion-trigger,
.card,
.grid-item__label {
transition: none !important;
animation: none !important;
transform: none !important;
will-change: auto;
}
}
The !important is intentional here — it ensures the reduced-motion preference wins regardless of @container specificity. Without it, a high-specificity @container rule can override a lower-specificity media query override.
For animation state management that spans JavaScript and CSS, mirror the prefers-reduced-motion check in JS using window.matchMedia('(prefers-reduced-motion: reduce)') and disable any imperative ResizeObserver callbacks that supplement container styles.
FAQ
How do container query motion triggers differ from viewport-based scroll animations? Container triggers evaluate parent component dimensions rather than the global viewport, enabling modular, reusable animation logic that scales independently of page layout. Scroll-driven animation patterns respond to scroll position on the compositor thread; container-driven triggers respond to the component’s available inline size on the main thread’s Style phase.
What is the rendering thread for @container evaluation?
@container evaluation runs on the main thread during the Style phase. The resulting transitions then run on the compositor thread only if they are restricted to transform and opacity. Any other animated property forces a main-thread Layout or Paint phase every frame.
Can container query triggers replace JavaScript animation libraries? Yes, for declarative state-based transitions. Complex physics-based, sequenced, or externally orchestrated animations still need JavaScript, but the CSS containment layer handles the state-driven motion without any JS overhead.
Why does my @container rule have no visible effect?
The most common cause is a missing container-type declaration on the ancestor element. Without it, the browser has no containment context to evaluate and the @container rule is silently ignored. Confirm in Chrome DevTools Elements panel — the ancestor should show a container badge.
How do I debug container query boundaries in DevTools?
In Chrome 105+, the Elements panel shows a container badge next to any element with container-type set. Hover over it to highlight the containment boundary. In the Rendering tab, enable Layout Shift Regions to catch geometry-triggering motion, and use Paint Flashing to confirm the animated element is not repainting on every frame.
Related
- Combining @container queries with motion states — preventing jank and CLS when
@containerbreakpoints fire during component resize - Scroll-Driven Animation Patterns — moving scroll-linked progress tracking to the compositor thread
- View Transitions API Implementation — coordinating container state changes with cross-document navigation transitions
- Compositor-Only Property Optimization — the full list of properties that stay off the main thread
- Hardware-Accelerated Properties — foundational guide to layer promotion and
will-changestrategy