Understanding CSS State Queries
CSS State Queries are a family of modern CSS mechanisms that allow you to apply styles conditionally, based on the current state of an element, its descendants, or its environment. Traditional pseudo-classes like :hover, :focus, and :checked already let us react to user interaction. The new generation of state queries goes much further, enabling you to write styles that respond to complex component states, the presence of certain child elements, custom states exposed by Web Components, and even the computed style values of a container — all without a single line of JavaScript.
In essence, state queries turn CSS from a static declaration of styles into a dynamic, context-aware styling engine. They unlock patterns like parent-element styling based on a child’s focus or validity, declarative theming via container style queries, and deeply encapsulated component styling through the Custom State API.
Why CSS State Queries Matter
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Embracing state queries brings several tangible benefits to modern web applications:
- Reduced JavaScript dependency – Many UI reactions that previously required event listeners and manual class toggling can now be handled purely in CSS.
- Better performance – Browsers optimise native pseudo-class evaluation, often outperforming JavaScript-driven style recalculations.
- Cleaner, more declarative code – State is expressed directly in the stylesheet, making the relationship between appearance and state obvious and easier to maintain.
- Improved accessibility – Features like
:focus-visiblehelp tailor focus indicators for keyboard users without affecting mouse users. - True encapsulation in Web Components –
:state()lets component authors expose internal states without leaking implementation details, while consumers style those states naturally.
Whether you’re building a design system, a complex form, or a reusable component library, mastering state queries will make your CSS more expressive and your components more robust.
Core State Query Mechanisms
:has() – The Parent State Selector
The :has() pseudo-class is often called a “relational” selector, but it’s fundamentally a state query about descendants. It matches an element if it contains at least one descendant that satisfies the inner selector. Because it can check interactive pseudo-classes like :hover, :focus, or :invalid on children, it effectively lets you style a parent based on the state of its child.
Example: Highlight a form fieldset when any of its inputs are focused, and apply a different style when an input is invalid.
fieldset:has(input:focus) {
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0,123,255,0.25);
}
fieldset:has(input:invalid) {
border-color: #dc3545;
background-color: #fff5f5;
}
Another powerful use case is styling a card that contains a specific element, such as an image with a particular attribute, or a call-to-action button.
.card:has(img[src$=".svg"]) {
border: 2px solid #6f42c1;
}
.card:has(.btn--primary:hover) {
transform: scale(1.02);
transition: transform 0.2s;
}
:has() supports full complex selectors inside it, including combinators. You can even chain multiple :has() calls to create sophisticated state-driven logic.
:focus-visible and :focus-within
These two pseudo-classes refine how we handle focus state. :focus-visible applies styles only when the browser determines that focus should be visually indicated (typically keyboard navigation), leaving mouse users unaffected. :focus-within matches an element when it itself or any descendant is focused — a perfect tool for highlighting the active form group or interactive region.
/* Accessible focus ring only for keyboard users */
button:focus-visible {
outline: 3px solid #005fcc;
outline-offset: 2px;
}
/* Highlight the whole form row when an input inside is focused */
.form-group:focus-within {
background-color: #f0f7ff;
border-radius: 8px;
}
Using :focus-visible instead of the generic :focus prevents ugly focus rings on mouse clicks, while still providing clear indicators for keyboard navigation.
:modal – Styling Based on Dialog State
The <dialog> element comes with a dedicated :modal pseudo-class that matches when the dialog is opened using showModal(). This lets you style the dialog itself and its backdrop purely with CSS, without maintaining a separate “open” class.
/* Styles applied only when the dialog is a modal overlay */
dialog:modal {
border: none;
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0,0,0,0.3);
padding: 2rem;
}
dialog::backdrop {
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
}
The ::backdrop pseudo-element automatically appears behind a modal dialog, so you can create dimming and blur effects without any extra markup.
:popover-open – State for the Popover API
Modern browsers now support the Popover API, which provides a light‑dismiss, non‑modal overlay mechanism. The :popover-open pseudo-class matches an element when it is shown as a popover, enabling state‑driven styling.
/* Style the popover panel when visible */
.my-popover:popover-open {
background: white;
border: 1px solid #ccc;
border-radius: 8px;
box-shadow: 0 10px 25px rgba(0,0,0,0.15);
padding: 1rem;
/* Position is handled via anchor positioning or inset */
inset: auto;
}
Combined with anchor positioning, :popover-open allows you to build tooltips, menus, and picklists whose appearance is fully controlled by CSS.
:state() – Custom State for Web Components
The CSS Custom State API introduces the :state() pseudo-class, allowing custom elements to expose internal states that can be targeted from outside stylesheets. This is a game‑changer for component encapsulation: component authors define states like --selected, --active, or --loading using the element’s internals, and consumers style them naturally with :state(--selected).
First, define a custom element that toggles a state via ElementInternals:
class ToggleButton extends HTMLElement {
static get observedAttributes() { return ['pressed']; }
constructor() {
super();
this.internals = this.attachInternals();
this._pressed = false;
}
connectedCallback() {
this.addEventListener('click', () => {
this._pressed = !this._pressed;
this.setAttribute('pressed', this._pressed);
});
this._syncState();
}
attributeChangedCallback(name, oldVal, newVal) {
if (name === 'pressed') {
this._pressed = newVal !== null;
this._syncState();
}
}
_syncState() {
if (this._pressed) {
this.internals.states.add('--pressed');
} else {
this.internals.states.delete('--pressed');
}
}
}
customElements.define('toggle-btn', ToggleButton);
Now, any stylesheet can react to the --pressed state without knowing the internal structure of the component:
toggle-btn:state(--pressed) {
background: #007bff;
color: white;
box-shadow: inset 0 0 0 2px #0056b3;
}
This pattern keeps styling declarative, even for highly complex components, and avoids leaking implementation details through CSS custom properties or manual class management.
Container Style Queries – Querying Computed State of Containers
Container queries are famous for size-based conditions, but they also support style queries. A style query checks the computed value of a CSS custom property (or any inheritable property) on a container element and applies styles to its descendants accordingly. This effectively queries a “state” expressed as a style value.
Example: a container that sets a --theme custom property, and its children adapt via a style query:
/* Define the container */
.card-container {
container-type: inline-size;
/* Also declare it as a style container */
container-name: card-container;
}
/* Inline a theme custom property */
.card-container[data-theme="dark"] {
--theme: dark;
}
.card-container[data-theme="light"] {
--theme: light;
}
/* Style query on the container */
@container card-container style(--theme: dark) {
.card {
background: #1e1e1e;
color: #f0f0f0;
border-color: #444;
}
}
@container card-container style(--theme: light) {
.card {
background: white;
color: #333;
border-color: #ddd;
}
}
Style queries work with any custom property, enabling context‑aware styling that feels like a native CSS “if” statement. You can query booleans (e.g., --is-compact: true), numbers, or string values, and combine them with size queries for extremely fine‑grained control.
How to Use CSS State Queries in Your Applications
Integrating state queries into your workflow follows a clear path:
- Identify the state you need to react to — Is it a child’s focus, a custom element’s internal mode, a container’s theme, or a popover’s visibility?
- Choose the right query mechanism — Use
:has()for parent‑child relationships,:state()for custom elements, style queries for context‑dependent theming, and:modal/:popover-openfor overlay states. - Implement incrementally — Start by replacing explicit class toggles with the appropriate pseudo-class. For Web Components, add
statesmanagement in the constructor and connected callback. - Provide fallbacks where necessary — While modern browsers support most of these features, older ones may not. Use
@supportsto guard new selectors, and consider polyfills for critical functionality. - Combine queries for richer interactions — For example, use
:has(:focus-visible)to highlight a card only when keyboard‑focused, or nest a style query inside a size container query for responsive theming.
Example of combining multiple state queries:
.dashboard-panel:has(.filter-widget:popover-open) {
z-index: 10;
background: #f9f9f9;
}
@container dashboard style(--mode: editing) {
.dashboard-panel:has(input:focus) {
border: 2px dashed #007bff;
}
}
Best Practices
- Leverage
:has()for parent styling but keep it readable — Avoid overly deep or convoluted chains. If a selector becomes hard to understand, break it into smaller logical parts or add explanatory comments. - Prefer
:focus-visibleover raw:focusfor interactive controls — It respects user intent and reduces visual noise for mouse users. - Use
:state()to keep Web Component styling declarative — Expose semantic states like--loading,--expanded, or--errorrather than forcing consumers to inspect attributes or internal DOM. - Adopt style queries for theming and context — They remove the need for complex descendant selectors and allow components to adapt purely based on their container’s environment.
- Test across browsers with progressive enhancement — Use
@supports (selector(:has(*)))to provide alternative styles where needed, and always verify that state changes are still perceivable even if the query is unsupported. - Keep state definitions minimal and orthogonal — Each custom state should represent a single, clear condition. Avoid bloated states that combine multiple meanings.
Conclusion
CSS State Queries represent a fundamental shift in how we think about styling. By moving state‑driven logic from JavaScript into the stylesheet, we create applications that are more performant, easier to maintain, and naturally resilient to change. From the parent‑aware power of :has() and the accessibility‑focused :focus-visible, to the encapsulated :state() and the context‑savvy container style queries, these tools empower you to write CSS that feels like a first‑class language for dynamic UI. Start using them today, and watch your stylesheets become smarter, cleaner, and more aligned with the component‑driven architecture of modern web applications.