Skip to main content
ASoc
Tutorial

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.

The ASoc Team8 min read

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 familyOccurrencesFiles
data-* (any)00
aria-* (any)11436

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

MechanismReadable by CSSReadable by assistive techUsed here
data-* attributeYes, via [data-x="y"]No — has no defined meaning to a screen reader0 times
aria-* attributeYes, via [aria-expanded="true"] (rarely needed — see below)Yes — standardized semantics114 times
React state → conditional classNameYes, directly, no attribute neededNo — has to be paired with an aria-* attribute if the state is meaningfulThroughout (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 .datasetdata-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

SymptomCauseFix
element.dataset.foo is undefinedThe attribute was written as dataFoo or data_foo instead of data-fooOnly a literal data- prefix populates .dataset; camelCase converts on read (data-foo-bardataset.fooBar), not on write
A [data-open="true"] CSS selector never matchesThe value was set as a boolean (data-open={true}) rather than a stringHTML 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 itA speculative hook added ahead of an actual test suiteThis 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 togglesdata-* carries no ARIA semantics by itselfPair 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 propA typo landed outside the data-/aria- prefixes React specifically allows through without a warningConfirm 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.

Keep reading

Tutorial9 min read

HTML Nav: The Element, Not the Layout

A real production nav, from a 4-item desktop bar to a slide-out drawer — the aria-expanded toggle, the z-index stacking, and what a div-only navbar loses.

Read more
Tutorial9 min read

React Icons: 12 From a Library, 55 Written by Hand

One icon library, twelve imports, 55 inline SVGs — and the split falls exactly on the client boundary rather than on taste.

Read more