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.
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:
| File | role value(s) | What HTML can't express natively |
|---|---|---|
DashboardTabs.tsx | tablist, tab (rendered 3×, one per dashboard section), tabpanel | HTML 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.tsx | dialog | Neither uses the native <dialog> element, so the modal semantics have to be declared explicitly |
TemplateGallery.tsx | region, group | A carousel has no native element; region marks the labeled landmark, group scopes the slide controls |
YourProductsGrid.tsx, TemplatesExplorer.tsx | group | Groups a set of related controls (filters, cards) that aren't a <fieldset> |
FaqItem.tsx | region (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.tsx | link | Covered in detail below — an anchor styled to look disabled, where removing href would cost more than it fixes |
app/icon.svg | img | An 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.
The one that's easy to get wrong: role="link" on a styled <a>
// 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
| Symptom | Cause | Fix |
|---|---|---|
| A screen reader never announces a status message that visibly appears | The aria-live node is conditionally rendered along with its first message | Always render the live-region node; change only its text content |
| A custom tab/accordion/modal is silent to assistive tech despite working visually | No role at all on a pattern HTML has no native element for | Add 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 role | Remove it — a native element's implicit role needs no ARIA restatement |
| An error message interrupts a sighted user's flow unnecessarily | role="alert" used for routine, expected feedback | Use 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 tech | No middle state expressed — either a real href or no element at all | Use role="link" + aria-disabled="true" + tabIndex={0} on a non-anchor element, as this codebase's Button does |
| Two announcements fire for one message | A visible live text node isn't marked aria-hidden, so it's both visually shown and read aloud, alongside the actual live region | Mark 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.
