CSS Custom States vs Data Attributes
Map state to styling, serialization, and accessibility audiences; drive every projection from one reducer; and preserve readable, tested fallbacks.
CSS custom states and data attributes can activate similar-looking selectors, but they make different promises to component authors, consumers, and users. This guide maps those audiences, preserves native and ARIA semantics, and drives internal state, explicit fallback, and public serialization from one bounded reducer.
CSS custom states solve an internal styling problem
CSS custom states and data attributes can both activate selectors, but they communicate with different audiences. A state in ElementInternals.states lets a custom element expose a styling hook without serializing implementation state into markup. A data attribute is visible, inspectable, serializable, and often treated as part of the public DOM contract. ARIA and native properties communicate semantics and behavior to users and assistive technology. One spelling should not impersonate all three jobs.
The decision begins with ownership. If the component owns a transient visual condition such as busy or validated, CSS custom states can keep its selector contract explicit without attribute-reflection loops. If a server, test, consumer stylesheet, or persistence layer must read or write the value, a data attribute may be the honest public surface. If the condition changes meaning or interaction, use the relevant native property or ARIA state whether or not another styling hook exists.
The HTML custom-state definition connects CustomStateSet entries to the :state() pseudo-class. This guide turns that mechanism into a lifecycle decision, with feature detection and an explicit attribute fallback.
The result is one reducer, one semantic state, and multiple synchronized projections—not competing sources of truth.
- CSS custom states
- Expose an internal custom-element styling condition.
- Data attributes
- Expose intentional serialized state to markup, tests, servers, or consumers.
- Native and ARIA
- Provide behavior and user semantics.
- Owner
- One reducer projects all required channels.
Reading rule: labels, symbols, patterns, and structure carry the conclusion; color is supplementary.
Map state to three audiences
First list the audiences for each state. The component implementation needs a value to render and enforce transitions. Consumer CSS may need a documented selector. The DOM and accessibility tree may need serialized semantics for tooling, forms, or assistive technology. Mark every audience before choosing a mechanism.
CSS custom states are strongest when a custom element needs an internal, author-defined styling flag. Names are identifiers such as busy, then selected through :state(busy). They are not automatically attributes, persistence, events, form values, or accessibility semantics. CustomStateSet does not make “busy” understandable to a screen reader.
Data attributes are plain serialized strings. They work in static markup, cross shadow boundaries only through selectors allowed by the component contract, and are easy for tests and consumers to inspect. That visibility is valuable when public state is intentional and costly when it exposes an implementation detail that becomes impossible to rename.
Native elements and properties come first. The form-associated custom-elements guide explains how custom controls participate in forms; a CSS flag cannot replace disabled behavior, constraint validation, name/value submission, or labels. Add ARIA only where native semantics do not already express the condition. Styling may mirror semantic state, but the semantic channel remains authoritative for user-facing meaning.
Understand CustomStateSet and :state()
A custom element calls attachInternals(), then adds or deletes identifiers through internals.states. Styles target the host with :state(name), and selectors can combine that state with component context. Keep names small, documented, and behavioral rather than visual: busy communicates a condition; blue communicates an implementation.
The Selectors Level 4 definition of :state() specifies the selector surface. Browser support and details remain refresh-sensitive, so feature-detect ElementInternals, the states property, and selector support independently. A partially available API should not leave a component unreadable.
CSS custom states do not appear when HTML is serialized and do not exist before JavaScript upgrades the element. Server-rendered pages therefore need a readable baseline. Text content, native attributes, or an intentionally reflected fallback can express initial status. Do not hide the only status label behind a selector that activates after upgrade.
Shadow DOM does not remove the need for a public contract. The declarative Shadow DOM SSR article covers delivering shadow content before JavaScript. Here the narrower rule is that :state() may style an upgraded component, while initial HTML must remain meaningful without it. The lab’s status text is always present.
Use data attributes for intentional serialization
Choose a data attribute when the state is genuinely part of the element’s public markup contract: a server emits it, a consumer configures it declaratively, an end-to-end test must inspect it, or serialization and restoration matter. Define allowed values and whether external mutation is supported. data-status="busy" is clearer than five unrelated booleans when the states are mutually exclusive.
Attributes are strings, so parsing and invalid values need policy. An observedAttributes callback that writes the reducer and a reducer that reflects the attribute can form a loop. Assign ownership: external attribute changes dispatch an action; the reducer computes canonical state; one projection function reflects only when the value differs. Never let the attribute and internal field update each other ad hoc.
Presence selectors suit independent booleans; enumerated attributes suit exclusive modes. Remove stale attributes during every transition. A component that reaches success while data-error remains present exposes contradictory styling and test evidence.
The guidance on component APIs that age well applies directly: every observable attribute becomes compatibility surface. CSS custom states can reduce accidental surface area, while data attributes make deliberate integration easy. The comparison is not modern versus old. It is private styling state versus public serialized state, with explicit support costs for each.
- :state(): concise internal styling, but neither serialized nor semantic.
- data-*: styleable and serialized, but carries no accessibility behavior by itself.
- Native/ARIA: communicates behavior and meaning; styling can mirror it.
- Fallback: use one reducer and paired projection rules, not separate behavior.
Reading rule: labels, symbols, patterns, and structure carry the conclusion; color is supplementary.
Keep semantics independent of styling hooks
For idle, busy, success, error, and disabled, ask what the user can perceive and do. Busy may require aria-busy on the region whose updates are pending. Disabled should use a native disabled property where supported, or the correct form-associated custom-element behavior plus keyboard and focus handling. Error may require linked descriptive text. Success may need a status announcement, not merely green paint.
Do not infer semantics from a CSS selector. A CSS custom state named disabled does not prevent clicks, remove focusability, or communicate disabled state. A data-disabled attribute does none of those things either. The reducer should update semantic properties in a dedicated projection whose behavior is tested without reading computed color.
Prefer native elements when they satisfy the interaction. A customized button-like element has to reproduce activation, keyboard, disabled, focus, and form behavior that a button already owns. Custom-element state APIs are useful for components that genuinely need custom-element encapsulation, not permission to rebuild common controls casually.
The CSS scope for components guide explains selector reach and ownership. CSS custom states add a host-level condition within that styling architecture. They do not change the accessibility tree. Keep the state map diagram nearby during review: implementation, styling consumer, and user semantics are related projections with different guarantees.
Drive every projection from one reducer
Model valid transitions as a pure reducer. Idle can begin work; busy can resolve to success or error; any active result can reset; disabled can be entered from allowed states and must preserve or discard prior status according to a published rule. Reject impossible actions rather than producing a half-updated component.
After each accepted action, project the resulting state in one ordered step: update visible text; apply native or ARIA semantics; update internals.states when supported; reflect the explicit fallback or public data attribute; and emit one versioned event if consumers need notification. Cleanup should delete every state token not present in the new state and remove obsolete semantic properties.
Avoid hydration races by reading one initial source. If the server owns data-status, initialize from it once, validate it, then let the reducer own subsequent updates. If state is purely client-derived, do not pretend the attribute is authoritative. Mutation observers are rarely the right synchronization mechanism for a component’s own reflection.
The lab records action, previous state, next state, accepted flag, active custom-state tokens, reflected attributes, and semantics. A deterministic JSON receipt exposes drift. CSS custom states and the fallback must represent the same reducer state where both are supported, but semantic attributes may intentionally differ because their vocabulary and purpose are different.
- Dispatch an action to one pure reducer.
- Reject invalid transitions without partial projection.
- Update visible text and native or ARIA semantics.
- Add the current custom-state token and delete stale tokens.
- Reflect only the explicit public or fallback attribute.
- Export the transition ledger for parity review.
Reading rule: labels, symbols, patterns, and structure carry the conclusion; color is supplementary.
Design a fallback without selector drift
Feature detection should select a projection, not fork component behavior. Keep the reducer, visible text, actions, and semantics identical. When CustomStateSet and :state() are supported, apply the internal token. Otherwise, reflect a namespaced data attribute such as data-state-fallback. Consumer-facing public attributes, if any, remain independent of that implementation fallback.
Ship paired selectors during the compatibility window: :state(busy) and [data-state-fallback="busy"] should resolve to the same declaration block. Where selector-list parsing could discard an unsupported selector, use separate rules or a supported feature query rather than assuming graceful partial parsing. Test the real target browsers.
The MDN CustomStateSet reference is useful for current API examples and compatibility context, but deployment decisions should be refreshed against target-browser tests. Support tables age; the component’s observable fallback contract should not.
Forced colors, reduced motion, no JavaScript, and 200% zoom are part of the fallback review. Color cannot be the only state indication. Motion should enhance, not carry, a transition. Static text must reveal status before upgrade. CSS custom states can improve encapsulation while the visible and semantic baseline keeps the component robust when that enhancement is unavailable.
Choose the smallest honest state contract
The decision rule is compact. Treat custom element data attributes as public API only when that serialization is intentional. Use a native property or ARIA for behavior and user semantics. Use a data attribute when serialized public state is intentionally readable or writable by outside systems. Use CSS custom states when an upgraded custom element needs an internal styling condition. Use more than one only as projections from one owner, never as peers that can drift.
The local lab demonstrates idle, busy, success, error, and disabled through a bounded pure reducer. It feature-detects the internal state set and selector, maintains an explicit fallback, updates visible and semantic output independently, rejects invalid transitions, cleans stale tokens, and exports a transition receipt. A no-script description keeps the example understandable without upgrade.
Its truth boundary is clear: CSS custom states do not replace ARIA, native behavior, public attributes, persistence, form participation, or server-readable state. The lab proves its own reducer and projection parity in the current browser; it does not establish universal support or prescribe custom elements where native HTML works.
Revisit after browser-support changes, selector-spec changes, lifecycle additions, or any expansion of the public attribute API. The best CSS custom states architecture is not the one with the cleverest selector. It is the one where every observer can tell which channel owns which promise—and where one transition cannot leave styling, markup, and meaning telling three different stories.
Runnable local artifact — The lab demonstrates styling projections in one browser; custom states do not replace native behavior, ARIA, public attributes, persistence, forms, or server-readable state.
Dispatch bounded actions through one reducer, atomically refresh visible, semantic, custom-state, and fallback projections, reject invalid transitions, and export the ledger.