Container Style Queries: CSS Theming Without JavaScript
Container style queries let you theme components based on custom property values — no JS, no class toggling. Here's how they actually work and when to use them.
What Container Style Queries Actually Are
Honestly, most developers are still sleeping on container style queries — and the ones who've heard of them think they're just a fancier version of container size queries. They're not. Style queries let you conditionally apply CSS based on the computed value of a custom property on a container element. That's a fundamentally different capability.
The distinction matters. Size queries react to *how big* a container is. Style queries react to *what state* it's in — expressed through CSS custom properties. You can have a --theme: dark property on a wrapper div and every descendant component can branch its styles off that single signal. No JavaScript. No class toggling. No React context re-renders.
Browser support landed in Chrome 111, and as of mid-2026 you've got it in Firefox 128+ and Safari 17.4+. We're past the 'experimental flag' phase. If you're building for a reasonably modern user base, this is production-viable right now.
The Syntax: container-type, @container, and style()
To use style queries you declare a containment context, then query it with the style() function inside an @container rule. The setup is minimal. A container needs container-type: style (or inline-size if you also want size queries on the same element) and optionally a container-name for targeting specific ancestors.
Here's a working example. Say you're building a card component that needs to adapt between a light brand theme and a dark analytics dashboard theme — without the parent knowing anything about the card's internals:
/* Parent wrapper sets the theme token */
.dashboard {
container-type: style;
container-name: ui-surface;
--surface-theme: dark;
}
.sidebar {
container-type: style;
container-name: ui-surface;
--surface-theme: light;
}
/* Card responds to the token — no JS, no prop drilling */
.card {
background: rgba(255, 255, 255, 0.9);
color: #111;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
@container ui-surface style(--surface-theme: dark) {
.card {
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.9);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
}
}That's the whole thing. The .card component doesn't need a theme prop. The dashboard doesn't need to pass anything down. The CSS containment chain handles the communication.
Why This Beats JavaScript Theming for Component Libraries
If you've built a theme toggle in React, you know the usual approach: set a class on <html>, use CSS variables keyed to that class, maybe store preference in localStorage. It works. But it also means your components are coupled to the global theme state — which creates friction when you embed a component inside a marketing section that intentionally inverts the theme.
Style queries break that coupling. A component can respond to the nearest ancestor's --theme token, not the global root. That means you can have a light-background page with a dark hero section, and any component inside the hero automatically picks up the dark variant. No special prop, no wrapper HOC, no React context gymnastics.
The performance angle is real too. JavaScript-driven theming triggers style recalculations through a render cycle. CSS containment is handled entirely in the browser's style engine — it's faster by definition. For something like a data table with hundreds of rows each containing themed sub-components, that difference is measurable.
Compare this to the Tailwind vs CSS Modules tradeoff: Tailwind gives you utility classes that are applied at build time, but dynamic theming still requires class-toggling via JS. Container style queries give you dynamic branching that's purely CSS, which is a genuinely different tool — not a replacement for Tailwind, but a layer above it.
Integrating Style Queries with Design Tokens
Design tokens are where style queries get really interesting. Instead of querying for a single --theme: dark value, you can expose a whole token system as custom properties and let style queries compose behavior from them. Think of it as a CSS-native feature flag system for visual states.
Here's a pattern that works well with Empire UI's component architecture. Define a token contract at the container level, then let each component opt into the values it cares about:
``tsx
// In your React component — set the token on the container
function DashboardShell({ variant = 'default' }: { variant: 'default' | 'analytics' | 'minimal' }) {
return (
<div
className="dashboard-shell"
style={{
'--ui-variant': variant,
'--ui-density': variant === 'minimal' ? 'compact' : 'normal',
} as React.CSSProperties}
>
{/* All children respond to these tokens via CSS style queries */}
</div>
);
}
`
``css
/* Global stylesheet — no component-level JS needed */
.dashboard-shell {
container-type: style;
container-name: shell;
}
.stat-card {
padding: 20px;
gap: 8px;
font-size: 1rem;
}
@container shell style(--ui-density: compact) {
.stat-card {
padding: 12px;
gap: 4px;
font-size: 0.875rem;
}
}
@container shell style(--ui-variant: analytics) {
.stat-card {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
}
The component tree stays clean. You're not threading density and variant props down through five levels of components. One container, one set of tokens, all descendants adapt.
Combining Style Queries with Glassmorphism Effects
One practical use case I keep coming back to: glassmorphism effects that need to invert or mute based on surface context. If you've read the glassmorphism guide, you know the technique depends heavily on what's behind the frosted glass — a light background needs different blur and opacity values than a dark one.
Style queries solve this elegantly. Set --surface: light or --surface: dark on the parent container, and your glass components automatically pull the right values. No prop. No variant class.
```css .glass-card { backdrop-filter: blur(12px); background: rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.2); border-radius: 16px; } @container ui-surface style(--surface: dark) { .glass-card { background: rgba(255, 255, 255, 0.06); border-color: rgba(255, 255, 255, 0.1); backdrop-filter: blur(20px) saturate(180%); } } @container ui-surface style(--surface: vibrant) { .glass-card { background: rgba(120, 80, 255, 0.12); border-color: rgba(120, 80, 255, 0.25); } }
What's particularly good about this pattern is that it composes with CSS Houdini paint worklets. You can have a Houdini worklet rendering the background texture and a style query adapting the glass overlay on top of it — two CSS-native APIs working in parallel with zero JavaScript coordination between them.
Current Limitations and Workarounds
Style queries have a few rough edges you should know about before going all-in. The spec only allows querying custom properties (CSS variables) — you can't query computed values of regular properties like color or background-color. That's by design, since querying computed values would create circular dependency risks, but it means you need to be deliberate about what you expose as queryable tokens.
The other limitation is specificity inheritance. Style query rules follow normal CSS cascade rules, which sounds obvious until you're debugging a component that's inside two nested containers each setting conflicting values for --theme. The nearest ancestor wins, which is usually what you want — but you need to make sure your container hierarchy is intentional.
What about Tailwind v4.0.2? Tailwind doesn't have built-in utilities for container-type: style yet. You'll need to either add it to your @layer utilities in your global CSS or just write it as a standard CSS class. The upcoming Tailwind v4.1 roadmap mentions container query improvements, but style queries specifically aren't confirmed. For now, a short custom utility handles it fine:
```css @layer utilities { .container-style { container-type: style; } .container-style-size { container-type: style inline-size; } }
Can you use style queries and size queries on the same element? Yes — container-type: style inline-size enables both. The @container rule accepts both style() and dimension conditions. You can even combine them: @container sidebar (max-width: 320px) and style(--density: compact) is valid syntax.
Testing and Debugging Container Style Queries
Chrome DevTools has the best support right now. In the Elements panel, custom properties on a container element show up in the Computed styles section, and you can see which @container rules are matching in the Styles panel — they're labeled with the container name and the style condition. Firefox's Inspector is close behind as of version 128.
For unit testing React components that depend on style queries, you're mostly out of luck with jsdom — it doesn't evaluate CSS at all. Integration tests with Playwright or Cypress against a real browser are the right tool here. A simple test pattern: set the custom property on a parent element, then assert that the child element has the expected computed styles. For parallax scrolling effects or other scroll-triggered visual states that combine with style queries, Playwright's page.evaluate() gives you access to getComputedStyle which reads the actual applied values.
One debugging trick: temporarily give your container a visible border in DevTools to confirm the containment boundary is where you think it is. It sounds basic, but unexpected ancestors grabbing container status is a surprisingly common bug when you're refactoring component trees.
When to Reach for Style Queries vs Other Approaches
Style queries aren't the answer to every theming problem. They're specifically good at: components that need to adapt to their ancestor's semantic state, multi-theme layouts where different sections use different palettes, and design token systems where you want CSS-native reactivity. They're not good at: user-preference-driven global theme switching (use prefers-color-scheme or a root class for that), component state that changes frequently based on user interaction (use :hover, :focus, data attributes, or JS), or anything requiring animation timing coordination between components.
The mental model I find most useful: think of style queries as a CSS-native event bus for visual state. The container publishes a state through a custom property. Any descendant can subscribe to that state through an @container style() rule. It's one-way, it's declarative, and the browser handles the subscription efficiently.
For Empire UI components specifically, style queries work best as a layer on top of — not a replacement for — the existing variant prop system. Use props for states that callers explicitly control, and style queries for states inherited from the layout context. That keeps the component API clean while giving layout authors a way to influence visuals without threading props everywhere.
FAQ
Yes, as of Firefox 128 and Safari 17.4 style queries are supported without flags. Chrome has had them since version 111. If you need to support older browsers, you'll want a fallback — typically the default non-queried styles should represent your baseline (light) theme.
No — only CSS custom properties (variables) are queryable in style queries. The spec explicitly restricts this to avoid circular dependency issues with computed values. You need to define your own custom property tokens and set them on the container to use as query targets.
The nearest ancestor container wins. This follows normal CSS cascade rules for custom property inheritance. If .outer sets --theme: light and .inner sets --theme: dark, a component inside .inner will match style(--theme: dark) queries. The specificity of the @container rule itself doesn't affect which container value is read.
Yes. Set container-type: style inline-size to enable both. Your @container rules can then mix dimension conditions and style() conditions — including combining them with and: @container sidebar (max-width: 320px) and style(--density: compact) is valid CSS.
Tailwind v4.0.2 doesn't ship a utility for container-type: style. Add your own in @layer utilities in your global CSS file — something like .container-style { container-type: style; }. Tailwind's existing container query plugin handles size-based @container rules but not style() conditions yet.
No — style queries are purely a browser runtime feature evaluated during style cascade, the same as any other CSS selector. SSR just sends HTML and CSS to the browser; the style query evaluation happens client-side like normal. There's no hydration concern because no JavaScript is involved.