Container Query Units for Proportional Motion

Part of Container Query Motion Triggers in Modern View Transitions & Scroll APIs.

The problem

A card component slides its detail panel in from the right when expanded. In a narrow sidebar the panel travels 24px and looks right. The same card in a wide main column still travels 24px, which barely registers across a 700px panel. Switching to translate: 100% 0 makes the panel travel its own width, which is too much in the wide layout and exposes an empty gap during the slide. Viewport units make it worse: vw distances are huge in a sidebar on a desktop and tiny in a full-width card on a phone.

Motion distance should relate to the space the component actually has — its container.

Root cause analysis: which box a length is relative to

Pixels ignore context. A fixed distance looks proportionally larger in small containers and smaller in large ones.

Percentages in translate refer to the element itself. translate: 50% 0 moves the element by half of its own width, which only equals “half the container” if the element fills the container.

Viewport units refer to the viewport. 10vw is the same in a sidebar and a hero, which is exactly what a reusable component should not depend on.

Container query units refer to the nearest query container. 1cqi is 1% of the inline size of the nearest ancestor with a container-type; cqb uses the block size; cqmin and cqmax use the smaller or larger of the two. A panel that slides by 30cqi travels 30% of its component’s width wherever the component is placed.

Fallback when there is no container. If no ancestor establishes a suitable query container, container units fall back to the small viewport units (svi, svb). The motion still works but reverts to viewport-relative behaviour — a silent bug if the container type was forgotten.

cqb needs a block container. container-type: inline-size makes only the inline axis queryable, so cqb falls back up the tree or to the viewport. Use cqi for most motion, or container-type: size when block size is also defined.

Travel distance of the detail panel in three placementsclamp(16px, 8cqi, 64px) against a fixed 24px.Travel distance of the detail panel in three placementsSidebar 280px, fixed24 pxSidebar 280px, 8cqi clamped22.4 pxColumn 700px, fixed24 pxColumn 700px, 8cqi clamped56 pxFull width 1200px, fixed24 pxFull width 1200px, 8cqi clamped64 px
clamp(16px, 8cqi, 64px) against a fixed 24px.

Step-by-step resolution

Proportional travel for a reusable componentContainer units for distance, clamps for sanity, tokens for time.Proportional travel for a reusable component1Set container-type: inline-size on the card root.cqi now refers to the card2Replace pixel travel with a container-relative expression.Distance follows available space3Clamp the distance between a perceptible minimum and a comfortable maximum.No invisible or huge moves4Keep the duration on the motion token scale.Speed varies modestly, time stays predictable5Test the component in its narrowest and widest placements.Motion reads correctly in both6Under reduced motion, remove the travel and keep a fade.
Container units for distance, clamps for sanity, tokens for time.

Production code pattern

.card {
  container-type: inline-size;
  container-name: card;
}

.card__detail {
  --travel: clamp(16px, 8cqi, 64px);          /* 8% of the card's width, bounded */
  opacity: 0;
  translate: var(--travel) 0;
  transition:
    opacity var(--motion-exit-duration) var(--motion-exit-easing),
    translate var(--motion-exit-duration) var(--motion-exit-easing);
}

.card[data-expanded] .card__detail {
  opacity: 1;
  translate: 0 0;
  transition:
    opacity var(--motion-enter-duration) var(--motion-enter-easing),
    translate var(--motion-enter-duration) var(--motion-enter-easing);
}

/* Keyframes read container units per animating element too. */
.card__badge {
  animation: nudge-in var(--motion-enter-duration) var(--motion-enter-easing) backwards;
}
@keyframes nudge-in {
  from { opacity: 0; translate: 0 clamp(4px, 3cqi, 12px); }
}

@media (prefers-reduced-motion: reduce) {
  .card__detail { --travel: 0px; }
  .card__badge { animation: none; }
}

Rendering Impact: composite. Container units are resolved to pixels when styles are computed; the animation interpolates plain lengths on translate. Resizing the container during an animation retargets future transitions but does not add per-frame layout.

Resolving --travel inside the component, rather than passing a pixel value from outside, keeps the component self-contained: the same markup works in a sidebar, a modal and a full-width layout. Using a custom property also gives the reduced-motion branch one value to neutralise.

Viewport-relative against container-relative travelSame component, placed in a 280px sidebar on a 1440px desktop.Viewport-relative against container-relative traveltranslate: 4vw4% of 1440px = 57.6pxPanel travels a fifth of the sidebarMotion feels oversizedWrong reference boxtranslate: clamp(16px, 8cqi, 64px)8% of 280px = 22.4pxProportional to available spaceSame CSS in every placementScales with the component
Same component, placed in a 280px sidebar on a 1440px desktop.

Duration should not follow container size linearly

It is tempting to scale duration with the same container units, so wider components animate longer. Resist doing so linearly: a card that fills a 1600px screen would animate several times longer than one in a sidebar, and wide layouts would feel slow. If duration must vary, use a small tier — for example, a slightly longer token above a container-size breakpoint via @container card (inline-size > 720px) — or the sub-linear scaling described in scaling duration by distance and size. Clamped distances already keep speed within a sensible range.

Verification checklist

Constraints and trade-offs

  • container-type creates size containment on the chosen axis, which can change layout if the element relied on its contents for width.
  • Nested containers make units refer to the nearest one; name containers and check which applies.
  • Container units in unsupported browsers invalidate the declaration; provide a pixel fallback declaration first.
  • Very large containers can push clamped maxima into view as the dominant case; choose maxima for the widest realistic placement.
  • cqb often falls back unexpectedly with inline-size containers.

Frequently asked questions

What does 1cqi mean?

One percent of the inline size of the nearest ancestor query container. With no suitable container, it falls back to the small viewport inline unit.

Why not use translate percentages?

Percentages in translate refer to the element’s own size, not its container, so they only match container proportions when the element fills its container.

Should animation duration use container units too?

Not linearly. Keep durations on the token scale, or use small tiers or sub-linear scaling, so wide layouts do not feel slow.

Why does my cqb distance ignore the container?

container-type: inline-size only exposes the inline axis. cqb then resolves against another container or the viewport. Use cqi or container-type: size.