Class Names and Data Attributes
What goes on an element, and why: native HTML attributes and ARIA for what users perceive, classes for styling, and data-* attributes (data-slot, a state attribute such as data-state or data-variant, or data-testid) for everything else.
Short version
- State users should perceive is a native attribute or ARIA. Use
disabled,checked,open,aria-expanded,aria-current,aria-invalidand so on, and style from that attribute (disabled:,aria-expanded:). Adata-*attribute never stands in for it. - A class styles something. Tailwind utilities, a class a stylesheet defines, or a third-party library's class. Nothing else.
- A
data-*attribute marks what has no meaning for assistive technology. It names a component's part (data-slot), carries a visual state or variant (data-state,data-variant,data-error), or gives a test something to find (data-testid). - A test or a script finds an element by its role and name first, by a
data-*attribute when it has none, and never by a class.
Why
Tailwind and Bootstrap ignore a class they do not know, so a class that styles nothing renders as nothing. No build, type check or browser warns about it. Such a class is either a typo or a hook, and nothing on the page tells you which one:
- Hooks hidden in class names break silently. An unstyled class looks dead, so a styling change removes it. If the waldur-integration-testing suite selected it, that suite fails while nothing in HomePort does, because the reader lives in another repository.
- A class can be styled from elsewhere. A parent's arbitrary variant (
[&_.x]:text-warning) styles a class that no stylesheet names. The class looks unused in its own file, and removing it silently drops the styling. - Lint enforces it.
waldur-custom/no-unknown-classfails on any class that styles nothing (see "Unknown class guard" in code-quality.md). A marker class has to be either an attribute or an explicit, commented exception.
An attribute says what it is for in its own name. A styling refactor leaves it alone. It can carry a value (data-variant="success"), Tailwind styles it directly (data-[state=open]:), and Radix and waldur-ui already render it.
Decision table
| You need to… | Use | Example |
|---|---|---|
| Style an element | Tailwind utilities in className |
className="flex items-center gap-2 text-secondary" |
| Expose state users should perceive | a native attribute, or ARIA when there is none | disabled, checked, aria-expanded={open}, aria-current="page", aria-invalid |
| Style by that state | Tailwind's native-state and aria-* variants |
disabled:opacity-50, aria-expanded:bg-tertiary, aria-[current=page]:text-brand |
| Use styles a stylesheet already defines | that class | className="card-bordered", className="team-chat", className="form-table" |
| Let a caller style a component | a className prop, merged with cn() |
<Tag className="me-1"> |
| Name a part of a component, for its parents or tests | data-slot |
data-slot="modal-body", data-slot="menu-item-icon", data-slot="filter-field" |
| Expose a state or variant with no accessibility meaning | data-state, data-variant, data-size, a boolean flag |
data-state="open", data-variant={variant}, data-error={invalid ? '' : undefined} |
| Style by that state | Tailwind data-[…]: variants |
data-[state=open]:animate-…, group-data-[state=open]:rotate-180 |
| Style a child by its part or state, from the parent | [&_[data-…]]: on the parent |
[&_[data-error]]:text-warning, [&_[data-slot=filter-field]]:pt-0 |
Find an element from app code (querySelector, :has) |
data-slot (or an id) |
[data-slot="table-filters-menu"] #filter-item-… |
| Find an element in a test | its role and accessible name | getByRole('button', { name: 'Set filters' }), get_by_role("dialog", name=…) |
| … when it has no role or name that identifies it | data-testid |
data-testid="page-toolbar", data-testid="team-chat-conversation" |
| Find a third-party widget's parts (react-select) | the library's own classes, allow-listed | .metronic-select__menu (UNKNOWN_CLASS_HOOKS in eslint.config.js) |
Native state and ARIA first
Before reaching for any attribute, ask whether a screen-reader user should know the fact it expresses. If so, it belongs in HTML or ARIA. In order of preference:
- Semantic HTML and native attributes:
<button>,<nav>,<dialog>,disabled,checked,open,required,hidden. The browser, assistive technology and CSS all understand them as they are. - ARIA, where HTML has no equivalent:
role,aria-label,aria-expanded,aria-selected,aria-current,aria-pressed,aria-invalid,aria-describedby. data-*, for what has no meaning for assistive technology: a component's parts, visual variants, hooks for app code, test ids.
Style from the attribute that carries the meaning, so what users see and what assistive technology announces cannot drift apart. Tailwind has a variant for each: disabled:, checked:, open:, required:, invalid: (native validity), aria-expanded:, aria-selected:, aria-disabled:, aria-invalid:, aria-[current=page]:, and group-aria-expanded: / peer-disabled: for neighbours. A named aria-*: variant matches only the value "true" (aria-invalid: is [aria-invalid="true"]), so render true, not "yes"; for other values use the bracket form, as in aria-[current=page]:.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
Rules:
- Never add ARIA just to get a hook. ARIA changes what assistive technology announces. A role or state that is not true misleads users, and no ARIA is better than wrong ARIA. For a styling or test hook, use
data-*. - Do not mirror ARIA in
data-*. Style and test againstaria-expanded, not adata-expandedyou keep in sync with it. Radix is the one exception: it setsdata-state="open"alongsidearia-expanded, anddata-[state=…]:is fine for its components, especially for states ARIA cannot express, such as a closing animation. - Tests check the ARIA attribute, not its
data-*twin:expect(trigger).toHaveAttribute('aria-expanded', 'true'). - A visual marker still needs an accessible counterpart.
FormSteps'data-erroronly colours a step's title;aria-invalidwould be wrong on a navigation link. The error must also reach assistive technology some other way, such as an icon with an accessible name, or text linked witharia-describedby.
Class names
Use a class only when something styles it:
- Tailwind utilities. Use them for most styling. See design-tokens.md for colours and font weights.
- Stylesheet classes. A class a
.scssor.cssfile defines, such as.team-chat,.form-tableor.card-bordered. One also counts when it only applies inside a parent (.card .small), or when a parent's arbitrary variant styles it ([&_.x]:). - Third-party classes. Classes defined by a library stylesheet the app imports, such as Leaflet's
leaflet-*(import 'leaflet/dist/leaflet.css'). - A
classNameprop. It lets the caller add its own utilities. Merge it withcn(), last, so it can override.
Do not:
- invent a class as a marker (
is-active,has-error,my-thing-wrapper). Use an attribute. - keep a class "in case something uses it". Find out (Before you remove or rename), then keep it as an attribute or delete it.
- select by a class in tests or app code. A class can be renamed in a styling change that never looks at tests.
1 2 3 4 5 6 | |
data-slot: a component's parts
data-slot names a part of a component, so that parents, stylesheets, app code and tests can reach that part without knowing its markup. waldur-ui components set it on their parts: the modal parts (modal-dialog, modal-body, modal-footer; see modals.md), menu-item-icon / -label / -trailing, featured-icon, tag, toast and date-picker-input.
Guidelines:
- The component sets it, not its callers. It is part of the component's API: renaming it is a breaking change for anything that selects it.
- Name it in kebab-case after the component. For example
menu-item-label, notlabel, so slots from nested components never collide. - Use one slot per element. For a variant or a state, add a separate attribute (
data-size,data-variant) rather than a second slot. - Hand-written copies carry the slot too.
InvoicePayButtonandDropdownLinkwrite outMenuItem's icon and label spans, becauseMenu.Itemrenders no slots underasChild. They set the samedata-slots.
Styling and finding a slot:
1 2 | |
1 2 3 4 5 6 | |
1 2 3 4 | |
State and variant attributes
For state that assistive technology should know, use the native attribute or ARIA (above). Any other state or variant a component exposes is a data-* attribute, not a class it toggles. Radix sets several on its own (data-state="open", data-side, data-disabled, data-swipe), and waldur-ui follows the same pattern (data-variant, data-tone, data-size).
- Values are lowercase words:
data-state="open",data-variant="success". - A boolean flag is present or absent. Render
''for true andundefinedfor false (data-error={invalid ? '' : undefined}), never"false":[data-error]matches any value, including"false". - Style it with Tailwind's data variants. On the element itself:
data-[state=open]:…,data-[selected]:…. From an ancestor markedgroup:group-data-[state=open]:…. From the parent onto a child:[&_[data-error]]:….
1 2 3 4 5 6 7 8 9 10 | |
1 2 3 4 5 6 | |
data-testid: for tests only
First find an element the way a user would: by its role and accessible name (getByRole, getByLabelText; see testing.md). An icon-only button already has a name, because BaseButton uses its tooltip as the aria-label. Add a data-testid only when the element has no role or name that identifies it, such as a layout region, a container, or one of several similar elements.
- Name what it is, not how it looks:
page-toolbar,app-footer,provider-offering,team-chat-conversation,badge-right-icon. - Never style by a test id. If a stylesheet or app code needs the element, give it a
data-slot. - Hooks the E2E suite uses are a contract with another repository. List each one in e2e-locators.md and leave a comment where it is rendered:
1 2 3 4 5 6 | |
- Unit tests may use slots and state attributes for structure (
[data-slot="modal-body"],toHaveAttribute('data-error')). That is howToast.test.tsxchecks the E2E hook, and howFormSteps.test.tsxchecks the error marker.
Before you remove or rename
A class or attribute can have readers that no unit test covers. Before removing or renaming one, look for each kind:
- Stylesheets:
.namein.scss/.css, including nested BEM (&--modifier,&__part). - Arbitrary variants:
[&_.name],has-[.name],group-[.name]and[&_[data-slot=name]]in class strings. - App code:
querySelector,closest,matches,classList,:has(…). - Tests and stories:
toHaveClass,querySelector,getByTestId, play functions;e2e/ande2e-visual/. - The integration suite: waldur-integration-testing's
tests/**/*.py, especiallytests/components/homeport_hooks.py. - Git history:
git log -G'\.name\b' -- '*.scss' '*.css'shows whether the name was ever styled.
A hook another repository reads moves in three steps, because each repository's CI runs against the other's current main/develop:
- HomePort renders the new hook beside the old one.
- The other repository selects the new hook, keeping the old one as a fallback (
:is([data-testid="x"], .x)). - HomePort drops the old hook. The other repository drops its fallback once the HomePort image it runs against includes step 3.
Exceptions
Some classes have to stay as hooks even though they style nothing, for example react-select's metronic-select__*, which the library generates and the E2E suite reads. List such a class as a pattern in UNKNOWN_CLASS_HOOKS in eslint.config.js, with a comment saying who reads it. Keep that list short: every entry is a contract with no type check behind it.
Checklist
- Every class I added styles something: a Tailwind utility, a stylesheet class, or a third-party class.
- State users should perceive is a native attribute or ARIA, and is styled from that attribute (
disabled:,aria-*:). - I added no ARIA that is not true, and no
data-*attribute that mirrors one. - Every marker I added is a
data-*attribute:data-slotfor parts,data-state/data-variant/a boolean flag for state,data-testidonly for tests. - Tests find elements by role and name first.
- Nothing selects or styles by
data-testidexcept tests. - Any hook the integration suite reads is listed in e2e-locators.md and commented where it is rendered.
- Before removing a class or attribute, I checked the readers listed above, the integration suite included.