This page was opened from file://. Chromium blocks the local CSS and JS for this site in that mode. Preview it over HTTP with pnpm dev or pnpm preview.

Skip to main content
dout.dev Frontend notes, design systems, and the sharp edges of shipping.

Article

/ Archive /

Classless CSS: Attributes as a Component API

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.

Classless CSS: Attributes as a Component API

Article content

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;
  • dataset for 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

Discussion

Comments live in GitHub Discussions

Each thread is keyed to the source markdown entry for this post.