No ARIA is better than bad ARIA
The disciplined use of ARIA, the accessibility attributes that fill the gaps native HTML and your shadcn primitives leave open.
Every year, WebAIM scans the home pages of the top million websites and counts the accessibility errors a machine can detect. The 2026 run found a result worth pausing on: pages that used ARIA averaged roughly 59 detected errors, while pages that used none averaged about 42. More ARIA, more errors. The gap is large, and it points the same direction every year the report has run, so it isn’t noise.
You might read that and conclude ARIA is harmful. It isn’t. The pages that reach for ARIA are the ones attempting harder interfaces, and ARIA is the power tool they grab to build them. The catch is that the same tool can cut the wrong way. ARIA is the first thing in this chapter that can make an interface measurably worse while the diff makes it look like you improved accessibility. Everything else you have learned, semantic HTML, the shadcn primitives, the four commitments, only ever helps. ARIA is the one surface where you can ship a regression and feel good about it.
So this lesson is mostly about restraint. By the end you will know the four ARIA surfaces a SaaS engineer actually reaches for, roles, labels, descriptions, and live regions, and you will also know when not to reach for any of them. You will label an icon-only button so a screen reader can read it, announce a “Saved” toast and a search-result count to someone who can’t see them, and wire help text to an input. Those tasks show up in every screen you build from the next chapter on.
It helps to frame this against the previous two lessons. Semantic HTML is the first move, and the shadcn primitives ship correct ARIA for the widgets they cover. Together those handle something like ninety percent of accessibility before you type an aria- anything. This lesson is the other ten percent: the custom widgets and the live state the primitive doesn’t know about. Most of your ARIA will be a handful of attributes, and the skill is knowing which handful and having the discipline to stop there.
The one rule that prevents most ARIA bugs
Section titled “The one rule that prevents most ARIA bugs”One rule, held honestly, prevents the large majority of ARIA bugs before they exist. It comes straight from the WAI-ARIA Authoring Practices Guide (the APG), the W3C document that defines how ARIA is meant to be used:
If you can use a native HTML element with the semantics and behavior you need, use it.
The same guide leads with the maxim this lesson is named after: no ARIA is better than bad ARIA. A control with no ARIA at all is a known quantity. A control with wrong ARIA tells the user something false, and a false signal is worse than silence.
To understand why ARIA goes wrong so reliably, you need one mental model:
ARIA only changes what assistive technology announces. It never changes behavior.
It does not touch keyboard handling, focus, or clicks. ARIA edits the description of an element that assistive technology (AT: screen readers, switch devices, magnifiers) reads aloud. It does not touch what the element does. Hold that distinction and most ARIA mistakes become visible the moment you make them.
Here is the mistake that the WebAIM numbers are partly made of. You need a Save control, and for whatever reason you build it on a <div>:
<div role="button" onClick={save}>Save</div>This renders, it clicks with a mouse, and a screen reader now announces “Save, button.” That announcement sets an expectation: a screen-reader user hears “button” and expects Enter and Space to activate it, the way every button they’ve ever used does. But a <div> isn’t in the tab order and has no key handling, so it’s dead to the keyboard. The role claimed something the markup can’t deliver. The tempting wrong fix is to stack on tabIndex={0}, then an onKeyDown for Enter, then another for Space, then recreate the focus behavior by hand. You would be rebuilding <button>, badly, attribute by attribute.
<button onClick={save}>Save</button>The native element brings the role, the keyboard, and the focus together as one package, for free. The screen reader announces “Save, button,” and Enter and Space genuinely work, because the browser wired them. ARIA gives you only the announcement; the native element gives you the announcement and the behavior, already in sync.
That contrast shows what “ARIA is not behavior” means, and it is also why “semantic HTML first” from the previous lesson is not a style preference. Native elements are the only thing that keeps what AT says about a control and what the control does in lockstep. The moment you split them, putting the role on one element and the behavior nowhere, the announcement and the reality no longer match.
The scope of this lesson is narrow on purpose. Native HTML and the shadcn primitives cover the baseline. ARIA is for four jobs, and only four:
- Roles that describe custom widgets. The primitive already writes almost all of them, so this is mostly a list of things you don’t do.
- Labels that give a name to a control with no visible text.
- Descriptions that attach help or error text to a control.
- Live regions that announce changes a sighted user would simply see.
The rest of the lesson covers those four in order. Each one arrives the same way: here is the gap native HTML leaves open, and here is the one ARIA tool that fills it.
Roles: the few you write, the many the primitive owns
Section titled “Roles: the few you write, the many the primitive owns”A role overrides an element’s semantics for assistive technology: it tells AT to treat the element as something other than what its tag says. That raises a question: when do you actually need to override semantics that HTML already gives you?
Mostly, you don’t. Every HTML5 element ships an implicit role for free. A <button> is already a button to AT. A <nav> is already a navigation region, <main> is already the main region, and <h1> is already a heading. So the explicit role you were about to type, as in <button role="button"> or <nav role="navigation">, just repeats the element you already chose. Redundant ARIA is the harmless end of “bad ARIA,” but it is still noise, and it is still a sign you reached for the attribute before the element.
What about the genuinely complex roles, the ones for tabs, menus, comboboxes, trees, and grids? Here the work from the previous lesson pays off directly: you do not write these. A shadcn DropdownMenu is already wired as menu and menuitem, Tabs is tablist, tab, and tabpanel, and Dialog is dialog. The Radix primitive underneath owns the entire role taxonomy for the widget, and, true to the “the source is the docs” idea from the first lesson, you can open the file in components/ui/ and see the roles sitting right there. Your job is to recognize that this machinery exists and is already handled, not to reproduce it.
That leaves a short list of roles a SaaS engineer actually authors. It is short enough to give in full:
role="status"androle="alert", for announcing live changes. These are the live-region surface, and they get their own section later; they’re on the list so the list is complete.role="presentation"(its synonymrole="none"does the same thing), which strips an element’s implicit semantics. The trigger is specific: you have inherited markup that reuses a semantic element purely for layout, such as a<table>laying out a form or a<ul>used as a grid, and AT is announcing “table” or “list, 3 items” over what is really just visual scaffolding. The role tells AT to ignore the semantics and treat the element as a plain container.- Landmark roles:
banner,navigation,main, andcontentinfo, the explicit equivalents of<header>,<nav>,<main>, and<footer>. You met landmark landmarks back in the lesson on semantic structure. The rule here is the same as before: prefer the element. Use theroleonly in the rare case you genuinely cannot change the tag.
The same shape repeats across every entry: the right move is almost always to fix the element, and the role is the fallback for when you can’t. role="presentation" shows this most clearly, since the real fix is usually to stop using a <table> for layout in the first place. But when you have inherited code you can’t restructure today, the role is there:
{/* legacy layout table — presentation until it's rebuilt as a grid */}<table role="presentation"> {/* … inherited rows … */}</table>That is the entire role surface you own: status and alert in their own section, presentation as the fallback for inherited markup, and landmarks only when the element is out of reach. Everything else, the primitive already wrote.
Naming controls a screen reader can read
Section titled “Naming controls a screen reader can read”Every interactive control needs an accessible name , the string AT announces when the user lands on it. A sighted user reads a button’s name off its visible text. AT needs that same name, and the only question is where the name comes from. There are four sources, and one rule keeps them simple: exactly one source supplies the name; you never stack them. They do have a fixed override order when stacked, where aria-labelledby beats aria-label beats visible text. But once you are relying on that order, you have already made the mistake. Supply one.
Here are the four sources, in the order you actually reach for them, from the one you want first to the one you want last:
- Visible text content. The default, and the best one. In
<button>Save</button>or<a href="/settings">Settings</a>, the name is the visible text, so the name and what the user sees can never drift apart. Whenever the control has visible text, you are already done. Add nothing. aria-label. Supplies a name when there is no visible text to use. This is the icon-only button, coming up next, the case this whole section exists for.aria-labelledby={id}. Points at another element’s text and borrows it as the name. Reach for this when the label already exists elsewhere on screen, such as a section named by its own heading. It pairs naturally withuseIdfor the id, which we’ll use in the next section.title. Listed only so you know to avoid it for naming. Thetitleattribute shows a tooltip on mouse hover, but AT support for it as an accessible name is inconsistent, and it never appears for keyboard or touch users at all. Don’t rely on it.
One thing this chain is not about is form inputs. A text input gets its name from a <label htmlFor>, which you met in the lesson on the label contract: that is the form-specific labeling mechanism, and it is the right tool for inputs. Don’t reach for aria-label on an <input> when a visible <label> is what the field actually wants.
The icon-only button
Section titled “The icon-only button”Now for the case that recurs in every modal, drawer, toast, and table row you will ever build, the most frequent ARIA task in a shadcn codebase. You drop a close button into a dialog:
<Button size="icon"><X /></Button>To a sighted user this is obviously a close button, because the X says so. To a screen reader it announces, in full, “button.” That’s it. No name. The user has landed on a control with no idea what it does, and this miss is everywhere.
Before the fix, one point about the icon corrects a common instinct. You might think the <X /> needs an aria-hidden so the screen reader doesn’t try to read the SVG. It doesn’t: Lucide (the icon set shadcn ships) marks every icon aria-hidden="true" by default, so the glyph is already invisible to AT. The problem isn’t that the icon is being announced; it’s that nothing is. The name has to go on the button, where the interaction lives, never on the icon. There are two equivalent ways to put it there:
<Button size="icon"><X /></Button>
<Button size="icon" aria-label="Close"><X /></Button>
<Button size="icon"> <X /> <span className="sr-only">Close</span></Button>The starting point. The Lucide <X /> is already aria-hidden, so AT reads only the button’s role and finds no name. The screen-reader user hears “button” and nothing more.
<Button size="icon"><X /></Button>
<Button size="icon" aria-label="Close"><X /></Button>
<Button size="icon"> <X /> <span className="sr-only">Close</span></Button>The most direct fix. aria-label supplies the name straight onto the interactive element, and the control now announces “Close, button.” This is the one to reach for in the common case.
<Button size="icon"><X /></Button>
<Button size="icon" aria-label="Close"><X /></Button>
<Button size="icon"> <X /> <span className="sr-only">Close</span></Button>The equivalent alternative: real text content, hidden from the screen visually but present in the accessibility tree, so it becomes the button’s name the normal way (source 1 in the precedence chain). The result is identical, so pick whichever reads cleaner at the call site. Reach for this when you want the label to live as real DOM text rather than an attribute.
That sr-only is a Tailwind utility: sr-only hides an element visually while keeping it in the accessibility tree. It does a lot of the work of accessible labeling, and you’ll see it again in a moment; for now, read it as “text only AT can perceive.”
Now put it into practice. The exercise below hands you a small toolbar where every button is icon-only and nameless. Give each one an accessible name, and watch for the case the precedence chain warns about: the one button that does have visible text needs nothing, so don’t stack a redundant aria-label onto it.
Every icon-only control in this toolbar announces only 'button' to a screen reader — Bold, Italic, and Delete have no accessible name. Give each one a name, either with aria-label on the Button or an .sr-only text child. The icons are already aria-hidden, so don't touch them. Publish already has visible text, so it's correct as-is — leave it alone, and don't stack a redundant aria-label onto it.
Reference solution
export function App() { return ( <div role="toolbar" className="flex gap-2 p-4"> <Button aria-label="Bold"> <BoldIcon /> </Button> <Button aria-label="Italic"> <ItalicIcon /> </Button> {/* The .sr-only alternative is equally correct — pick whichever reads cleaner */} <Button> <TrashIcon /> <span className="sr-only">Delete</span> </Button> <Button>Publish</Button> </div> );}Each icon-only button now exposes a name on the interactive element: aria-label on Bold and Italic, an .sr-only text child on Delete. The two approaches are interchangeable. The icons stay aria-hidden, so the screen reader reads the name and nothing else: “Bold, button.” Publish is left untouched, because its visible text already is its name. Adding aria-label="Publish" would stack a second source on a control that already has one, the redundancy the precedence chain exists to prevent.
Help text and descriptions with aria-describedby
Section titled “Help text and descriptions with aria-describedby”A name tells the user what a control is. Sometimes you also need to tell them something about it: a format hint under a field, a sentence of help text, an error message. That second category is a description, and AT reads it after the name. The mechanism is aria-describedby, and it works by reference: you point the control at the id (or several ids) of the elements that hold the description text.
const hintId = useId();
<input aria-describedby={hintId} /><p id={hintId}>We'll never share your email.</p>The attribute takes a space-separated list of ids, so a control can have more than one description, a hint and an error for instance, and AT reads each referenced element in turn. Here it points at one: the input announces its name, then “We’ll never share your email.”
The id itself is the part worth pausing on, because hand-writing it invites a bug. You reached for useId back in the lesson on stable ids, and this is one of its core uses. A description lives in a separate element from the control that references it, so the two have to agree on a string. If you hard-code id="email-hint" and that component renders twice on the same page, say two copies of the same form or a list of them, you now have two elements sharing one id, and the reference is ambiguous. useId hands you an id that is unique per instance and stable across the server render and the client hydration, which is exactly the guarantee a cross-element reference needs.
const hintId = useId();
<input type="email" aria-describedby={hintId} /><p id={hintId}>We'll never share your email.</p>One discipline to hold from the start: a reference and its target belong together. If the element a describedby points at gets emptied or removed but the attribute stays, AT is left reading an empty description or pointing at a node that no longer exists. Whenever you remove the hint, remove the reference in the same change.
This is deliberately the shallow version. Descriptions earn their keep in form validation: wiring an error message to a field, pairing aria-describedby with aria-invalid, and announcing the error at the right moment. That is a whole topic, and it belongs to the forms chapter (Chapter 44), which builds it properly. Here you only need the wiring: a description is a referenced element, read after the name, with a useId-stable id holding the two ends together.
Hiding the right things from the right audiences
Section titled “Hiding the right things from the right audiences”Three attributes hide content, and they are constantly confused for one another. The confusion comes from treating “hidden” as one thing, when it isn’t. A single question untangles all three: who needs to perceive this content? A piece of content can serve sighted users, AT users, both, or neither, and there is a different tool for each case.
sr-only(the Tailwind utility): in the accessibility tree, off the screen. Visible to AT, hidden from sighted users. Use it for text that gives AT the context a sighted user already gets from layout or an icon: the skip-link text, the icon-button label from two sections ago, a “Showing results for…” line.aria-hidden="true": on the screen, out of the accessibility tree . Visible to sighted users, hidden from AT. Use it for purely decorative visuals that would be noise if announced: a decorative image, an icon sitting next to a visible text label.hidden/display: none: off the screen, out of the accessibility tree, and out of layout entirely. Hidden from everyone, because it is content that is not relevant to anyone right now: the body of a collapsed accordion panel, a tab’s contents while another tab is active.
The everyday version of this is icons, and it is where the three tools fall into place. Take <Mail /> Send, an icon beside the word “Send.” The word carries the meaning, and the icon is decoration. If AT announced both, the user would hear “mail, Send,” which is just noise, so the icon should be aria-hidden. With Lucide that is already true by default, which is the right default: a decorative icon next to a label needs nothing from you.
A standalone <Mail /> button, an icon with no text, looks like it breaks that rule, since it clearly does something. So shouldn’t you un-hide its icon? No. The resolution is the rule you already learned: you don’t expose the icon, you name the button (aria-label="Compose"). The name lives on the interactive element, and the icon stays decorative in both cases. The icon being decorative and the control being meaningful are two separate layers, and once you separate them the conflict disappears.
Walk the decision through once before you sort it at speed. The walker below gives you realistic snippets of content. For each one, answer the one question, who needs to perceive this, and follow it to the right tool.
A decorative visual beside a visible label is noise to a screen reader, because the label already carries the meaning.
aria-hidden="true" keeps it on the screen and out of the accessibility tree, and every Lucide icon ships this way by default, so usually you write nothing.
Pick sr-only or leave it in the tree instead and AT reads “mail, Send,” the same word twice, clutter on every pass.
The control is meaningful, but you still don’t expose the icon.
The name lives on the interactive element, as aria-label="Compose" on the <Button> (or an .sr-only text child), and the glyph stays decorative, exactly as in the decorative case.
Un-hide the icon instead and AT may read the raw SVG title or nothing useful, so the reliable name belongs on the button.
The text alternative for an icon-only button, a skip-link’s words, a “Showing 12 of 134 results” line a sighted user infers from the list: AT needs the text that layout or an icon conveys visually.
sr-only keeps it in the accessibility tree while hiding it from sight.
Reach for aria-hidden here and the control announces nothing, or the AT user never learns the result count, so the information vanishes for exactly the audience that needed it.
The body of a collapsed accordion panel or the contents of a closed modal: content that is irrelevant to every audience until the UI enters that state.
hidden (or display:none) removes it from the screen, the accessibility tree, and layout.
aria-hidden alone is the wrong tool here: it leaves the subtree in layout and in the tab order, so keyboard focus can still land on a control inside the “hidden” panel.
This is the one that leaves users stuck, so the walk ends on a don’t.
aria-hidden removes a node from the accessibility tree but not from the tab order, so keyboard focus still reaches the control, and when it lands there AT announces nothing at all.
If the control should be gone, take it out of the DOM or make it genuinely inert; never aria-hidden over something the keyboard can still reach.
Now the same judgment at speed. Sort each piece of content by the audience it should reach.
For each piece of content, ask the one question — who needs to perceive it? — and drop it in the matching bucket. Drag each item into the bucket it belongs to, then press Check.
Announcing what only sighted users can see
Section titled “Announcing what only sighted users can see”The fourth surface is the one with real machinery behind it, and it carries most of this lesson’s value. Here is the problem it solves. A sighted user sees a toast slide in, a “Saved” badge flip on, or a result count change after they filter a table. An AT user perceives none of it: the page changed somewhere off to the side, silently, and nothing announced it. A live region fixes this. It is a region of the DOM that AT watches, so that when its contents change, AT reads the new text aloud without the user having moved focus there at all.
A live region is defined by a small set of attributes. The walkthrough below builds a minimal one, one attribute at a time.
<div aria-live="polite" aria-atomic="true"> {message}</div>
<div role="status">{message}</div>aria-live says when AT announces a change. "polite" waits until the user is idle, then reads it, which is the right choice for almost everything. The alternative, "assertive", interrupts whatever AT is saying right now. Reserve assertive for genuine, must-not-miss messages, and reach for polite by default.
<div aria-live="polite" aria-atomic="true"> {message}</div>
<div role="status">{message}</div>aria-atomic says how much to announce. true reads the whole region on any change; the default (false) reads only the node that changed. Use true when the message only makes sense whole: “Saved 3 of 5” means nothing if AT reads just the “5”.
<div aria-live="polite" aria-atomic="true"> {message}</div>
<div role="status">{message}</div>You rarely set those two attributes by hand, because the roles bundle the right combination. role="status" implies aria-live="polite" and aria-atomic="true", and role="alert" implies assertive and atomic. Learn the two roles and you’ve learned the common cases, so reach for the role, not the raw attributes.
<div aria-live="polite" aria-atomic="true"> {message}</div>
<div role="status">{message}</div>This is the rule the next diagram is about, and the one that breaks live regions most often: the region must already exist in the DOM before its content arrives. AT monitors registered regions for changes, so a region that mounts with its text already inside was never registered in time, and the change goes unheard.
That last rule is subtle enough, and broken often enough, that it is worth watching rather than just reading. Here is the point: AT announces a mutation inside a region it is already watching, not a region appearing. So the order of operations matters in a way that is invisible in the rendered output. Scrub through the sequence below, which shows AT’s point of view over time for two implementations side by side: one that always mounts the region empty, and one that conditionally renders it.
Mount. The always-mounted region renders empty, so it’s in the DOM, and AT registers it and starts watching. The conditional version renders nothing while msg is falsy, so there’s no region for AT to watch yet. Neither announces anything; both are silent. The whole difference is settled right here, before any text exists.
setMsg('Saved'). On the left, “Saved” is inserted into the region AT was already watching, so AT detects the mutation and announces it. On the right, msg turns truthy, so the region mounts with “Saved” already inside it. AT sees a brand-new node appear, not a change in a watched region, so it stays silent (or fires unreliably). Same text on screen, but only one is heard.
setMsg('Saved ✓'). Every subsequent change to the watched region is announced too, so it keeps working. The conditional region now exists, but it was never registered, so its updates stay unreliable. AT announcement keys off a mutation inside a region it already watches, not a region that appears with text already in it.
The fix follows straight from the diagram: always render the region, and toggle only its contents. The two versions sit one keystroke apart, and that one keystroke is the whole bug.
{error && <div role="alert">{error}</div>}The region only exists when error is truthy, so it mounts with the text already inside, and AT was never watching it. The error is shown to sighted users and announced to no one. This is the most common shape of the live-region bug.
<div role="alert">{error}</div>The region is always in the DOM, and only its contents toggle (it’s empty when error is falsy). AT registered it on mount, so when error fills it, AT announces the change. The markup is the same, only the timing is reordered, and now it works.
status or alert?
Section titled “status or alert?”The two roles split on one axis: alert interrupts, status waits. alert cuts into whatever AT is currently reading, every single time. That is the right behavior for a genuine, time-sensitive failure, and the wrong behavior for routine confirmations. Wire your “Draft saved” badge as alert and the screen reader cuts off the user’s reading every few seconds. The split is about severity:
role="status"is the default reach, for anything informational: “Saving…”, “Draft autosaved”, “Search returned 12 results”, “Copied to clipboard.”role="alert"is reserved for genuine, time-sensitive failures the user must not miss: “Session expired”, “Payment failed”, “Connection lost.”
When in doubt, use status. alert is the exception you justify, not the default you reach for.
Wiring a message as role="alert" makes a screen reader cut into whatever it’s currently reading to announce it — every time. Pick the messages where that interruption is justified; the rest belong in a polite role="status". Select all that warrant alert.
alert: each tells the user something they were trying to do didn’t happen, and missing it leaves them acting on a false belief — so interrupting is justified. The other three are routine confirmations and a result count; nothing breaks if the user hears them a beat later. Wire those as alert and the screen reader interrupts the user’s reading on every filter and every save — the slot-machine failure. status is the default reach; alert is the exception you justify against “must not be missed right now.”You usually don’t build the toast
Section titled “You usually don’t build the toast”Here is the payoff for understanding all of that machinery: most of the time, you get to not build it. The most common live region in any SaaS app is the toast, and shadcn’s default toast is Sonner . (If you find an older codebase using a Toast component imported from components/ui/toast, that’s the deprecated predecessor; recognize it as legacy.) Sonner renders one persistent live region at the app root and injects every toast’s content into it. So the pre-mount rule, the entire diagram you just scrubbed through, is already handled for you. The region is always mounted, and Sonner mutates its contents.
So your job narrows to the one piece of judgment that’s actually yours: picking the severity.
toast.success('Saved');toast.error('Payment failed');The severity you pick is the politeness you get: a confirmation announces calmly, a failure with the urgency it deserves. You learned the machinery not so you’d hand-build a toast, but so you’d trust the primitive that did, and, just as importantly, so you can build the live region yourself when the surface isn’t a toast and no primitive covers it. (Sonner’s own setup is a job for a later chapter; here, the discipline is the severity call.)
That last case, a live region you build by hand, comes up more than you’d expect. Here is where it shows up in a real SaaS UI:
- Toasts use Sonner. Handled, as above.
- A search or filter result count is a
role="status"in the results header, updated after a filter runs: “Showing 12 of 134”. This is the canonical one you build yourself, since there’s no primitive for “the list changed, tell AT how many.” - An optimistic save indicator is a “Saved” badge as
role="status"when the optimistic update lands, and arole="alert"on rollback: “Couldn’t save, changes reverted.” (Optimistic UI, updating the screen before the server confirms, gets its full treatment in a later chapter; here it’s just a live-region surface.) - An inline form error is a
role="alert"beside the field, populated when validation fails. The forms chapter (Chapter 44) owns the depth.
Recognize the pattern and you’ll spot every place a live region belongs, and you’ll reach for one only where the primitive doesn’t already cover you.
The four surfaces, together
Section titled “The four surfaces, together”Step back and the whole lesson fits on one screen. Native HTML and the shadcn primitives are the baseline, and they handle the large majority of accessibility before you write a single aria- attribute. ARIA is the narrow slice on top, and it is exactly four jobs:
- Roles are almost always the primitive’s, not yours. Trigger: a custom widget no semantic element or primitive covers (rare). Mostly:
presentationto strip inherited layout semantics, landmarks when you can’t change the tag. - Labels are yours, on controls with no visible text. Trigger: an icon-only button. The name goes on the control, never the icon.
- Descriptions are yours, for help and error text. Trigger: an input that needs a hint or an error read after its name. Wire it with
aria-describedbyand auseIdid. - Live regions are yours, for async state a sighted user would just see. Trigger: a toast, a result count, a save indicator. Mount the region first; pick
statusoveralertunless it’s a genuine, must-not-miss failure.
One habit ties the WebAIM numbers back to the start of the lesson, and it is worth carrying into every screen: before you add any aria-*, ask whether a native element or your shadcn primitive already does this. If it does, that’s the bug. Fix the markup, don’t add the attribute. That question, asked every time, is the difference between the pages averaging 42 errors and the ones averaging 59. No ARIA is better than bad ARIA.
This lesson was about what AT announces. The next one is about where focus is, and it picks up the thread this one left open: the focusable element with aria-hidden on it, and the larger problem of focus order, which the platform does not solve for you.
External resources
Section titled “External resources”The source of the first rule and 'no ARIA is better than bad ARIA,' plus the correct, behavior-complete pattern for every widget.
The practical reference for roles, states, and properties — with the native-vs-ARIA comparison this lesson is built on.
Google's Learn Accessibility module on when ARIA helps and when native HTML already wins — a second, example-led take on the lesson's central call.
The annual report behind this lesson's opening data — closes the loop on the more-ARIA-more-errors finding with the real numbers.