State attributes

State attributes are stable data-* attributes that reflect component state for CSS and tests. Prefer them over poking into private class names or internal DOM structure.

Overview

resolveStateAttributes from @sometic/styling/state maps a typed style state object to attribute records. Engines (button, input, selection, overlays) call this during resolve so adapters and custom elements expose the same hooks.

Canonical keys:

State fieldAttribute
disableddata-disabled
loadingdata-loading
invaliddata-invalid
readonlydata-readonly
focuseddata-focused
focusVisibledata-focus-visible
filleddata-filled
emptydata-empty
checkeddata-checked
selecteddata-selected
expandeddata-expanded
orientationdata-orientation
sizedata-size
variantdata-variant

Boolean attributes are emitted only when true (default value "true", or "" if you configure empty-string presence). String fields emit when non-empty. checked: "indeterminate" maps to data-checked="indeterminate".

Example: resolve and apply

ts
import { resolveStateAttributes } from "@sometic/styling/state";

const attrs = resolveStateAttributes({
    disabled: true,
    invalid: true,
    size: "md",
    variant: "outline",
});

// {
//   "data-disabled": "true",
//   "data-invalid": "true",
//   "data-size": "md",
//   "data-variant": "outline",
// }

Adapters spread these onto the root (or host) element alongside data-slot="root".

CSS patterns

css
[data-disabled="true"] {
    opacity: 0.6;
    pointer-events: none;
}

[data-invalid="true"] {
    outline: 2px solid var(--color-danger, crimson);
}

[data-loading="true"] [data-slot="loader"] {
    display: inline-block;
}

[data-filled="true"] [data-slot="label"] {
    transform: translateY(-0.75rem) scale(0.85);
}

Combine with slots:

css
[data-slot="root"][data-variant="ghost"] {
    background: transparent;
}

Relationship to ARIA and native attributes

State attributes are styling and test hooks. Accessibility still relies on native and ARIA properties:

ConcernPrefer
Disabled interactionnative disabled / aria-disabled as the engine specifies
Invalid fieldsaria-invalid, linked error text
Busy buttonsaria-busy when loading
Expanded disclosurearia-expanded on the correct control

Do not replace ARIA with data-* alone. Engines typically set both where relevant (for example loading buttons set aria-busy and data-loading).

Size and variant

data-size and data-variant are opaque strings. Sometic does not prescribe a closed enum at the styling layer. Your design system defines meaning (sm / md / lg, solid / outline / ghost). Pass the same strings through component props; resolvers reflect them for CSS.

Testing

Query by role first, then assert state attributes when useful:

ts
expect(button).toHaveAttribute("data-loading", "true");
expect(input).toHaveAttribute("data-invalid", "true");

Avoid selectors that depend on generated utility class order.

When not to invent private attributes

Stick to the published map for shared components. New public state keys should be documented so every adapter stays consistent. Avoid encoding transient animation-only flags as public API unless your app needs them.

FAQ

Why data-* instead of only classes? Classes collide with consumer naming and merge strategies. Attributes stay stable across unstyled and themed modes.

Are false booleans present as data-x="false"? No. Absent means false for boolean keys.

Do custom elements mirror the same attributes? Yes for Wave A elements that share engines. Observed attributes and resolved state stay aligned where documented on the component page.

Is data-expanded a Menu API? No. Expansion is a general state hook used by overlays and disclosure patterns. For menus and tabs, see Menu and Tabs.