Skip to main content
ASoc
Tutorial

ARIA in HTML: 13 Roles, and Why Not One More

This codebase's first rule of ARIA in practice: 13 role attributes across 9 files, each filling a gap HTML has no native element for — plus the live-region defect every form here used to ship.

The ASoc Team9 min read

ARIA supplements HTML; it does not replace it. The W3C's own first rule of ARIA is to use a native HTML element or attribute over a role whenever one exists that already has the semantics you need. This codebase follows that rule closely enough that it has exactly 13 role attributes, across 9 files, in its entire component tree — and every single one exists because HTML has no native element for the job it's doing.

The short answer

role="tablist", role="dialog", role="region" and similar values tell assistive technology what a chunk of custom-built UI is, when HTML itself has no <tab> or equivalent element to say so natively. A button, a link, a heading, a list — those already have roles built in, and adding role="button" to a real <button> is redundant at best. The rule that decides when role earns its place: reach for it only when the interaction pattern has no native HTML equivalent, and prefer a semantic element or a plain aria-* state attribute (aria-expanded, aria-live) everywhere one will do the job alone.

Every role in this codebase, and why it's there

$ grep -rn 'role=' src/components src/app | grep -v '^\S*: *\*' | wc -l
13

Filtering out the two hits that are only comments describing the pattern (not live attributes), the real count is 13, across 9 files:

Filerole value(s)What HTML can't express natively
DashboardTabs.tsxtablist, tab (rendered 3×, one per dashboard section), tabpanelHTML has no tab-panel element; the tab pattern is assembled from <button>s and <div>s that need roles to announce their relationship
PreviewModal.tsx, SavedTemplates.tsxdialogNeither uses the native <dialog> element, so the modal semantics have to be declared explicitly
TemplateGallery.tsxregion, groupA carousel has no native element; region marks the labeled landmark, group scopes the slide controls
YourProductsGrid.tsx, TemplatesExplorer.tsxgroupGroups a set of related controls (filters, cards) that aren't a <fieldset>
FaqItem.tsxregion (conditional: role={open ? "region" : undefined})An open accordion panel is announced as a landmark only while it's actually open — applying it unconditionally would clutter the landmark list with 7 closed panels for every 1 open one, on the 8-row /pricing FAQ
Button.tsxlinkCovered in detail below — an anchor styled to look disabled, where removing href would cost more than it fixes
app/icon.svgimgAn inline SVG has no implicit role the way an <img> element does

Nine files, 13 attributes, and not one of them is decorative. Compare that against the picture the rest of this codebase's ARIA usage gives: the html-data-attribute post counted 114 aria-* attributes across 36 files doing the broader job of marking element state for styling and assistive tech alike — role is a small, deliberate subset of that, reserved for the cases plain aria-* state attributes can't cover because there's no element underneath for the state to attach to.

// src/components/atoms/Button.tsx
<span role="link" aria-disabled="true" tabIndex={0} className={classes}>

This fires when a Button is rendered as disabled. The obvious options both have a real cost. Render a plain <a> with no href and it's no longer a link at all — no default keyboard behavior, no announced role, just a styled <span> in disguise that happens to look like one. Remove the element entirely and the layout shifts. What's here instead: a <span> explicitly told role="link" so assistive technology still announces it as one, aria-disabled="true" so a screen reader says "disabled" rather than silently skipping it, and tabIndex={0} so it's still reachable by keyboard — visible, identifiable, and honestly labeled as non-functional, rather than either fully interactive or invisible to assistive tech.

This is the textbook case for role existing at all: HTML's <a> has no disabled state to begin with (unlike <button disabled>, which is native and needs no ARIA), so there's no attribute on the real element that expresses "this is a link, but you can't use it right now." Reaching for role here isn't working around HTML — it's filling a genuine gap in it.

The defect this codebase actually shipped and fixed: a live region that announced nothing

role isn't the only ARIA surface with sharp edges. FormStatus.tsx — the shared result message for every useActionState form in this app — carries a defect-and-fix story in its own comment:

// src/components/atoms/FormStatus.tsx
/**
 * The monitored element is rendered ALWAYS, including when there is
 * nothing to say. That is the whole point of this file. A live region
 * has to be in the accessibility tree *before* its contents change,
 * because what gets announced is a mutation inside a region already
 * under observation. Mounting the `aria-live` node together with its
 * first message — which is what `{state && <p aria-live="polite">…</p>}`
 * does, and what all five forms here used to do — inserts a whole new
 * subtree instead, and most screen readers announce nothing at all.
 */
export default function FormStatus({ state, className, ... }) {
  return (
    <>
      <p aria-live="polite" className="sr-only">
        {state?.message ?? ""}
      </p>
      {state && (
        <p aria-hidden="true" className={/* visible styling */}>
          {state.message}
        </p>
      )}
    </>
  );
}

All five of this app's forms used to conditionally render their aria-live region — {state && <p aria-live="polite">{state.message}</p>} — which reads as correct and isn't. A screen reader's live-region announcement fires on a mutation inside a node the browser already knows to watch. Mount the node and its first message in the same render, and there's no "before" state for that mutation to be relative to — most screen readers announce nothing. The fix splits the job into two permanent nodes: an sr-only one that's always present and only its text content changes, and a visible, aria-hidden one so sighted users don't hear the message read twice. That's a genuine ARIA defect this codebase shipped, in a component every form in the app depends on, not a hypothetical one written for this article.

The comment also explains why the live region stays aria-live="polite" rather than switching to role="alert" (which implies assertive, interrupting behavior) on error: the failure text here is a validation result the user just asked for by submitting, not an unsolicited interruption, and swapping the role per state would recreate the exact remount problem the fix above was built to avoid.

Mistakes and how they show up

SymptomCauseFix
A screen reader never announces a status message that visibly appearsThe aria-live node is conditionally rendered along with its first messageAlways render the live-region node; change only its text content
A custom tab/accordion/modal is silent to assistive tech despite working visuallyNo role at all on a pattern HTML has no native element forAdd the ARIA Authoring Practices role for that pattern (tablist/tab/tabpanel, dialog, etc.)
role="button" on a real <button> (or similar redundant pairing)Reaching for ARIA by habit instead of checking if the native element already has the roleRemove it — a native element's implicit role needs no ARIA restatement
An error message interrupts a sighted user's flow unnecessarilyrole="alert" used for routine, expected feedbackUse aria-live="polite" instead; reserve role="alert"/assertive live regions for true interruptions
A disabled-looking link is either fully clickable or invisible to assistive techNo middle state expressed — either a real href or no element at allUse role="link" + aria-disabled="true" + tabIndex={0} on a non-anchor element, as this codebase's Button does
Two announcements fire for one messageA visible live text node isn't marked aria-hidden, so it's both visually shown and read aloud, alongside the actual live regionMark the visible copy aria-hidden="true" and let the hidden node carry the announcement

Frequently asked questions

What is ARIA in HTML? A set of attributes — role, and the aria-* family like aria-expanded or aria-live — that can be added to any HTML element to tell assistive technology things the element's own semantics don't already convey. It's an addition to HTML, applied through ordinary attributes, not a separate language or replacement markup.

When should I use role instead of a semantic HTML element? Only when no semantic element already expresses what you need — a real <nav>, <button>, or <dialog> needs no role restating its own implicit one. role earns its place on custom-built patterns HTML has no element for, like a tab interface or a non-native modal, which is exactly the 14 cases this codebase uses it for.

Does adding aria-label mean I don't need visible text? No — aria-label overrides what's announced, but it does nothing for a sighted user, a browser's find-in-page, or SEO text extraction. It's the right call for an icon-only button with no visible label at all; it's the wrong call as a substitute for writing real visible copy.

If I use semantic HTML5 elements everywhere, do I still need ARIA? Usually less of it, but rarely none. Semantic elements (<nav>, <main>, <button>) cover a wide slice of common UI for free. What they can't cover is custom interaction state — an accordion's open/closed status, a live region announcing a form result — which is where aria-* attributes (not necessarily role) still earn their place, as FaqItem's aria-expanded/aria-controls and FormStatus's aria-live both do in this codebase. This blog's own accessibility-scanner audit covers the other half of the picture — the defects that a perfect Lighthouse score doesn't catch, which is a different failure mode than the ones a role census like this one surfaces. And for a case study in the ARIA-vs-native question landing squarely on the native side, the one real <textarea> in this codebase carries zero ARIA attributes of any kind — a native multi-line text control needs none, which is the plainest possible illustration of this post's first rule.

Templates in this post

ASoc Brief is a product-designer résumé and portfolio site with a single-page work/resume/contact layout, ASoc Byte markets an IT and startup engineering studio with AI services and social proof, and ASoc Canvas is a no-code landing-page-builder site with a drag-and-drop editor — all built on the same semantic-first, ARIA-where-needed markup audited above.

Browse the full sets: Next.js landing page templates, Tailwind landing page templates.

Keep reading

Tutorial9 min read

Auth in React: The Session Belongs in a Cookie, Not in Context

Six Server Actions, a 68 KiB auth SDK kept out of every initial bundle, and one line that decides the whole security posture: getClaims(), never getSession().

Read more
Tutorial9 min read

Best SaaS Landing Pages: What 17 Real Templates Actually Ship

Not a gallery — a section-by-section census of 17 SaaS landing templates. Pricing, testimonials and integrations each appear in 10 of 17; the median product is 11 pages.

Read more
Tutorial9 min read

Collapsible HTML: Why This Codebase Skips the Details Element

Zero details elements across 91 components, and the CSS grid-template-rows trick FaqItem uses instead — plus when the native element is the right call.

Read more