Container-Aware Motion: Animation Sized by the Space a Component Actually Has

A component almost never knows where it will land. The same product card ships into a full-bleed marketing row, a three-up grid, and a 280px filter rail, and in each of those places the layout adapts perfectly well while the motion does not: the hover lift, the slide-in offset, and the reveal distance were all written once, in pixels, against whatever width the author happened to have on screen that afternoon. This guide, part of the CSS-only micro-interactions and animations area, is about closing that gap — treating movement as something measured against the component's own box rather than the browser window, using @container and container query units. It leans heavily on the mechanics documented across Mastering Container Queries & Responsive Layouts, because container-aware motion is that area's tooling pointed at a different problem.

The governing idea is a motion budget: every box has an amount of movement it can absorb before movement stops reading as movement. A 40px slide inside a 700px hero is a gesture. The same 40px slide inside a 240px card is a fifth of the component lurching sideways, and readers perceive it as a glitch rather than a transition. Container-aware motion makes that budget explicit and lets the box compute it.

What this section covers:

  • Deriving travel distance from cqi and cqb so a component moves proportionally in any slot.
  • Switching the kind of affordance — hover-reveal versus persistent control — on container width.
  • Removing motion entirely below a size floor, and layering that with user motion preferences.
  • The debugging and support story for shipping this in production.

Prerequisites

You should already be comfortable with:

Nothing here needs JavaScript, a build step, or a preprocessor.


The core concept: movement measured against the containing box

One motion rule, three container widths Three cards representing narrow, medium and wide containers. Each declares the same six cqi travel distance, which resolves to fourteen, twenty-five and forty-two pixels respectively, shown as bars of proportional length. One rule, three resolved distances narrow slot 240px box 6cqi = 14px a small, calm move grid cell 420px box 6cqi = 25px a readable gesture wide banner 700px box 6cqi = 42px room for flourish the stylesheet says 6cqi once; the box supplies the pixels no breakpoints, no measurement, no script

Two independent mechanisms are in play, and keeping them separate is the whole discipline of this section.

Mechanism one is continuous. A container query unit is a length that resolves against the query container's dimensions: 1cqi is one percent of the container's inline size, 1cqb one percent of its block size. Because it is a length, it is legal everywhere a length is legal — including inside translate(), translateX(), margin, inset, and keyframe declarations. Writing transform: translateX(6cqi) produces a distance that varies smoothly with the box, with no thresholds anywhere. There is no step, no jump, no breakpoint list to maintain.

Mechanism two is discrete. A size query — @container (min-width: 380px) — is a boolean that flips at a threshold, and inside its block you can change anything: which @keyframes name runs, whether a transition is declared at all, whether an element is revealed on hover or simply always present. This is the mechanism for decisions that cannot be interpolated. "Should this panel open on hover, or should its controls just be visible?" has no halfway answer; it needs a threshold.

Most real container-aware components use both. The continuous mechanism keeps the ordinary case proportional; the discrete mechanism handles the two or three places where proportionality is not enough and the interaction design itself has to change.

The reason this belongs in a motion section rather than a layout one is that motion has a perceptual floor that layout does not. Shrink a font from 18px to 15px and it stays legible. Shrink a slide from 40px to 6px and it stops being a slide — it becomes a shimmer, an artefact, something the eye registers as an error. Layout degrades gracefully under compression; motion degrades into noise. That asymmetry is why a section on container-aware motion needs a page specifically about suppressing motion in small containers, and why the honest answer at the bottom of the scale is often zero.


Syntax and parameters

TokenAccepted valuesDefault / notes
container-typenormal, inline-size, sizenormal. inline-size is the safe choice; size also contains the block axis and requires an explicit height.
container-name<custom-ident> list, or nonenone. Naming lets a nested descendant target a specific ancestor.
container<name> / <type> shorthandUnset. container: card / inline-size sets both at once.
1cqilength1% of the query container's inline size — the unit for horizontal travel in a horizontal writing mode.
1cqblength1% of the query container's block size. Requires container-type: size, not inline-size.
1cqmin / 1cqmaxlength1% of the smaller / larger of the two container axes. Useful for square-ish travel that must not overflow either way.
@container <name>? (<condition>)min-width, max-width, min-height, inline-size, rangesMatches against the nearest eligible container, or the named one.
@container <name>? style(--p: v)custom property comparisonMatches on a computed custom property rather than a measurement.

Two constraints catch people out. cqb and any block-axis query need container-type: size, which applies containment in both axes and therefore requires the container to have a determinate height — an auto-height container with container-type: size collapses. And a container query unit used outside any container falls back to the small viewport unit equivalent, so 6cqi on an element with no query-container ancestor silently becomes 6svi, which is why forgetting container-type produces motion that looks plausible but tracks the wrong thing entirely.


Step-by-step implementation

Step 1 — Establish the container and take nothing else from it

The wrapper exists to be measured. Give it container-type and, in anything larger than a demo, a name, so a future nested container cannot accidentally intercept the query. Naming conventions are covered in nesting and naming container queries.

.card-slot {
  container: card / inline-size; /* name / type shorthand */
}

Step 2 — Express travel as a container fraction

Replace the pixel constant with a cqi value. Pick the number by asking what fraction of the component should move, not how many pixels felt right at one width.

.card {
  transform: translateY(0);
  transition: transform 240ms cubic-bezier(.2, .8, .2, 1);
}

/* 2% of the container's inline size, upward, on hover. */
.card:hover,
.card:focus-within {
  transform: translateY(calc(-2cqi));
}

Step 3 — Bound the fraction so extremes stay sane

A raw fraction is unbounded: at 1400px, 2cqi is 28px, which may be more lift than the design ever wants. clamp() gives the proportional value a floor and a ceiling, which is exactly the fluid-sizing pattern applied to distance instead of type.

.card {
  /* Never less than 4px of lift, never more than 14px, proportional between. */
  --lift: clamp(4px, 2cqi, 14px);
  transition: transform 240ms cubic-bezier(.2, .8, .2, 1);
}

.card:hover,
.card:focus-within { transform: translateY(calc(-1 * var(--lift))); }

Step 4 — Let pacing follow distance

If the distance grows, the duration should grow with it or the wide version reads as faster. Derive a unitless ratio from the container and multiply a base time by it. Keep the ratio dimensionless — a length cannot be multiplied into a <time>.

.card {
  /* Unitless 0.4 … 1 ratio describing how much room the card has.
     `cqi` is a length, and clamp() cannot mix a length with plain numbers, so the ratio has to
     be converted first: tan(atan2(<length>, <length>)) returns a real <number>. */
  --cqi-ratio: tan(atan2(1cqi, 1px));
  --room: clamp(0.4, calc(var(--cqi-ratio) * 0.16), 1);
  --lift: clamp(4px, 2cqi, 14px);
  --beat: calc(120ms + 180ms * var(--room)); /* 120ms … 300ms */

  transition: transform var(--beat) cubic-bezier(.2, .8, .2, 1);
}

Drag the frame below and hover at each width — the card rises further and takes longer as the container grows, so the perceived velocity stays constant.

Live demoHover lift and duration derived from container width
Hover the card, then drag the frame narrower and hover again — the lift distance and the time it takes both shrink with the container. Drag the bottom-right corner to resize the frame in either direction.

Step 5 — Add the discrete decisions on top

Continuous scaling handles the middle of the range. At the ends, use a query. Here the narrowest containers drop the lift entirely, because a 4px hover translate on a 240px card is imperceptible at best and jittery at worst.

@container card (max-width: 300px) {
  .card { transition: none; }
  .card:hover,
  .card:focus-within { transform: none; box-shadow: 0 0 0 1px currentColor; }
}

Note that the narrow case still responds to hover — it just responds with a static change rather than a movement. Removing the movement and removing the feedback are different things, and confusing them is how reduced variants end up feeling broken.

Step 6 — Put the user preference above everything

Container size decides what motion is appropriate. It never decides whether motion is allowed. The preference query goes last and overrides both branches.

@media (prefers-reduced-motion: reduce) {
  .card,
  .card:hover,
  .card:focus-within {
    transition: none;
    transform: none;
  }
}

Annotated production example: a queue item that changes its own affordance

This is a realistic list row — the kind that appears in a wide inbox pane, a medium two-column dashboard, and a narrow mobile-width rail, from the same markup. Above 380px of container width it hides its actions and slides them open on hover or focus. Below that, the actions are simply always there, because hover-to-reveal has no touch equivalent and no room to expand into.

Live demoHover-to-reveal in a wide card, persistent control when narrow
Hover the card at full width to slide the detail panel open, then drag the frame under 380px — the panel stops hiding and the control becomes permanently visible. Drag the bottom-right corner to resize the frame in either direction.
<div class="holder">
  <article class="item">
    <h3 class="item__name">Northwind invoice #4192</h3>
    <p class="item__meta">Due 14 August &middot; 2 attachments</p>
    <div class="item__extra">
      <a class="item__act" href="#">Approve</a>
      <a class="item__act" href="#">Request change</a>
    </div>
  </article>
</div>
.holder { container-type: inline-size; container-name: holder; }

.item {
  padding: 1rem;
  border: 1px solid var(--demo-border);
  border-radius: 12px;
  background: var(--demo-surface);
}
.item__name { margin: 0 0 .25rem; font-size: 1rem; color: var(--demo-fg); }
.item__meta { margin: 0; color: var(--demo-muted); font-size: .85rem; }

/* Narrow default: the actions are simply part of the card, always reachable. */
.item__extra {
  display: flex;
  gap: .5rem;
  flex-wrap: wrap;
  margin-top: .75rem;
}

.item__act {
  padding: .4rem .7rem;
  border: 1px solid var(--demo-accent);
  border-radius: 999px;
  color: var(--demo-accent);
  text-decoration: none;
  font-size: .82rem;
}

/* Only when there is room does the reveal affordance switch on. */
@container holder (min-width: 380px) {
  .item__extra {
    margin-top: 0;
    max-height: 0;
    opacity: 0;
    overflow: hidden;
    transition: max-height .3s ease, opacity .3s ease, margin-top .3s ease;
  }
  .item:hover .item__extra,
  .item:focus-within .item__extra {
    max-height: 4rem;
    opacity: 1;
    margin-top: .75rem;
  }
}

The --demo-* values above are the theme variables supplied by the demo frame, so the example follows light and dark mode here. Substitute your own colour tokens when you copy it.

Three details are load-bearing. First, the default is the always-visible variant and the reveal is the enhancement — written the other way round, an engine without container query support would ship a permanently hidden action row, which is a functional regression rather than a cosmetic one. Second, :focus-within accompanies :hover on every reveal rule, so a keyboard user tabbing into a hidden action opens the panel; without it the focused control sits inside a zero-height overflow-hidden box and the focus ring is invisible. Third, the transition is declared inside the query, not outside — so the narrow variant has no transition property to accidentally animate when the container resizes across the threshold.

The interaction-design argument behind this pattern, including why hover: none pointer detection is a complement rather than a substitute, is developed in container query hover affordances. For the layout side of the same component — how the card itself reflows across widths — see building responsive cards with container queries.


Performance and accessibility notes

Container units are free at animation time. A cqi value is resolved during style computation into an absolute length; by the time the compositor has the transform, it is looking at pixels like any other. Animating transform: translateX(6cqi) therefore stays on the compositor thread exactly as translateX(24px) would, and the same guidance about which properties are cheap applies unchanged — see will-change and the compositor thread.

The cost shows up on resize, not on play. When a query container changes size, the browser must re-resolve every container unit inside it and re-evaluate every @container condition that targets it. That is style work proportional to the subtree, and it is why you should not nest twelve query containers inside each other for the sake of it, and why container-type: size is worth avoiding unless you genuinely need the block axis. During a drag-resize or a sidebar collapse animation, that recomputation happens every frame.

Do not animate the container's own size while its children read cqi. Animating the width of a query container makes every descendant's container-unit length a moving target, which forces layout and style recalculation on every frame and produces exactly the jank the compositor was avoiding. Animate the container's transform instead, or animate the children and leave the box still.

will-change is still a scarce resource. Container awareness does not change the rule: add it to the handful of elements actively animating, remove it when they stop, and never blanket-apply it to a list.

Accessibility comes in three parts. Reduced-motion handling must sit outside the container logic and win over it — that is non-negotiable, and the recipes in prefers-reduced-motion recipes apply as written. Any hover-revealed content must be equally reachable by keyboard via :focus-within, and must not be the only route to a function. And large-amplitude movement is a vestibular concern independent of container size — a wide container is permission to move more, not permission to move a lot; the boundaries are covered in vestibular-safe animation patterns.


DevTools debugging workflow

Container-aware motion fails in ways that look like nothing happening, so the debugging loop is mostly about confirming which container is answering.

  1. Confirm a container exists. In Chrome or Edge DevTools, select the animating element and look at the Elements panel — an ancestor acting as a query container is badged container. If no ancestor carries the badge, your cqi values are silently resolving against the viewport.
  2. Check the Computed pane, not the Styles pane. The Styles pane shows translateY(-2cqi) as authored. The Computed pane shows the resolved pixel length. If Computed reports a value that tracks the window rather than the box as you resize, containment is on the wrong ancestor.
  3. Watch the @container rule flip live. With the element selected, drag the window or the container's own size in the layout; matching @container blocks appear and disappear from the Styles pane in real time. A rule that never appears has a name mismatch or is querying an axis the container does not contain.
  4. Use the Animations panel to check duration, not distance. Open the Animations drawer, trigger the interaction, and read the actual duration. If a calc() produced an invalid <time> — the classic being multiplying a length by a time — the declaration is dropped and the element falls back to the initial 0s, which looks identical to "no transition declared".
  5. Toggle reduced motion from the Rendering drawer. Emulate prefers-reduced-motion: reduce and re-run every container width. The reduced variant must still show a visible state change on hover and focus at every size.
  6. Watch the Performance timeline while resizing. A resize that triggers a long purple style-recalculation bar in a component using many container units is the signal to reduce the number of nested containers or to stop animating the container's dimensions.

Browser compatibility

FeatureChrome / EdgeSafariFirefox
Size container queries (@container, container-type)105+16+110+
Container query units (cqi, cqb, cqmin, cqmax)105+16+110+
Container units inside transform / @keyframes105+16+110+
Style queries for custom properties111+18+not yet
clamp() in length and ratio arithmetic79+13.1+75+
prefers-reduced-motion74+10.1+63+

Everything in the continuous half of this section — cqi distances, clamped travel, derived durations — has been interoperable since early 2023. Style queries are the one piece still missing an implementation in Firefox, so treat any style-query-driven motion switch as an enhancement layered over a size-query or class-based default. Where you need to gate the whole approach, @supports (container-type: inline-size) is the correct test; the pattern is set out in feature detection with @supports, and the viewport-breakpoint fallbacks in handling container query fallbacks for older browsers still apply to motion exactly as they do to layout.


Common pitfalls

PitfallCauseResolution
cqi distances track the window, not the componentNo ancestor has container-type, so container units fall back to viewport unitsAdd container-type: inline-size to the wrapper and confirm the container badge in DevTools
The container collapses to zero heightcontainer-type: size applies block-axis containment to an auto-height boxUse inline-size unless you need cqb; if you need cqb, give the container a determinate height
A derived duration is ignored entirelycalc() multiplied a length by a time, producing an invalid <time> that the parser dropsReduce the container value to a unitless ratio first, then multiply the base time by it
Hover reveal traps keyboard focus in an invisible boxThe reveal rule matches :hover only, while overflow: hidden clips the focused controlPair every :hover reveal with :focus-within on the same selector list
Motion re-plays or stutters while the sidebar animates openThe query container's own width is being animated, forcing style and layout recalculation each frameAnimate a transform on the container instead of its width, or suspend descendant transitions during the resize

FAQ

What makes motion container-aware rather than responsive? Responsive motion changes with the viewport, so a component sitting in a narrow sidebar on a large screen still gets desktop-sized movement. Container-aware motion measures the element's own containing box with @container and container query units, so the same component moves less in a narrow slot and more in a wide one.

Can I use cqi directly inside transform and keyframes? Yes. Container query units resolve to lengths, so translate(8cqi) or a keyframe declaring transform: translateX(60cqi) is valid anywhere a length is accepted, provided some ancestor has container-type set to size or inline-size.

Does animating with container units cost more performance than pixels? No meaningful difference at run time. The unit resolves to a pixel length during style computation, and the resulting transform still runs on the compositor. The extra work happens only when the container resizes and styles are recomputed.

Should the container decide whether motion happens at all? It can decide whether motion is appropriate, but never whether it is permitted. Container size chooses the amount and shape of movement; prefers-reduced-motion holds a veto that must override any container-driven choice.


Related articles

More pages in the same section.