Container-Query-Triggered Keyframe Animations: Animate Differently per Available Space

A reusable component does not know where it will be placed — the same card might sit in a wide hero, a three-column grid, or a narrow sidebar. The narrow problem this guide solves: make a component run one @keyframes animation when it has room and a different, calmer one when it is cramped, deciding entirely from the component's own container size with @container, plus style queries for state-driven switches. No viewport media queries, no JavaScript measuring. This page is part of the keyframe animation patterns guide and deliberately bridges into the responsive-layout side of the site: the mechanics build directly on the container query syntax basics guide.

What this technique enables:

  • Swap animation-name per container width — a wide card slides in, a narrow one fades.
  • Use style queries to switch animations by a custom-property state, not just size.
  • Keep components context-agnostic; placement decides the motion.
  • Layer reduced-motion safety on top, independent of container size.

Why Drive Animation from the Container, Not the Viewport

Viewport media queries answer "how big is the screen", which is the wrong question for a component dropped into an unknown slot. A card in a sidebar is narrow even on a 4K display. Animation is a presentation decision that should track the space the component actually occupies, and that is exactly what a size @container query measures. Switching animation-name inside a container query means the component carries its own responsive motion logic and behaves correctly in any layout, no parent coordination required.

The reason to do this in CSS rather than a ResizeObserver plus JavaScript is resilience and cost: the browser already tracks container sizes for layout, so reacting to them in CSS is essentially free, runs without script, and cannot desync the way an observer callback can during heavy main-thread work. The tradeoff is that container queries only select animations — they cannot interpolate based on size the way a script could. And crucially, choosing an animation by size says nothing about whether the user wants motion at all; reduced-motion handling is a separate, mandatory layer that must override the container's choice. Treat container size as "which animation is appropriate here" and prefers-reduced-motion as "is any animation allowed at all".


Complete Working Implementation

The card lives in a container declared with container-type: inline-size. Below a width threshold it fades in gently; at or above it, it slides and scales in with more flourish. A style query then lets a --state custom property on the container trigger an attention pulse independent of size.

Container width threshold selecting an animation A horizontal width axis with a threshold at 400px. To the left, narrow containers map to a fade-in animation; to the right, wide containers map to a slide-and-scale animation. @container width decides the animation min-width: 400px narrow container animation: card-fade wide container animation: card-slide same component, motion chosen by the space it occupies
<!-- The grid cell is the query container; the card is the queried child. -->
<div class="slot">
  <article class="card">
    <h3>Project Atlas</h3>
    <p>Quarterly status and next milestones.</p>
  </article>
</div>
/* Establish the container. The child can now query this width. */
.slot {
  container-type: inline-size;
  /* `container-name` is optional but lets nested queries target it. */
  container-name: slot;
}

/* Default / narrow behaviour: a calm fade. */
.card {
  animation: card-fade 0.4s ease both;
}

@keyframes card-fade {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* When the CONTAINER (not the viewport) is wide enough, swap the
   animation-name to a richer slide-and-scale entrance. */
@container slot (min-width: 400px) {
  .card {
    animation-name: card-slide;
    animation-duration: 0.5s;
    animation-timing-function: cubic-bezier(0.2, 0, 0, 1);
  }
}

@keyframes card-slide {
  from { opacity: 0; transform: translateY(24px) scale(0.96); }
  to   { opacity: 1; transform: translateY(0)    scale(1); }
}

/* Style query: react to a custom property STATE on the container,
   independent of its size. Pulse the card when --state: alert. */
@container slot style(--state: alert) {
  .card {
    animation: card-pulse 1s ease-in-out infinite;
  }
}

@keyframes card-pulse {
  0%, 100% { transform: scale(1); }
  50%      { transform: scale(1.02); }
}

/* Mandatory: container size never overrides the user's motion choice. */
@media (prefers-reduced-motion: reduce) {
  .card { animation: none; opacity: 1; transform: none; }
}

Set --state: alert on the .slot (for example <div class="slot" style="--state:alert">) to trigger the style-query pulse. Because the pulse rule lives inside a style query, the same card stays calm everywhere else without any extra class plumbing. The widths used here are container units conceptually; for sizing inside the card relative to its container, reach for the container query units cqi and cqb guide.


The Key Technique: Reassigning animation-name Inside @container

The mechanism is subtle but small: the card always declares an animation shorthand, so it animates by default. The @container block then does not start a new animation — it reassigns animation-name (and tweaks duration and easing) on the same element. The cascade resolves which animation-name wins based on whether the container query matches, and the browser runs whichever name is in effect. Because the query condition is the container's measured inline-size, the very same component picks card-fade in a 300px slot and card-slide in a 500px slot, with the decision made at layout time and re-evaluated automatically when the slot resizes. Style queries extend this from dimensions to state: @container style(--state: alert) matches on the computed value of a custom property on the container, letting a parent flip a child's animation by setting one variable — no class toggling, no JavaScript.


Variation: Named Containers and a Fallback for No Query Support

In real layouts a card may be nested inside several containers. Naming the container with container-name makes the query unambiguous, and an @supports guard lets you ship a sensible default to engines without container-query support.

/* Without container-query support, fall back to a viewport breakpoint
   so wide screens still get the richer entrance. */
@supports not (container-type: inline-size) {
  @media (min-width: 700px) {
    .card { animation-name: card-slide; }
  }
}

/* With support, the named query is authoritative regardless of viewport. */
@supports (container-type: inline-size) {
  .slot { container: slot / inline-size; } /* shorthand: name / type */
}

This mirrors the progressive-enhancement approach in the handling container query fallbacks for older browsers guide: detect support, prefer the container query, and degrade to a viewport breakpoint only where necessary.


Browser Support

Size container queries are baseline-supported: Chrome/Edge 105+, Safari 16+, and Firefox 110+, available across the board since early 2023. Style queries for custom properties — the @container style(--state: …) form used above — landed later: Chrome/Edge 111+ and Safari 18+, with Firefox support arriving more recently; use @supports (container-type: inline-size) to gate container-query CSS, and treat the style-query pulse as an enhancement that simply does nothing where unsupported. The keyframes, transform, and prefers-reduced-motion pieces are universally supported.


FAQ

Can @container change which keyframe animation runs? Yes. A size container query can set a different animation-name (or different animation properties) inside its rule block, so the same component runs one animation when narrow and another when wide, with no JavaScript.

Why does my @container animation never trigger? Almost always because no ancestor has container-type set. A component cannot query itself; the parent must declare container-type: inline-size so the child can react to its width.

What is a style query and how does it differ from a size query? A size query (@container (min-width)) reacts to the container's measured dimensions. A style query (@container style(--var: value)) reacts to the computed value of a custom property on the container, letting you switch animations by state rather than size.

Do container-query animations respect reduced motion? Only if you write it. Container queries select which animation runs; you still need a prefers-reduced-motion: reduce block to disable or simplify motion regardless of container size.


Related articles

More pages in the same section.