This Site Uses Zero HTML data-* Attributes. Here's What Replaced Them
114 aria-* attributes across 36 files carry every state hook this codebase needs. grep for data- in src/components and you get nothing back.
grep -rn 'data-[a-z-]*=' src/components src/app returns zero matches. This entire storefront — accordions, modals, dropdowns, carousels, dark mode — is built without a single HTML data-* attribute. State that a data-state="open" selector would normally carry lives in React's own useState instead, and the 114 aria-* attributes spread across 36 files do the job data attributes are often reached for: marking an element's state for both styling and assistive tech.
The short answer
A data-* attribute is any HTML attribute whose name starts with data-, reserved by the HTML spec for storing custom data on an element without inventing non-standard attributes. <div data-state="open"> puts a value in the DOM that both CSS ([data-state="open"]) and JavaScript (element.dataset.state) can read. It's commonly used by component libraries — Radix UI and shadcn/ui's generated components mark open/closed/checked state this way by convention, which is why one DEV Community piece calls data attributes "one of the original state management libraries."
What this codebase uses instead
Every interactive element that would typically carry a data-state or data-active attribute in a Radix-style codebase instead branches its class string on a plain boolean, read straight from React state:
// src/components/molecules/FaqItem.tsx — the /pricing accordion
export default function FaqItem({ question, answer }: FaqEntry) {
const [open, setOpen] = useState(false);
return (
<button
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-controls={panelId}
className="..."
>
{question}
<span className={`... ${open ? "-scale-y-100" : ""}`}>
<svg>...</svg>
</span>
</button>
);
}
Nothing here writes data-state={open ? "open" : "closed"} onto the DOM. The chevron's rotation comes directly from a template-literal conditional (open ? "-scale-y-100" : "") evaluated at render time — CSS never needs to see the state at all, because React already re-renders the class string the instant open changes. The panel's open/closed announcement to assistive tech runs through aria-expanded={open} on the button and a conditional role="region" / aria-labelledby on the panel, not a custom attribute a screen reader wouldn't recognize anyway.
The measured split: 0 data-, 114 aria-
| Attribute family | Occurrences | Files |
|---|---|---|
data-* (any) | 0 | 0 |
aria-* (any) | 114 | 36 |
That's not an oversight — aria-* is the standardized vocabulary browsers and assistive technology already understand (aria-expanded, aria-controls, aria-labelledby, aria-hidden, aria-current), so anywhere this codebase needs to expose element state to anyone — a screen reader, a focus manager, or its own conditional styling — it reaches for the attribute that already means something, rather than inventing a private one that only its own CSS and JS agree on.
The clearest example of "the same value serving both jobs" is the mobile-menu overlay pattern used across TemplateCard, UseCaseCard, and ProductDownloadGroup — the hover-only preview overlay CLAUDE.md documents is aria-hidden specifically because it's a pointer-only shortcut, and being aria-hidden plus invisible together is what keeps it out of both the accessibility tree and the tab order at once. A data-hover-only="true" attribute would have needed a second, separate CSS rule and told a screen reader nothing.
Where the pattern would actually justify a data attribute
Data attributes earn their place when a value needs to reach CSS and has no accessible meaning of its own — a decorative animation delay, a drag-and-drop payload identifier, a test-automation hook. This repo has zero of those needs today: nothing here is drag-and-drop, no staggered per-item CSS animation delay is computed dynamically, and there's no automated browser-driving test suite that would want a stable data-testid selector (CLAUDE.md's testing story is vitest unit tests over catalog invariants and filter logic — no Playwright-driven component tests to select against). If any of those three needs shows up later, a data-* attribute is the right tool; until then, adding one would just duplicate information the aria-* attribute or the React state already carries.
Comparison: data-* vs. aria-* vs. plain React state
| Mechanism | Readable by CSS | Readable by assistive tech | Used here |
|---|---|---|---|
data-* attribute | Yes, via [data-x="y"] | No — has no defined meaning to a screen reader | 0 times |
aria-* attribute | Yes, via [aria-expanded="true"] (rarely needed — see below) | Yes — standardized semantics | 114 times |
React state → conditional className | Yes, directly, no attribute needed | No — has to be paired with an aria-* attribute if the state is meaningful | Throughout (FaqItem, Header, EditionPicker, …) |
Note the middle column isn't why aria-* is chosen here — this codebase doesn't style off [aria-expanded] selectors either; the conditional class comes straight from the same open variable that sets the attribute. The attribute's job is purely to tell assistive tech what the component already knows, not to drive the visuals.
Reading a data attribute, for when this codebase eventually needs one
Nothing here argues data-* attributes are wrong in general — only that this specific codebase hasn't hit a need for one yet. For the record, the mechanics are simple once a real use case shows up (a drag-handle payload, a CSS animation delay computed per item, a stable end-to-end test selector):
<div id="row-4" data-row-index="4" data-status="pending"></div>
const row = document.getElementById("row-4");
row.dataset.rowIndex; // "4" — data-row-index becomes camelCase rowIndex
row.dataset.status; // "pending"
row.dataset.status = "complete"; // writes data-status="complete" back to the DOM
Every hyphenated segment after data- becomes a camelCase key on .dataset — data-row-index reads as dataset.rowIndex, not dataset["row-index"]. CSS can select the same attribute directly, without touching .dataset at all: [data-status="pending"] { opacity: 0.6; }. Tailwind supports the identical selector as an arbitrary variant — data-[status=pending]:opacity-60 — which is the shape a component library migrating toward Tailwind-native state styling would reach for instead of a manual [data-status="pending"] rule in a stylesheet.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
element.dataset.foo is undefined | The attribute was written as dataFoo or data_foo instead of data-foo | Only a literal data- prefix populates .dataset; camelCase converts on read (data-foo-bar → dataset.fooBar), not on write |
A [data-open="true"] CSS selector never matches | The value was set as a boolean (data-open={true}) rather than a string | HTML attributes are always strings — React serializes data-open={true} to data-open="true", so match the string, not the JS type |
Adding data-testid to every component "for testing" with no test consuming it | A speculative hook added ahead of an actual test suite | This repo's test suite (vitest) asserts on catalog data and filter logic, not the rendered DOM — don't add DOM-selector attributes a test never reads |
A screen reader announces nothing when a custom data-state toggles | data-* carries no ARIA semantics by itself | Pair it with the matching aria-* attribute (aria-expanded, aria-selected, …), or replace it entirely if the ARIA attribute already covers the need |
| ESLint or a linter flags a data attribute as an unknown DOM prop | A typo landed outside the data-/aria- prefixes React specifically allows through without a warning | Confirm the attribute starts with exactly data- or aria-; anything else needs a real, typed DOM prop |
Frequently asked questions
Does avoiding data-* attributes mean this codebase avoids storing any custom state in the DOM?
No — it stores plenty of state, just in React (useState, component props) rather than serialized onto DOM nodes. The DOM only receives the aria-* attributes needed for assistive tech; visual state is applied directly as class names computed at render time.
Is this an anti-pattern compared to how libraries like Radix UI use data-state?
Not for this codebase's shape. Radix's generated primitives are shipped as a library that can't assume the consumer's styling approach, so data-state gives any CSS (or Tailwind variant) a stable hook regardless of framework. This is a single first-party codebase where the component and its consumer are the same file — the React state that would populate data-state is already in scope to drive the class string directly, so the extra attribute would be redundant.
Would adding data-testid attributes be worth it if E2E tests get added later?
Only once those tests exist. Adding selector hooks ahead of a test suite is exactly the kind of speculative attribute this post argues against — it costs nothing today but adds DOM noise nothing reads, and CLAUDE.md is explicit that this project's tests target data invariants, not rendered markup.
Can data-* and aria-* attributes coexist on the same element?
Yes, and plenty of real codebases do exactly that — a data-* value driving a CSS selector alongside an aria-* attribute carrying the accessible name or state. This repo just hasn't needed the data-* half of that pairing yet.
Templates in this post
ASoc Zenith, ASoc Aegis and ASoc Ally are Next.js + Tailwind landing page templates that follow the same convention audited above — every toggle, accordion and dropdown ships state through React and aria-*, never a custom data-* attribute.
Browse the full sets: Next.js landing page templates, Tailwind landing page templates.
