Subject: [CSS] Proposal: state property and :state() pseudo-class – declarative state-based child views
To: www-style@w3.org
Many common UI patterns involve showing one child element among several possible views depending on a current state. Examples include:
- tab panels
- carousels
- button visual states (normal, hover, active, disabled)
- multi-step forms
- loading/success/error panels
- paginated content sections
- modal dialogs with different modes
Today, implementing these patterns typically requires JavaScript class toggling, DOM restructuring, index-based selectors (:nth-child), or radio/checkbox hacks using :checked.
CSS currently lacks a declarative mechanism to map a named state to a child view within a container. This proposal introduces a minimal primitive that stays within the fundamental CSS model: selectors → styles.
The container exposes a state value via a new state property:
.container {
state: login;
}Or driven by a CSS custom property:
.container {
state: var(--view);
}Children declare which state they represent via a new state-name property:
.login-view { state-name: login; }
.error-view { state-name: error; }
.loading-view { state-name: loading; }A new pseudo-class :state() allows selecting a container based on its current state:
.container > [state-name] { display: none; }
.container:state(login) > .login-view { display: block; }
.container:state(error) > .error-view { display: block; }Since everything is selector-based, CSS transitions work without any special logic.
Simple fade using opacity and visibility:
.login-view,
.error-view {
opacity: 0;
visibility: hidden;
transition: opacity 3s, visibility 3s;
}
.container:state(login) > .login-view {
opacity: 1;
visibility: visible;
}
.container:state(error) > .error-view {
opacity: 1;
visibility: visible;
}Note: display: none is not animatable. Using opacity + visibility instead allows smooth transitions while keeping the element out of interaction when hidden.
Fade from display: none using @starting-style:
CSS now provides @starting-style (CSS Transitions Level 2) to animate elements appearing from display: none:
.login-view {
display: none;
opacity: 1;
transition: opacity 3s;
}
.container:state(login) > .login-view {
display: block;
}
@starting-style {
.container:state(login) > .login-view {
opacity: 0;
}
}This allows a full 3-second fade-in when a child becomes visible, even from display: none, without any JavaScript.
Via :has() — zero JavaScript:
.button {
state: normal;
}
.button:has(:hover) { state: hover; }
.button:has(:active) { state: active; }
.button > [state-name] { display: none; }
.button:state(normal) > .normal { display: block; }
.button:state(hover) > .hover { display: block; }
.button:state(active) > .active { display: block; }Via custom property:
element.style.setProperty("--view", "error");Or directly:
element.state = "error";stateis a regular CSS property set on the containerstate-nameis a CSS property set on direct children:state()matches a container whose currentstateequals the given name- The cascade works normally — no implicit side effects on other elements
- No new event system is introduced
- Existing CSS features (
:has(), custom properties,@starting-style, pseudo-classes) drive state changes and transitions naturally
The :state(<ident>) pseudo-class matches elements whose internal state attribute equals the given identifier.
The state property updates this internal state attribute based on its computed value. The state property applies to the container element.
State switching is intended for direct child views of that container.
- Respects CSS architecture : selectors → styles, no property acting implicitly on other elements
- Declarative : no JavaScript needed for purely visual state switching
- Semantic : state names are meaningful, unlike index-based selectors
- Transitions : work naturally via
opacity+visibilityor@starting-style, no special logic required - Flexible : driven by CSS interactions, custom properties, or minimal JavaScript
- Consistent :
:state()is coherent with:has(),:is(),:where()
- JavaScript class toggling : couples UI logic to JavaScript for a purely visual concern
:nth-child: fragile, non-semantic, does not accept custom properties- Radio/checkbox hacks : misuse of form elements, limited to binary states
:has()+ sibling combinators : unwieldy for more than a few states- Container style queries : verbose, requires a separate rule per state and per child
-
stateproperty naming — doesstateconflict with existing properties or future proposals? Alternatives :ui-state,view-state,active-state. -
state-nameas HTML attribute or CSS property — shouldstate-namebe an HTML attribute (state="login") or strictly a CSS property? Both could coexist. -
Scope of
:state()— should:state()match only on direct children, or on any descendant? -
Children without
state-name— should they always be visible, always hidden, or controlled by an explicit keyword (e.g.,state-name: *)? -
Initial value of
state— should the default be an empty string (no children displayed), a keywordnone, or the first child's state name? -
Accessibility — should hidden children (not matched) be automatically removed from the accessibility tree, as with
display: none? -
Relation to
display: state— an alternative formulation usingdisplay: statewas considered. It offers more concision but introduces a property acting implicitly on children, which departs from the CSS model. Should both approaches be explored in parallel? -
Relation to Custom Element states — Web Components already expose a
:state()pseudo-class. Should this proposal reuse the same mechanism or define a separate one?
.tabs { state: var(--tab); }
.tabs > [state-name] { display: none; }
.tabs:state(home) > .home-panel { display: block; }
.tabs:state(settings) > .settings-panel { display: block; }<div class="tabs" style="--tab: home">
<div class="home-panel" state-name="home">Home content</div>
<div class="settings-panel" state-name="settings">Settings content</div>
</div>button { state: normal; }
button:has(:hover) { state: hover; }
button:has(:active) { state: active; }
button > [state-name] { display: none; }
button:state(normal) > .normal { display: block; }
button:state(hover) > .hover { display: block; }
button:state(active) > .active { display: block; }<button>
<span class="normal" state-name="normal">Click me</span>
<span class="hover" state-name="hover" >Click me (hover)</span>
<span class="active" state-name="active">Click me (active)</span>
</button>.form { state: var(--form-state); }
.form > .idle,
.form > .loading,
.form > .valid,
.form > .error {
opacity: 0;
visibility: hidden;
transition: opacity 3s, visibility 3s;
}
.form:state(idle) > .idle { opacity: 1; visibility: visible; }
.form:state(loading) > .loading { opacity: 1; visibility: visible; }
.form:state(valid) > .valid { opacity: 1; visibility: visible; }
.form:state(error) > .error { opacity: 1; visibility: visible; }element.style.setProperty("--form-state", "error");This proposal introduces two minimal primitives — a state property and a :state() pseudo-class — that enable declarative state-based child views while fully respecting the CSS architecture of selectors → styles.
It covers a very common UI pattern today handled by JavaScript, and integrates naturally with existing CSS features such as :has(), custom properties, @starting-style, and transitions.
I would welcome feedback from the working group and the community on the open questions and the overall direction.
Thank you for your time and consideration.