Cheatsheet

Accessibility patterns that fix the common failures

A practical reference rather than a standards summary: the element to use instead of a div, the small set of ARIA attributes that earn their place, the WCAG AA contrast numbers, how focus should behave, and a keyboard checklist you can run in five minutes. Most accessibility failures on real sites come from the same eight or nine mistakes, and every one of them is on this page.

Semantic HTML first: use this, not a div

Every row below replaces JavaScript you would otherwise have to write and maintain. A native element arrives with a role, an accessible name, keyboard behavior, focus handling, and platform conventions that no amount of ARIA reproduces exactly.

Use Instead of What you get for free
<button> A div with a click handler Tab stop, Enter and Space activation, button role, disabled state, form submission.
<a href> A button that navigates Link role, middle-click and open in new tab, visited state, a real URL to share.
<dialog> A div overlay with a z-index showModal() gives a focus trap, inert background, Escape to close, and top-layer stacking.
<details> <summary> A hand-rolled accordion Expanded state announced, keyboard toggle, find-in-page opens it automatically.
<nav> A div of links A navigation landmark screen reader users jump to directly.
<main> A content wrapper div One per page. The target for the skip link and the "jump to content" gesture.
<header> <footer> Divs with classes Banner and contentinfo landmarks when they are direct children of body.
<search> A div with role="search" The search landmark, natively, with no attribute to forget.
<h1> through <h6> Styled divs or a bare paragraph The document outline. Heading navigation is the single most-used screen reader shortcut.
<ul> <ol> <li> A stack of divs Item counts announced up front: "list, 12 items".
<table> <th scope> A CSS grid of divs Row and column headers read with every cell, so a data cell has context.
<caption> A heading above the table Becomes the table's accessible name, announced when focus enters it.
<fieldset> <legend> A heading above radio buttons The group question is repeated with each option, so "Yes" makes sense alone.
<label for> A placeholder or an adjacent span Accessible name, plus a bigger click target since clicking the label focuses the field.
<figure> <figcaption> An image with a paragraph under it A programmatic link between the caption and the thing it captions.
<time datetime> A span of formatted text A machine-readable date alongside the human one.
<strong> <em> <b> and <i> for emphasis Meaning rather than appearance. Some screen readers change tone for them.
<html lang="en"> Nothing at all The correct speech synthesizer voice. One attribute, and it is a WCAG level A failure to omit.

Gotcha: a <section> only becomes a landmark when it has an accessible name, so use aria-labelledby pointing at its heading. Without a name it is an anonymous generic box, which is why every band on this site carries one.

ARIA: the first rule, then the attributes worth using

The first rule of ARIA is not to use ARIA. If a native element with the semantics and behavior you need already exists, use it rather than repurposing a div and bolting a role on. Bad ARIA is measurably worse than none, because it overrides what the browser already got right. The rest of the rules follow from that one: do not change native semantics, keep every ARIA control keyboard-operable, never put aria-hidden or role="presentation" on something focusable, and give every interactive element an accessible name.

That leaves a short list of attributes that genuinely add something HTML cannot express.

Attribute Use it for
aria-label Naming a control with no visible text, such as an icon-only close button.
aria-labelledby Naming something from visible text elsewhere. It beats aria-label when both exist.
aria-describedby Attaching a hint, a format rule, or an error message to a field.
aria-hidden="true" Hiding purely decorative markup, most often an icon next to real text. Never on anything focusable.
aria-expanded On the trigger of a disclosure, dropdown, or menu. Goes on the button, not the panel.
aria-controls Pointing a trigger at the id of the region it opens. Support is patchy but it costs nothing.
aria-current="page" Marking the active item in a nav. Also accepts step, date, time, and true.
aria-live="polite" Announcing content that changes without a page load: a result count, a save confirmation.
role="status" Shorthand for a polite live region. Present in the DOM before the message arrives.
role="alert" An assertive live region that interrupts. Reserve it for errors and genuine urgency.
aria-invalid="true" Marking a field that failed validation, paired with aria-describedby for the reason.
aria-disabled="true" A control that is unavailable but must stay focusable so users can find out why.
aria-pressed A toggle button that stays down, such as bold in an editor toolbar.
role="switch" An on/off control where "on" and "off" read better than "pressed".
role="tablist" group Tabs, with tab, tabpanel, aria-selected, and arrow-key navigation. There is no native tab element.
role="presentation" Stripping semantics from a wrapper you only kept for layout. Same as role="none".

Gotcha: a live region must exist in the DOM before you write into it. Injecting a whole <div role="alert"> at the same moment as its message frequently announces nothing. Render the empty container up front and update its text content.

Gotcha: aria-label on a plain <div> or <span> is usually ignored, because those elements have no role to attach a name to. Put it on an element with a role, or use visually hidden text instead.

Focus management

Focus is the keyboard user's cursor. If you cannot see where it is, or it lands somewhere unexpected after an interaction, the page is unusable regardless of how the markup validates.

Rule Detail
:focus-visible Style this, not :focus. It shows a ring for keyboard users and suppresses it for mouse clicks, which is the actual reason people delete outlines.
outline: none Never on its own. If you remove the default ring you owe a replacement with at least 3:1 contrast against the adjacent color.
tabindex="0" Adds an element to the natural tab order. Needed only for custom widgets that are not already focusable.
tabindex="-1" Focusable by script but not by Tab. This is how you move focus to a heading or an error summary.
tabindex="1" or higher Always a bug. It jumps ahead of every natural tab stop on the page and breaks the reading order.
Skip link The first focusable element in the body, hidden until focused, pointing at the id on <main>.
Dialog open Move focus into the dialog, trap it there, and close on Escape. showModal() does all three natively.
Dialog close Return focus to the element that opened it. Skipping this drops keyboard users back at the top of the document.
inert Removes a subtree from focus, clicks, and the accessibility tree at once. The correct way to disable background content.
scroll-margin-top Add it globally when a sticky header exists, or a focused element scrolls under it. WCAG 2.2 makes that a failure.
Roving tabindex In a toolbar, tab list, or grid, exactly one item is tabbable and arrow keys move between them.
Route change In a single page app, focus the new page heading and announce the title. Nothing happens otherwise.
/* Visible focus that survives a design review */
:focus-visible {
  outline: 3px solid CanvasText;   /* system color, adapts to forced-colors mode */
  outline-offset: 2px;
}

/* Screen-reader-only text: readable by AT, invisible on screen */
.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The skip link: hidden until it takes focus */
.skip-link { position: absolute; inset-inline-start: -9999px; }
.skip-link:focus { inset-inline-start: 1rem; inset-block-start: 1rem; }

/* Respect the OS setting instead of animating regardless */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

Gotcha: display: none and visibility: hidden remove content from the accessibility tree entirely, which is correct for a closed menu and wrong for text you meant only to hide visually. That is what the .sr-only pattern above is for.

Color and contrast: the WCAG AA numbers

These are the thresholds an audit will check. Memorize the first three and you will catch most of what gets flagged.

Requirement AA threshold Applies to
Body text contrast 4.5:1 Any text under 24px, or under 18.66px when bold, against its background.
Large text contrast 3:1 24px and up, or 18.66px and up when bold. Headings usually qualify.
Non-text contrast 3:1 Input borders, focus rings, icons that carry meaning, chart segments.
Target size 24 by 24 CSS px Every pointer target, unless an equivalent larger control exists. New in WCAG 2.2.
Reflow 320 CSS px No two-dimensional scrolling at 320px wide, which equals 400% zoom on a 1280px screen.
Text resize 200% Nothing clipped or overlapping at double text size. Use rem, not px, for type.
Text spacing 1.5 line height Plus 2x paragraph spacing, 0.12em letter spacing, 0.16em word spacing, with no loss of content.
Color alone Never sufficient Errors, required fields, chart series, and links in body text need a second signal.

Gotcha: disabled controls and pure decoration are exempt from the text contrast rule, and placeholder text is not. Grey-on-grey placeholders are one of the most common automated findings, and the fix is usually to stop using a placeholder as a label anyway.

Gotcha: contrast is computed against the actual rendered backdrop. Text over a photo, a gradient, or a semi-transparent overlay has to pass at its worst point, not its average.

Forms, labels, and errors

Forms are where accessibility work pays off commercially, because every barrier here is a conversion you lost.

Do this Why
<label for="email"> on every field The id must match exactly. A wrapping label works too, but the explicit form survives refactors better.
Visible label, not a placeholder Placeholders vanish on focus, fail contrast, and are not announced consistently.
autocomplete="email" Required at AA for fields about the user. It also makes the form dramatically faster for everyone.
type and inputmode The right on-screen keyboard on mobile, plus free browser validation.
required, not aria-required The native attribute conveys the same thing and participates in validation.
aria-describedby to the error text Ties the message to the field so it is read on focus, not just seen.
aria-invalid="true" while invalid Announces the state itself. Remove it once the field is corrected.
Error summary at the top, focused On submit failure, focus a list of links to the failed fields. This is the single biggest form win.
Text plus icon for errors A red border alone is invisible to a large share of users.
Label text inside the accessible name If the visible label says "Send", the accessible name must contain "Send" so voice control works.
<button type="button"> for non-submit A button inside a form defaults to submit, which is the cause of most mystery page reloads.
<label for="email">Work email</label>
<input
  id="email"
  name="email"
  type="email"
  autocomplete="email"
  required
  aria-describedby="email-hint email-error"
  aria-invalid="true">
<p id="email-hint">We only use this to send the receipt.</p>
<p id="email-error" role="alert">Enter an email address including an at sign.</p>

Gotcha: aria-describedby accepts a space-separated list of ids, so a hint and an error can both be attached to one field. They are read in the order listed, not the order they appear in the DOM.

Images and alternative text

Alt text describes the function an image performs in context, not what the image looks like. The same photograph needs different alt text on a product page and in a photo essay.

Case What to write
Informative image The information it conveys, in a sentence. Skip "image of" and "photo of" - the role already says that.
Decorative image alt="", empty but present. A missing alt attribute makes the screen reader read the filename.
Image inside a link Describe the destination, not the picture. It becomes the link's accessible name.
Image inside a button Describe the action: "Delete row", not "trash can".
Icon next to visible text aria-hidden="true" on the icon. The text already carries the meaning; repeating it is noise.
Inline SVG that means something role="img" plus a <title> as the first child, or an aria-label on the svg element.
Chart or diagram Short alt for the takeaway, plus the underlying numbers in a real table nearby.
Text baked into an image Avoid entirely. If unavoidable, the alt must repeat the text verbatim.
Every raster image Intrinsic width and height attributes so the layout does not shift once it loads.

This site follows the same discipline it recommends: every image on newstack.dev carries descriptive alt text plus intrinsic width and height attributes, and no image is ever the only place a fact appears. The cheatsheets in particular ship no screenshots at all, because a table of commands is searchable, selectable, translatable, and readable at 400% zoom in a way a picture of the same commands never is.

Gotcha: layout shift is an accessibility problem, not just a Core Web Vitals number. A user with a motor impairment who is aiming at a button when an image loads above it and pushes everything down has just clicked the wrong thing.

The five-minute keyboard test

Automated tools catch somewhere around a third of real issues, which is worth having and nowhere near enough. Push the mouse away and run this list on any page you are about to ship.

Check Pass looks like
First Tab press A visible "Skip to content" link appears before anything else.
Tab through everything Every link, button, field, and control is reachable. Nothing is skipped.
Focus is always visible You can point at the focused element at every step without guessing.
Focus is never obscured A sticky header or cookie bar never covers the focused element.
Order matches the layout Focus moves the way the eye reads. A reordered CSS grid is the usual culprit.
Enter and Space Enter follows links and presses buttons; Space presses buttons and toggles checkboxes.
Escape Closes any open dialog, menu, or popover and returns focus to the trigger.
Arrow keys Move within tabs, menus, radio groups, and grids. Tab moves between widgets, not inside one.
No trap You can always Tab back out, except inside a modal dialog where the trap is intentional.
Zoom to 400% Content reflows to one column with no horizontal scrolling and nothing clipped.
Headings outline One h1, no skipped levels, and the outline alone tells you what the page covers.
Automated sweep axe DevTools or the Lighthouse accessibility audit reports zero violations.
One screen reader pass NVDA with Firefox on Windows, or VoiceOver with Safari on macOS. Twenty minutes teaches more than any article.

Gotcha: a passing automated audit is a floor, not a ceiling. Tools cannot tell you that alt text is wrong, that a heading is misleading, that focus went somewhere useless, or that the reading order makes no sense. Those are exactly the failures that make a page unusable.

Keep going

Reading order is a layout problem as much as a markup one: the CSS layout cheatsheet covers why order and grid placement never change the tab order, and how to keep the two in sync.

Automating these checks in CI is covered in the testing tool guide, where an axe run inside Playwright catches regressions before review does.

Primary sources worth bookmarking: the WCAG 2.2 quick reference for the criteria themselves and the ARIA Authoring Practices patterns for keyboard behavior on every widget type. More quick references are in the cheatsheet index.