Suppressing Motion in Small Containers: Deliberately Animating Nothing

Most container-aware technique is about making motion fit. This page is about the other conclusion — that below a certain size the honest design decision is no motion at all, and that shipping a still component in a tight slot is a feature rather than a shortcut. A carousel drift, a parallax offset, or a slide-in that was tuned in a 700px hero does not become a gentler version of itself at 260px; it becomes a few pixels of twitch, which readers experience as a rendering fault. This page is part of the container-aware motion section and picks up exactly where the proportional approach in motion that scales with container size runs out of road.


The case for a motion floor

Perceived movement has a lower bound. Below roughly six to eight pixels of travel, over a duration in the 200-400ms band typical of interface transitions, the eye stops resolving a direction and starts registering only that something changed — the same signal it gets from a repaint artefact, a subpixel reflow, or a flickering GPU layer. Proportional travel walks straight into that band: a distance written as 8cqi is a comfortable 56px at 700px and an unusable 21px at 260px, and clamping it to a floor of 8px does not save the effect, because the problem is not that 8px is too small to see. It is that 8px of travel inside a 260px component is too small to read as travel.

Small containers make it worse in three specific ways. Travel that is a large fraction of the container reads as the whole component moving rather than an element moving within it. Ambient loops — drifting badges, sweeping shimmer, a slow parallax — sit permanently in peripheral vision in a dense sidebar, where a dozen instances of them compete for attention that none of them deserve. And small containers are typically found in dense contexts, list rails and filter panels and compact dashboards, where the same component is repeated many times and any per-instance ambient motion multiplies into visual noise.

The alternative approaches are worse. Slowing the animation down does not help, because a slower tiny movement is a longer-lived artefact rather than a clearer gesture. Detecting the size in JavaScript and stripping a class works but pays a script and a measurement for a decision CSS can make in the cascade. A blanket removal of the effect everywhere throws away motion that is genuinely useful at the sizes where the component has room. A container floor is the smallest correct intervention: keep the effect where it works, remove it where it does not, express both in one stylesheet.

The tradeoff to be honest about is threshold churn. A hard-coded floor is a magic number that needs revisiting when the component's typography or padding changes, and a container that resizes across the boundary during a sidebar animation will flip motion on and off mid-drag. Both are manageable — put the floor in a custom property, and avoid animating the container's own width — but neither disappears.


Complete working implementation

The tile below grants motion rather than removing it. Nothing animates in the base rules; a single @container block above the floor switches the badge drift and the progress sweep on. Drag the frame under 360px and the component goes completely still while keeping its layout, its colour, and its content exactly as they were.

A motion floor with a user-preference override Container widths below the floor resolve to no animation; widths above it enable animation. A band underneath shows the reduced motion preference overriding both zones. A floor for motion, a veto above it floor: 360px below the floor no animation declared above the floor animation granted prefers-reduced-motion: reduce removes motion in both zones size is an opinion; the preference is a rule
Live demoAnimation switched off below a container width floor
At full width the badge drifts and the bar sweeps. Drag the frame below 360px and both stop dead — the narrow card keeps the same layout with no motion at all. Drag the bottom-right corner to resize the frame in either direction.
<div class="tile-wrap">
  <article class="tile">
    <span class="tile__badge">Live</span>
    <h3 class="tile__head">Deploy queue</h3>
    <div class="tile__bar"><span class="tile__fill"></span></div>
    <p class="tile__note">Motion only above the width floor.</p>
  </article>
</div>
.tile-wrap { container-type: inline-size; container-name: tile; }

.tile {
  padding: 1rem;
  border: 1px solid var(--demo-border);
  border-radius: 12px;
  background: var(--demo-surface);
}
.tile__head { margin: .5rem 0 .7rem; font-size: 1rem; color: var(--demo-fg); }
.tile__note { margin: .7rem 0 0; color: var(--demo-muted); font-size: .82rem; }

.tile__badge {
  display: inline-block;
  padding: .2rem .55rem;
  border-radius: 999px;
  background: var(--demo-accent);
  color: var(--demo-bg);
  font-size: .72rem;
}

.tile__bar {
  height: 8px;
  border-radius: 4px;
  background: var(--demo-border);
  overflow: hidden;
}
.tile__fill {
  display: block;
  height: 100%;
  width: 40%;
  border-radius: 4px;
  background: var(--demo-accent);
}

/* Motion is opt-IN, granted only once the container is wide enough
   for the travel to read as movement rather than vibration. */
@container tile (min-width: 360px) {
  .tile__badge { animation: drift 2.6s ease-in-out infinite alternate; }
  .tile__fill  { animation: sweep 2.6s ease-in-out infinite alternate; }
}

@keyframes drift { to { transform: translateX(4cqi); } }
@keyframes sweep { to { width: 85%; } }

/* The user preference outranks the container every time. */
@media (prefers-reduced-motion: reduce) {
  .tile__badge, .tile__fill { animation: none; }
}

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.

The suppressed rendering is not a degraded one. The badge still sits where it sits, the bar still shows 40%, the colours and spacing are untouched — the only thing missing is the drift, which was ambient decoration. Nothing that carried information was in the motion, and that is the test a suppressible effect has to pass before you make it suppressible.


The key technique: grant motion, do not revoke it

The structural choice that makes this maintainable is writing the animation inside the min-width query rather than writing it at the top level and cancelling it inside a max-width query. The two look equivalent and are not.

Granting means the still state is the cascade's base. Every failure — an engine without container query support, a mistyped container name, a wrapper that lost its container-type in a refactor — resolves to no animation, which is safe, quiet, and indistinguishable from a deliberate choice. Revoking means the animated state is the base and the cancellation is conditional, so every one of those same failures resolves to animation everywhere, including the 240px slots where it was specifically judged unacceptable.

Granting also avoids a subtle cancellation bug. animation: none inside a max-width block removes the animation, but it does not undo any base styles that the animation was compensating for. If the base rule set opacity: 0 expecting an entrance keyframe to bring it to 1, cancelling the animation leaves the element permanently invisible. Under the grant model that cannot happen, because the base rules never contain an entrance start state at all — they describe the finished, resting element, and the keyframes are the only thing that ever moves it away from that. When you do have to revoke, reset every property the keyframes touched in the same block:

/* If you must revoke rather than grant, restore the resting state too. */
@container tile (max-width: 359px) {
  .panel {
    animation: none;
    opacity: 1;        /* undo the entrance start state */
    transform: none;   /* undo any offset the keyframes assumed */
  }
}

Variation: keeping the still version alive with an instant state change

The failure mode of suppression is a component that stops responding. If hover feedback consisted entirely of a lift, removing the lift removes the feedback, and the narrow variant feels dead rather than calm. The fix is to separate acknowledgement from movement: below the floor, keep a state change that is instantaneous rather than animated.

/* Base: acknowledgement with no travel — instant, works at any size. */
.row {
  border-inline-start: 3px solid transparent;
  background: transparent;
}
.row:hover,
.row:focus-within {
  border-inline-start-color: currentColor;
  background: color-mix(in srgb, currentColor 6%, transparent);
}

/* Above the floor, add the movement on top of the same acknowledgement. */
@container tile (min-width: 360px) {
  .row {
    transition: transform 200ms cubic-bezier(.2, .8, .2, 1),
                background 200ms ease;
  }
  .row:hover,
  .row:focus-within { transform: translateX(clamp(4px, 1.5cqi, 12px)); }
}

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

The border and background change carry the entire semantic load; the translate is an embellishment layered over it. That ordering also means the reduced-motion block only has to remove the embellishment, never the feedback — which is precisely the structure recommended in prefers-reduced-motion recipes and the reason a well-built reduced variant never needs a separate "accessible" design.


Accessibility framing

Suppressing motion by size is an editorial judgement; suppressing it by preference is an obligation, and the two must not be allowed to substitute for each other. A component whose container happens to be wide has no special licence to ignore prefers-reduced-motion, and a component that is narrow is not thereby compliant. Keep the preference query outside and after every container block so it wins on cascade order regardless of specificity, and remember that WCAG 2.2 success criterion 2.3.3 concerns motion triggered by interaction — the details of what the criteria do and do not require are in WCAG motion success criteria. Looping ambient motion of the kind suppressed here also raises the five-second auto-playing rule from 2.2.2 if it runs indefinitely alongside content; the vestibular considerations behind large-amplitude effects are covered in vestibular-safe animation patterns.


Browser support

The pattern needs only size container queries and the reduced-motion media feature. Container queries are available in Chrome and Edge 105+, Safari 16+, and Firefox 110+; prefers-reduced-motion in Chrome 74+, Safari 10.1+, and Firefox 63+. Because motion is granted inside the query rather than revoked, an engine that does not understand @container skips the block entirely and renders the still variant everywhere — a correct, conservative result that needs no @supports gate. If you want the older engine to keep some motion at large viewports, wrap a viewport breakpoint in @supports not (container-type: inline-size) and grant the animation there instead.


FAQ

Why does a slide or parallax effect look like jitter in a small container? The travel distance shrinks with the box until it is only a few pixels, and a few pixels of movement over a few hundred milliseconds is below the threshold at which the eye reads direction. Without a perceived direction the movement registers as instability or a rendering fault rather than a transition.

Should the container query or prefers-reduced-motion win? The user preference always wins. Treat the container floor as an editorial judgement about whether motion is useful at that size, and the reduced-motion query as a hard constraint that must be able to remove motion the container query allowed.

How do I stop the suppressed version from feeling broken? Keep the state change and remove only the movement. Swap the animated property for an instant one such as a border, background, or opacity step so hover, focus, and open states are still visibly acknowledged without travel.

Is animation: none enough to suppress an animation? It removes the animation but leaves the element wherever its base styles put it, which may be an entrance start state such as opacity: 0. Reset those properties in the same rule so the element renders in its final position rather than its hidden one.


Related articles

More pages in the same section.