When I talk about classless CSS, I am not talking about a stylesheet that blindly targets every HTML
element, and I am not proposing a religious ban on the class attribute.
I am talking about a component architecture in which semantic HTML identifies what an element is, while attributes describe its configuration and state.
That distinction looks small in the markup. Architecturally, it changes everything.
Consider a button built from a collection of classes:
<button class="primary medium circle icon">
{{icon}}
</button>
Now compare it with the same button described through attributes:
<button
data-variant="primary"
data-size="medium"
data-shape="circle"
data-content="icon"
aria-label="Add item"
>
{{icon}}
</button>
Both examples give CSS enough information to render a button. Only the second one exposes a readable component API.
The difference is not syntax. It is data modelling.
A Class List Is an Unordered Bag of Tokens
The class attribute is represented in JavaScript by a live DOMTokenList, and CSS
treats a class selector such as .primary as the equivalent of
[class~="primary"]. In other words, a class is membership in a whitespace-separated token
set, not a named component property. This is defined explicitly by the
Selectors Level 4 specification
and exposed to JavaScript through
Element.classList.
That means these two elements express exactly the same state to CSS:
<button class="primary medium circle icon">...</button>
<button class="icon circle medium primary">...</button>
The position of a class in the attribute does not establish priority. If two matching rules assign
different values to the same property, the cascade resolves the conflict through origin, importance, layer
order, specificity, scope proximity, and finally source order. The order of the tokens in
class is irrelevant. The
CSS Cascade specification
defines that sorting process.
This becomes fragile as a component grows:
.primary {
--button-background: oklch(55% 0.2 255);
}
.danger {
--button-background: oklch(55% 0.22 25);
}
<button class="primary danger">Delete</button>
Is this button primary or dangerous?
HTML accepts the combination. CSS accepts the combination. classList accepts the combination.
Nothing in the model says that primary and danger belong to the same axis and
should therefore be mutually exclusive.
The result depends on which CSS declaration wins, not on which state the component intentionally represents.
The problem gets worse when the same flat token list mixes unrelated concepts:
<button class="primary medium circle icon loading elevated compact">
{{icon}}
</button>
Some tokens describe appearance, some describe dimensions, some describe content, and one describes transient state. Their names do not tell a browser, a developer, or a tool how those values relate to one another.
Attributes Turn Tokens Into Axes
An attribute gives every dimension a name:
<button
data-variant="primary"
data-size="medium"
data-shape="circle"
data-content="icon"
data-loading
aria-label="Save"
>
{{icon}}
</button>
Now the component exposes five separate axes:
| Attribute | Responsibility | Example cardinality |
|---|---|---|
data-variant |
Visual emphasis or intent | One value |
data-size |
Component size | One value |
data-shape |
Geometric treatment | One value |
data-content |
Content layout | One value |
data-loading |
Transient state | Present or absent |
data-variant="primary" cannot accidentally coexist with data-variant="danger".
Setting one replaces the other because an element cannot carry the same attribute twice in conforming
HTML.
The CSS becomes an explicit mapping from state to design tokens:
@layer reset, components, themes, overrides;
@layer components {
:where(button) {
--button-background: oklch(96% 0.01 255);
--button-color: oklch(24% 0.03 255);
--button-block-size: 2.5rem;
--button-inline-size: auto;
--button-radius: 0.5rem;
--button-padding-inline: 1rem;
display: inline-grid;
place-items: center;
min-inline-size: var(--button-inline-size);
block-size: var(--button-block-size);
padding-inline: var(--button-padding-inline);
border: 0;
border-radius: var(--button-radius);
background: var(--button-background);
color: var(--button-color);
}
:where(button[data-variant="primary"]) {
--button-background: oklch(55% 0.2 255);
--button-color: white;
}
:where(button[data-variant="danger"]) {
--button-background: oklch(55% 0.22 25);
--button-color: white;
}
:where(button[data-size="medium"]) {
--button-block-size: 2.75rem;
--button-padding-inline: 1.125rem;
}
:where(button[data-shape="circle"]) {
--button-inline-size: var(--button-block-size);
--button-radius: 50%;
--button-padding-inline: 0;
}
:where(button[data-content="icon"]) {
grid-template-columns: 1fr;
}
:where(button[data-loading]) {
cursor: progress;
opacity: 0.72;
}
}
This CSS does not ask a class name to play three roles at once. The element selector establishes the component baseline. Each attribute selector configures one axis. Custom properties provide the values that consumers can replace.
The Selectors specification gives attribute selectors more matching modes than simple presence. CSS can
match an exact value with [data-size="medium"], a token in a whitespace-separated value with
[data-modifier~="circle"], a prefix, a suffix, or a substring. It can also combine
independent conditions. The complete matching model is documented in
Selectors Level 4.
For example, a rule can address one precise intersection without inventing a new concatenated class name:
@layer components {
:where(
button[data-variant="primary"][data-shape="circle"][data-content="icon"]
) {
--button-background: oklch(50% 0.22 255);
}
}
This is structural granularity. CSS can select a single axis or an exact combination of axes because the markup preserves that information.
Attribute Selectors Are Not More Specific
There is an important correction to make here: attribute selectors are not inherently easier to override because they have lower specificity.
They do not.
A class selector and an attribute selector both add 0-1-0 to specificity. The CSSWG groups
class selectors, attribute selectors, and pseudo-classes in the same specificity column. Both the
Selectors specification
and
MDN's specificity guide
make this explicit.
These selectors therefore have the same weight:
.primary {} /* 0-1-0 */
[data-variant="primary"] {} /* 0-1-0 */
Adding an element selector actually makes the attribute version slightly harder to override:
button[data-variant="primary"] {} /* 0-1-1 */
The override advantage comes from architecture, not from the selector type itself.
In the previous example, :where() deliberately reduces the component selectors to zero
specificity. Cascade layers then define where defaults, themes, and product-level overrides belong. For
normal declarations in different layers, later layers take precedence regardless of selector specificity,
as defined by the
CSS Cascade specification.
That makes a consumer override predictable:
@layer overrides {
:where(button[data-variant="primary"]) {
--button-background: oklch(48% 0.18 290);
}
}
Attributes provide the address. :where(), custom properties, and cascade layers provide the
override strategy.
These are different responsibilities, and keeping them separate is what makes the system scale.
Do Not Rebuild class Inside data-modifier
This version is valid:
<button
data-variant="primary"
data-size="medium"
data-modifier="circle icon"
>
{{icon}}
</button>
It can be selected using the whitespace-token matcher:
[data-modifier~="circle"] {}
[data-modifier~="icon"] {}
But data-modifier="circle icon" is still a bag of tokens. It has simply moved that bag from
class to another attribute.
If circle describes shape and icon describes content, separate them:
<button
data-variant="primary"
data-size="medium"
data-shape="circle"
data-content="icon"
aria-label="Add item"
>
{{icon}}
</button>
One attribute per conceptual axis produces a schema. A generic modifier list produces a convention.
Conventions live in documentation and memory. Schemas live in the DOM.
The DOM Becomes the Shared CSS and JavaScript Contract
HTML defines data-* attributes specifically for custom data, state, and annotations for which
no more appropriate element or attribute exists. It also exposes them through
HTMLElement.dataset, a DOMStringMap whose camel-cased properties have read and
write access to the corresponding attributes. This behavior is defined by the
HTML Standard
and documented by
MDN.
The mapping is direct:
data-variant <-> dataset.variant
data-size <-> dataset.size
data-shape <-> dataset.shape
data-content <-> dataset.content
Changing a component no longer requires JavaScript to know which class must be removed before another class can be added:
const button = document.querySelector("button");
button.classList.remove("medium");
button.classList.add("large");
button.classList.remove("primary");
button.classList.add("danger");
Instead, JavaScript updates named properties:
const button = document.querySelector("button");
button.dataset.size = "large";
button.dataset.variant = "danger";
The browser updates the corresponding HTML attributes, attribute selectors are rematched, and CSS renders the new state. No CSS-specific mutation API is required.
There is one detail worth remembering: values written through dataset are always converted to
strings. Assigning false does not remove an attribute. It creates a value of
"false", as documented by
MDN's dataset reference.
For a presence-based boolean state, use presence deliberately:
button.toggleAttribute("data-loading", isLoading);
:where(button[data-loading]) {
cursor: progress;
}
This creates a small, declarative state machine in the DOM. JavaScript owns state transitions. CSS owns presentation. HTML is the contract they both understand.
Typed attr() Makes That Contract More Powerful
Historically, CSS could reliably use attr() mainly to produce text through
content. The modern syntax defined in
CSS Values and Units Level 5
can parse an attribute as a specific CSS type and use it in any property:
<button
data-variant="primary"
data-inline-padding="1.25"
data-accent="oklch(55% 0.2 255)"
>
Save changes
</button>
@layer components {
:where(button[data-inline-padding]) {
padding-inline: attr(data-inline-padding rem, 1rem);
}
:where(button[data-accent]) {
background: attr(
data-accent type(<color>),
var(--button-background)
);
}
}
JavaScript still sees strings through dataset, but CSS can parse those strings as a
<color>, <number>, <length>,
<percentage>, angle, time, or another supported CSS type. If parsing fails,
attr() can use a declared fallback.
MDN documents the syntax, types, fallbacks, security constraints, and feature detection.
This is a meaningful interoperability improvement. An attribute can be:
- serialized in HTML;
- read and written through
dataset; - observed by component code;
- matched as component state by CSS;
- parsed into a typed CSS value without JavaScript copying it into an inline style.
Advanced attr() shipped in Chromium 133, and cross-browser interoperability for it is part of
Interop 2026. MDN still marks usage on properties other than content as experimental, so feature
detection and a stable fallback remain appropriate:
@supports (padding: attr(data-padding rem, 1rem)) {
:where(button[data-padding]) {
padding-inline: attr(data-padding rem, 1rem);
}
}
Typed attr() should not become an excuse to put every design value into HTML. Enumerated
attributes such as data-size="medium" are usually the better public API because they keep the
design system constrained. Typed values are useful when the value is genuinely data-driven, continuous, or
intended as a controlled escape hatch.
The architecture matters more than the novelty of the syntax.
Use Native Attributes When Native Semantics Exist
data-* does not add behavior, meaning, accessibility, or validation by itself. The HTML
Standard says custom data attributes are intended for cases where no more appropriate attribute or element
exists.
Use the platform state when the platform already has one:
<button type="button" disabled>Save</button>
<button type="button" aria-pressed="true">
Bold
</button>
@layer components {
:where(button:disabled) {
cursor: not-allowed;
opacity: 0.56;
}
:where(button[aria-pressed="true"]) {
--button-background: oklch(45% 0.18 255);
}
}
Do not create data-disabled when disabled is the real state. Do not create
data-pressed while leaving assistive technology unaware of the pressed state. Custom
attributes are for custom dimensions, not replicas of the platform.
Attributes Do Not Validate Themselves
An attribute-based API is clearer, but HTML will still accept this:
<button data-size="enormous" data-variant="banana">
Unexpected button
</button>
Attribute axes make invalid values easier to detect, document, type, lint, and test. They do not automatically reject them.
A robust component should define its allowed values and fall back safely:
const variants = new Set(["neutral", "primary", "danger"]);
const sizes = new Set(["small", "medium", "large"]);
function normalizeButton(button) {
if (!variants.has(button.dataset.variant)) {
button.dataset.variant = "neutral";
}
if (!sizes.has(button.dataset.size)) {
button.dataset.size = "medium";
}
}
This is another architectural benefit. The validation code can address variant and
size as named properties instead of inferring their roles from one shared token list.
The Real Meaning of Classless
Classes are useful. They remain excellent hooks for grouping unrelated elements, identifying application-owned regions, integrating with existing systems, and addressing cases that do not deserve a formal state axis.
The problem is not the existence of classes. The problem is using a flat class list as the entire public API of a component.
A class such as .primary says, "this element belongs to a group called primary."
An attribute such as data-variant="primary" says, "the value of this component's variant
property is primary."
Those statements may produce the same pixels today. Only one of them establishes a contract that CSS, JavaScript, tooling, tests, and future typed attribute values can share.
That is the classless approach I care about:
- semantic elements for identity;
- native attributes for native state;
data-*attributes for custom configuration;- one attribute for each conceptual axis;
- low-specificity selectors with
:where(); - custom properties for configurable values;
- cascade layers for predictable ownership and overrides;
datasetfor direct JavaScript interoperability;- typed
attr()as progressive enhancement for data-driven CSS values.
It is not fewer characters. It is less ambiguity.
And less ambiguity is what makes a component system easier to extend, override, automate, and maintain.
Sources and Further Reading
- Selectors Level 4: Attribute selectors
- Selectors Level 4: Class selectors
- Selectors Level 4: Specificity
- CSS Cascading and Inheritance: Cascade sorting order
- CSS Cascading and Inheritance: Layer order
-
HTML Standard: Custom data attributes and
dataset - MDN: Using data attributes
-
MDN:
HTMLElement.dataset -
CSS Values and Units Level 5:
attr() -
MDN:
attr() -
Chrome for Developers: CSS
attr()gets an upgrade - WebKit: Interop 2026