DEV Community

Sherotree
Sherotree

Posted on

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 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.
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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”>
Enter fullscreen mode Exit fullscreen mode

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 + role when a <button> would do
  • Set tabIndex={0} on static text “for accessibility”
  • Use aria-label that 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:

  1. Put the three-bucket checklist in the system or project prompt (once).
  2. Ask the agent to fail the turn if any bucket is missing — same as a CI check.
  3. 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.

Top comments (0)