Motion Driven by Container Style Queries
Components often move through states — idle, loading, success, error — and each state has its own motion: a spinner while loading, a small bounce on success, a shake on error. The usual approach is a state class on the component and a matching selector for every animated part: .upload.is-loading .upload__icon, .upload.is-error .upload__field, and so on. Container style queries offer a cleaner wiring. Set one custom property on the container — --state: loading — and let each descendant query it. The parts do not need to know about the component's classes, only about the state vocabulary. This page builds a state-driven upload button with style queries and covers how to keep the motion accessible and the fallback sensible. It belongs to Container-Aware Motion in the CSS-Only Micro-Interactions & Animations guide.
How style queries select motion
A container style query tests the computed value of a custom property on an ancestor: @container style(--state: loading) { … }. Rules inside apply to descendants whenever the condition holds. Because the query reads a computed value, it does not matter how the property got there — an inline style, a class, :has(), or a parent's inheritance all work. Any animation property can be set inside the block, so the state picks the animation.
The complete implementation
The demo drives the state with radio buttons and :has(), so it runs without script. In an application, script would set --state directly, for example el.style.setProperty('--state', 'success').
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Style-query motion states</title>
<style>
body { font: 15px/1.5 system-ui, sans-serif; margin: 2rem; }
.upload {
container-name: upload;
--state: idle;
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border: 1px solid #cbd5e1;
border-radius: 10px;
inline-size: fit-content;
}
.upload__icon {
inline-size: 1.25rem;
aspect-ratio: 1;
border-radius: 50%;
border: 3px solid #94a3b8;
}
/* State-specific motion, chosen by the container's --state. */
@container upload style(--state: loading) {
.upload__icon {
border-color: #2563eb #2563eb transparent transparent;
animation: spin 800ms linear infinite;
}
}
@container upload style(--state: success) {
.upload__icon {
border-color: #16a34a;
background: #16a34a;
animation: pop 320ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
}
@container upload style(--state: error) {
.upload__icon {
border-color: #dc2626;
animation: shake 360ms ease-in-out;
}
}
@keyframes spin { to { rotate: 1turn; } }
@keyframes pop { from { scale: 0.6; } }
@keyframes shake {
25% { translate: -4px 0; }
50% { translate: 4px 0; }
75% { translate: -2px 0; }
}
@media (prefers-reduced-motion: reduce) {
.upload__icon { animation-duration: 1ms !important; animation-iteration-count: 1 !important; }
}
</style>
</head>
<body>
<div class="upload" style="--state: loading">
<span class="upload__icon" aria-hidden="true"></span>
<span class="upload__label" role="status">Uploading…</span>
</div>
</body>
</html>
Each state's block sets both the static look (border colour, fill) and the motion (the animation). Switching --state from loading to success stops the spin, because the loading block no longer matches, and starts the pop, because a new animation-name applies. Changing animation-name always starts the new animation from its beginning, which is exactly right for one-off state feedback. It also means that setting the same state twice in a row does nothing: re-applying --state: error after an error does not replay the shake, because the computed animation-name never changed. If repeated errors should each shake, toggle through another state, or restart the animation from script with getAnimations().
The key technique: the state vocabulary is the API
The component's parts depend only on the values --state can take, not on class names or DOM structure. That makes the vocabulary the contract. Document it — idle | loading | success | error — and keep it small. New parts can join the component and react to the same states without touching the component's root; a parent component can even set --state on a child's container to drive it, without reaching into its internals.
Registering the property tightens the contract further. @property --state { syntax: "idle | loading | success | error"; inherits: true; initial-value: idle; } rejects any other value, so a typo such as --state: sucess falls back to idle instead of silently matching nothing. Registered Properties and Type Safety covers keyword syntax strings.
Smoothing the static changes between states
The keyframe animations handle the motion, but each state also changes static properties — the icon's border colour and fill. Those change instantly unless they are transitioned. Adding transition: border-color 200ms ease-out, background-color 200ms ease-out to .upload__icon, outside the style-query blocks, lets colours cross-fade as the state changes while the keyframes play on top. The two mechanisms cooperate: transitions smooth the values that persist in a state, and animations add the one-off or looping motion that marks entering it.
Watch out for inherited state in nested components
--state inherits, which is the point — every descendant can see it. It also means a nested component that uses the same property name sees its ancestor's state. An upload button inside a form whose container is --state: error would start shaking along with the form. Two remedies from Scoping Custom Properties to Components apply directly. Either prefix the property per component (--upload-state, --form-state), or reset it at each component root with a zero-specificity rule such as :where(.upload) { --state: idle; } so every component starts from its own idle state. Naming the container helps too: @container upload style(...) only ever tests the nearest upload container, not whichever ancestor happens to be closest.
Debugging style queries
When a state's motion does not appear, check three things in order. First, confirm the custom property's computed value on the container in DevTools' Computed pane — a stray space or quote makes loading and "loading" different values. Second, confirm the query targets the right container: an unnamed style query tests the nearest ancestor, which may be an intermediate wrapper rather than the component root. Third, check that the animation property is not overridden by a more specific rule outside the query, since rules inside @container have no extra specificity of their own.
Accessibility of state motion
Motion reinforces the state but must not be its only signal. The implementation pairs every animation with a static change — border colour, fill — and the label, marked role="status", announces the new state to screen readers when script updates its text. The shake for errors is small (4 pixels) and runs once, which keeps it clear of vestibular triggers; a larger or repeating shake would not be. Under prefers-reduced-motion: reduce, the implementation collapses every state animation to a single 1ms iteration, so the end-state styling still applies while the movement disappears. The spinner is the one exception worth considering: an infinitely looping spinner for a long upload should also offer a text progress indication, since WCAG 2.2.2 asks for a way to pause motion that lasts more than five seconds.
Style queries versus classes and ()
Style queries are not always the right tool. A class on the component is simpler when only one or two parts animate and they are always in the same place. :has() is better when the state is already expressed in the DOM — an aria-busy="true" attribute, a checked input — because it avoids duplicating that state in a custom property. Style queries shine when the state must flow through several layers of components, or when the parts that react are provided by different teams.
Browser support
Style queries for custom properties are supported in Chrome and Edge 111+, Firefox 151+ and Safari 18+. @property is supported in Chrome and Edge 85+, Firefox 128+ and Safari 16.4+. :has() is supported in Chrome and Edge 105+, Firefox 121+ and Safari 15.4+, and prefers-reduced-motion in Chrome 74+, Edge 79+, Firefox 63+ and Safari 10.1+. In browsers without style queries, the state blocks are ignored and the component stays in its idle appearance; the status text still conveys the state.
FAQ
Can a style query change which animation runs?
Yes. A style query such as @container style(--state: loading) can set animation-name, duration or any other animation property on descendants. Changing the custom property on the container switches every matching animation inside it.
Why use a style query instead of a class for animation states? A class must be applied to or matched from every element that animates. A custom property set once on a container is inherited, and style queries let each descendant react to it without knowing about classes, which keeps components decoupled.
Does the element need container-type for a style query?
No. Style queries work against the nearest ancestor container, and every element is a style container by default. You only need container-type for size queries; container-name helps target a specific ancestor.
What happens in browsers without style queries?
The @container style() rules are ignored, so descendants keep their base animations. Design the base state as the resting state, and treat the state-specific motion as an enhancement.
Related
- Container-Aware Motion — the parent guide.
- Style Queries With Custom Properties — the query syntax in depth.
- CSS-Only Loading Spinners and Skeletons — the loading motion itself.
- Style Query Fallbacks and Support — handling unsupported browsers.
Related articles
More pages in the same section.