Structured Prompts for Accessible React (Roles, Keyboard, Contrast)
I used to paste “make this component accessible” into ChatGPT and hope for the best. Sometimes I got a role="button" on a real <button>. Sometimes the contrast “fix” was #666 on #fff with no math behind it. The model was
I used to paste “make this component accessible” into ChatGPT and hope for the best. Sometimes I got a role="button" on a real <button>. Sometimes the contrast “fix” was #666 on #fff with no math behind it. The model wasn’t dumb — my prompt was.
Once I stopped asking for vibes and started asking for a checklist the model had to fill, the diffs got usable. Here’s the prompt shape I use for React UI, and why each bucket matters.
Soft prompts produce soft a11y
Vague asks produce vague markup:
- “Make it accessible” → random
aria-labels, duplicate roles, no keyboard path - “Add ARIA” → decorative icons announced twice, interactive divs with no Enter/Space
- “Fix contrast” → a hex that looks darker, not one that passes 4.5:1
You’re outsourcing judgment without giving constraints. Models are great at filling a schema. They’re mediocre at inventing your team’s a11y bar from a one-liner.
The three buckets I always force
I put every generation (or agent edit) through the same three headings. If any heading is empty in the reply, I reject the draft.
1. Roles (and native elements first)
Ask the model to prefer HTML semantics, then ARIA only when needed:
For each interactive or landmark node in the component:
- Prefer a native element (<button>, <a href>, <input>, <nav>, <main>).
- If you use a custom role, name the role and the reason native HTML wasn’t enough.
- Never put role="button" on a <button>.
- List every aria-* you add and what user need it covers.
That last line kills cargo-cult attributes. If the model can’t say why aria-describedby exists, it shouldn’t invent it.
2. Keyboard
Sighted mouse testing lies. Force an explicit path:
Describe the keyboard path for this UI:
- Tab order (which nodes are focusable, in what order)
- Activation keys (Enter / Space for buttons; arrows only if you implement a composite widget)
- Escape / dismiss behavior for overlays
- Focus return target after close
- Visible focus styles (class or CSS custom property — don’t rely on outline: none alone)
If the answer is “user can click,” the prompt failed. Composite widgets (menus, tabs, listboxes) need more than Tab — but most “buttons” and “links” don’t need arrow-key theater. Make the model justify complexity.
3. Contrast
Don’t accept “looks fine.” Ask for numbers:
For text and UI chrome in this component:
- Foreground / background pairs (hex or token names)
- Target ratio: 4.5:1 for normal text, 3:1 for large text and non-text UI (WCAG 2.x AA)
- Show the calculated ratio or admit you didn’t calculate it
- If using design tokens, name the token — don’t invent a one-off hex
Models will still approximate ratios. That’s fine — the point is they have to show the pair. Reviewers catch math errors faster than they catch “I made it darker.”
A full prompt template I actually paste
You are editing a React + TypeScript component. Return:
1) the full updated component
2) a short a11y report with three sections: Roles, Keyboard, Contrast
Rules:
- Native elements before ARIA
- No role on elements that already have that implicit role
- Keyboard path must be documented; no mouse-only flows
- Contrast: list fg/bg pairs and target ratios (AA)
- Do not add product marketing or unrelated refactors
- Keep existing design tokens; don’t invent new color literals unless necessary
Component / context:
<paste code or file path summary>
User story:
<e.g. “Filter chips in a toolbar; one chip can be selected; Clear resets”>
The user story matters. Without it, the model guesses wrong focus order (e.g. putting “Clear” before the chips because it appeared first in the JSX).
What I reject on sight
Even with a structured prompt, I still throw away drafts that:
- Wrap everything in
div+rolewhen a<button>would do - Set
tabIndex={0}on static text “for accessibility” - Use
aria-labelthat duplicates visible text (screen readers hear it twice in some setups) - Remove focus outlines without a replacement
- Claim “WCAG AAA” with no pairs listed
Structured prompts don’t replace review. They make review cheaper.
Wiring this into an agent loop
If you use Copilot Chat, Cursor, or a custom agent on PRs:
- Put the three-bucket checklist in the system or project prompt (once).
- Ask the agent to fail the turn if any bucket is missing — same as a CI check.
- Keep a golden example: one good Button, one good Modal, one good Combobox. Point the model at those files instead of the open web.
I keep the checklist in the repo (docs/a11y-prompt.md) so humans and agents share one source. When WCAG guidance or our token names change, we edit that file — not twelve ChatGPT threads.
Tiny example: what changes in the output
Soft prompt: “Make this accessible.”
You get a <div onClick> with role="button" and aria-label="Submit", no keyboard handler, contrast unchecked.
Structured prompt: same UI, three buckets required.
You get a <button type="submit">, focus ring via existing --focus-ring token, Enter/Space free because it’s a real button, and a report line: text/primary on surface/default ≈ 7.2:1.
Same model. Different ask.
Start tonight
You don’t need a new tooling stack. Copy the three buckets into your next AI edit. Reject incomplete reports. After a week you’ll notice fewer “a11y polish” follow-up PRs — not because the model got smarter, but because you stopped asking it to invent the rubric.
If your team already has an a11y lint (eslint-plugin-jsx-a11y, axe in CI), keep it. Structured prompts catch the design intent those linters never see: focus return, composite widget behavior, and whether that new hex belongs in the token set at all.
Originally published by Dev.to AI. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.