Container Query Animation: @container + @keyframes Patterns
Trigger CSS @keyframes animations based on container size, not viewport width — the missing piece for truly component-driven, layout-aware motion in 2026.
Why Viewport-Based Animation Was Always Broken
Here's the honest truth about @media-based animation: it was never really responsive. A card component sitting in a 400px sidebar shouldn't behave identically to that same card spanning a full 1200px hero — but with viewport media queries, it does. You'd write @media (min-width: 768px) { .card { animation: slideIn 0.4s ease } } and feel okay about it, right up until the designer dropped that card into a two-column grid and everything looked wrong at 900px viewport width.
Container queries — stable in Chromium since 105 (2022) and in Firefox since 110 (2023) — solve the container-size half of that problem. You define a containment context, query it, and change styles based on how wide *that element's parent* actually is. Most tutorials stop at layout changes: switch from column to row, bump up font-size, show a hidden sidebar. What they don't cover is animating on those breakpoints.
That's what this guide is about. Combining @container with @keyframes gives you motion that responds to actual layout context. A card that slides in when it's narrow, scales in when it's wide, and does something tasteful in between. Worth noting: you're not doing anything exotic here — these are plain CSS keyframes triggered by container conditions, no JavaScript required.
Setting Up Containment: The Part Everyone Gets Wrong
Before you can query a container, you have to declare it. And the declaration has to be on the *parent*, not the element you're styling. This is the single biggest gotcha. You're not querying self — you're querying the box the element lives inside.
/* The parent declares itself as a container */
.card-wrapper {
container-type: inline-size;
container-name: card;
}
/* Now the child can query it */
@container card (min-width: 480px) {
.card {
animation: expandIn 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) forwards;
}
}
@keyframes expandIn {
from {
opacity: 0;
transform: scale(0.92) translateY(8px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}container-type: inline-size is the option you'll use 90% of the time. It lets you query the container's inline dimension (width in horizontal writing modes) without affecting block layout. container-type: size queries both axes but triggers full layout containment — useful for fixed-dimension widgets but overkill for most cards and panels.
One more thing — container-name is optional but you should use it. Unnamed containers let *any* ancestor match your query, which produces bizarre specificity bugs the moment you nest containers. Name everything.
In practice, the wrapper pattern works cleanly in React too. You just add the CSS class to whatever element wraps your component, or handle it with a context provider if you're building a reusable library component. Nothing framework-specific needed.
The Core Pattern: Swap Animations at Container Breakpoints
The fundamental technique is straightforward. Define two or more @keyframes rules — one for each layout mode — then apply the right one inside the appropriate @container query. The animation fires when the container first matches the condition, which in practice means it runs on mount (if the container is already the right size) or when the container resizes past the threshold.
/* Base: narrow layout (< 480px) */
.card {
container-type: inline-size; /* self-referencing shorthand for simple cases */
animation: slideFromLeft 0.3s ease-out forwards;
}
@keyframes slideFromLeft {
from { opacity: 0; transform: translateX(-16px); }
to { opacity: 1; transform: translateX(0); }
}
/* Wide layout: different entrance feel */
@container (min-width: 560px) {
.card {
animation: fadeScaleIn 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
}
@keyframes fadeScaleIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}Wait — can you actually set container-type on an element and then query that same element from within itself? Short answer: sort of. You can query the element's own container if it *also* has an ancestor container in scope, but self-querying directly doesn't work in the spec. The cleaner pattern is always parent-as-container, child-as-styled-target. Keeps things predictable.
Honestly, the real power shows up when you're building design-system components that get slotted into wildly different layouts. A stat card dropped into a narrow sidebar should animate in from the side. The same card in a wide dashboard hero should scale. With viewport media queries, you'd never be able to express that without JavaScript. With @container, it's 20 lines of CSS.
Animating Continuous Resize: The Scroll-Linked Container Trick
Static entry animations are great. But what about animating *as* the container resizes? This is where it gets genuinely interesting. Browsers don't re-run @keyframes on every pixel of resize — they re-evaluate @container conditions and toggle animation application. That means you can get a step-function animation effect at breakpoints, not a continuous one.
For continuous resize-linked animation, you need animation-timeline: scroll() or a custom property approach. Here's one pattern that works: use @container to set a CSS custom property, and @keyframes to transition it.
.resizable-wrapper {
container-type: inline-size;
container-name: resizable;
}
.animated-bar {
--bar-scale: 0.6;
transform: scaleX(var(--bar-scale));
transition: transform 0.2s ease;
transform-origin: left;
}
@container resizable (min-width: 300px) {
.animated-bar { --bar-scale: 0.7; }
}
@container resizable (min-width: 450px) {
.animated-bar { --bar-scale: 0.85; }
}
@container resizable (min-width: 600px) {
.animated-bar { --bar-scale: 1; }
}That's not @keyframes — it's CSS transitions triggered by container breakpoints. But you can absolutely layer keyframes on top. Add animation: pulse 2s ease-in-out infinite inside the largest container breakpoint and remove it in smaller ones. The animation starts running only when the element has enough space to display it meaningfully. This is the kind of context-aware motion that used to need a ResizeObserver in JavaScript.
Quick aside: if you're building data-visualization widgets — progress bars, sparklines, stat cards — for a project like an analytics dashboard, this stepped-breakpoint approach is genuinely elegant. The bar doesn't overflow, the animation doesn't fire when the component is too squished to display it properly, and zero JS is involved.
Real-World Patterns: Cards, Navbars, and Bento Grids
Let's talk about where this actually ships in production. Three patterns come up constantly.
Pattern 1: Bento grid cells. Bento grids (like the ones on the Empire UI component library) have cells that span different numbers of columns. A cell spanning two columns should animate differently from a single-column cell. With container queries, the cell itself detects its own width and picks the right entrance.
.bento-cell {
container-type: inline-size;
container-name: bento-cell;
}
/* Single-column cell: subtle fade */
.bento-content {
animation: fadeIn 0.25s ease forwards;
}
/* Wide span: more dramatic reveal */
@container bento-cell (min-width: 480px) {
.bento-content {
animation: revealWide 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes revealWide {
from { opacity: 0; transform: translateY(20px) scale(0.97); }
to { opacity: 1; transform: translateY(0) scale(1); }
}Pattern 2: Navigation bar. A top nav that collapses into a hamburger menu at narrow container widths can fire a slide-down animation for the expanded state. Set container-type: inline-size on the <nav> element, then animate the menu items inside a @container nav (min-width: 640px) block. Items appear one by one with a staggered animation-delay. The @container query handles both the layout switch *and* the animation trigger in one declaration.
Pattern 3: Product cards. E-commerce cards that go from stacked (image on top, text below) to side-by-side in wider grid areas. The image slides in from the left in wide mode, drops down in narrow mode. If you're building this kind of UI, check out the glassmorphism components — the card variants there are good starting points for adding your own container-query animation layer on top.
Accessibility and the prefers-reduced-motion Guard
Container query animations carry exactly the same accessibility obligations as any other CSS motion. The difference is that they're more likely to be *unexpected* — users don't resize browser windows for fun, but your layout system might reflow containers dynamically as other elements mount or unmount. An animation that fires mid-interaction is disorienting.
The guard is simple and non-negotiable:
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}That global reset is the nuclear option but it works. If you want more surgical control — maybe you still want opacity fades but not transforms — you can scope it: wrap individual @container animation blocks in a @media (prefers-reduced-motion: no-preference) outer query. The nesting is valid CSS and it reads clearly.
Look, the motion design community sometimes treats reduced-motion support as an afterthought. It isn't. About 26% of macOS users have "Reduce Motion" enabled per Apple's own developer telemetry data from 2024. That's a meaningful audience. The 1-line guard costs you nothing.
Browser Support, Fallbacks, and What's Still Missing
Container queries themselves are at 93%+ global browser support as of mid-2026. The @container + @keyframes combination works in any browser that supports container queries — there's no additional surface involved, you're just applying animation properties inside an already-supported at-rule. So in practice, this is production-ready today.
The gap worth knowing about: container-style queries (querying CSS custom property values on a container) are still behind a flag in Firefox as of this writing. Don't depend on those for animation triggers yet. Stick to inline-size and block-size numeric comparisons and you're fine everywhere.
Fallbacks are straightforward. Any browser that doesn't understand @container simply skips the block — the element gets its default styles and no animation fires. That's usually acceptable. If you need a minimal entrance for legacy browsers, put a basic animation outside the @container block as the default, then override it inside the container query for modern browsers. Progressive enhancement, nothing complicated.
One thing CSS container queries still can't do: animate on the container's *style* properties (color, opacity, custom properties set by JS) in a fully cross-browser way. For that you still reach for JavaScript — a ResizeObserver or a small animation library. Empire UI's component animations use a hybrid of this: CSS container queries handle layout-triggered entrance animations, and for interactive motion the library leans on CSS custom properties toggled by Framer Motion. You can see the pattern in action in the aurora background component and across the style hubs — open DevTools and watch the custom properties fire.
FAQ
Yes. Container query support is at 93%+ globally as of 2026 — any browser that handles @container also runs keyframes inside it. No polyfill needed for production use.
That's intentional and correct. The animation runs whenever the @container condition is first matched — including on initial paint if the container already meets the size threshold. Use animation-fill-mode: forwards and animation-play-state to control replay behavior.
You always need a declared container ancestor. Set container-type on the parent element, then query it from child selectors. Self-querying isn't part of the spec.
With CSS Modules, yes — write @container blocks in your .module.css file normally. Tailwind doesn't have container query animation utilities yet, so you'll need a small @layer components block or a plain CSS file alongside your Tailwind config.