Accessibility skill 2 of 12

Keyboard Focus SKILL.md: Fix Tab Order, Focus Rings and Hidden Focus

The Keyboard Focus skill makes Claude, Cursor or another AI agent find the places where keyboard users get lost: removed focus rings, positive tabindex, clickable divs and focus hidden under sticky headers. A static checker and a real Tab-key walk measure the page first, so each fix is proved by pressing Tab, not by reading code.

keyboard-focus/SKILL.md, version 1.0.0, 3,347 words, plus scripts/kf_check.py and scripts/tab_walk.js.

An agent works on keyboard focus in this order: run a read-only static checker over the HTML and CSS, walk the real page with the Tab key and read the numbers, fix the markup first (native elements, DOM order), then the focus indicator, then composite widgets, then anything that covers or loses focus, and re-run both tools. A static check cannot see scripts or the cascade, so the Tab walk is the proof.

Patterns and measurements are in references/patterns.md and references/appendix.md.

When to use this skill, and when not to

Use it when keyboard users cannot reach a control, cannot see where focus is, lose focus after an action, or find focus behind a sticky header or banner.

RequestUse instead
Contrast of the focus ring or textcolour-contrast.md
Accessible names, roles, aria-hidden on focusable contentaria-names-roles.md
Choosing <button> or <a>, headings, landmarkssemantic-html.md
Native <dialog> and popover markup, Esc, backdropdialog-and-popover.md
Form labels and error texthtml-forms.md
Screen reader testing, switch devices, voice controlOut of scope. Needs a human with the device

Guardrails

  1. Propose, then change. Report and plan first. Edit only after the user approves a batch, on a branch under version control. Never push, merge or deploy.
  2. Never delete focus code you do not understand. Scripts that call focus(), set tabindex or trap focus can belong to a modal or a widget. Read the handler before changing it.
  3. Pages you own only. The Tab walk opens a page in a browser. Do not run it on sites the user does not own. Never ask for session cookies, tokens or passwords. For a page behind a login, ask the user for a static HTML copy with test data.
  4. Data handled. kf_check.py reads files. tab_walk.js prints counts, selectors and element names, never page text, form values or storage. Pages can contain personal data: do not request screenshots, and never paste page text into reports.
  5. Nothing is invented. Every rule carries a source and a date. Version-dependent behaviour carries verify before relying on this.
  6. Not a conformance statement. A clean run means the checked paths work. It is not a WCAG conformance claim and not legal advice. Any conformance claim needs a human reviewer who has used the page with a keyboard and a screen reader.
  7. Refuses to: add positive tabindex, remove an outline with no replacement, use aria-hidden to hide focusable content, or report a fix as verified without the Tab walk.

Inputs the agent must have before starting

Ask for every missing item. Do not assume.

InputAsk exactly
Files"List the HTML templates and every stylesheet, in load order."
Page"Which page may I open in a browser? Give a local file or a URL you own, with test data."
Widgets"Which components take keys: menus, tabs, toolbars, comboboxes, carousels, modals?"
Sticky parts"Which elements are sticky or fixed: header, cookie banner, chat button, bottom bar? What are their heights?"
Tooling"Is Node and Playwright installed? If not, may I install them in a temporary folder?"
Browsers"Which browsers and devices must work? Safari needs a separate check."
Approval"May I edit on a branch in batches, showing each diff first?"

Raw SKILL.md file

.md

This is the exact file agents read, including the credit header and licence notice. Save it as keyboard-focus/SKILL.md.

---
name: keyboard-focus
description: "Audits and fixes keyboard focus in HTML and CSS: outlines removed with no replacement, positive tabindex, click handlers on divs, nested interactive controls, focus hidden under sticky headers or cookie banners (WCAG 2.2 SC 2.4.11), roving tabindex versus aria-activedescendant, skip links and keyboard traps. Runs a static checker and a real Tab-key walk. Use for tab order, focus ring and keyboard navigation bugs. Do NOT use for contrast ratios, ARIA names, screen reader testing or native dialog markup."
license: CC-BY-4.0
metadata:
  author: Afeez Adebayo
  author_url: https://afeezadebayo.com
  author_linkedin: https://www.linkedin.com/in/afeez-adebayo/
  author_x: https://x.com/za_icon
  source: https://afeezadebayo.com/skills-md/frontend-development/accessibility/keyboard-focus/
  category: frontend-development
  subcategory: accessibility
  version: "1.0.0"
  created: "2026-10-29"
  updated: "2026-10-29"
---

# Keyboard Focus: Fix Tab Order, Focus Rings and Hidden Focus

An agent works on keyboard focus in this order: run a read-only static checker over the HTML and CSS, walk the real page with the Tab key and read the numbers, fix the markup first (native elements, DOM order), then the focus indicator, then composite widgets, then anything that covers or loses focus, and re-run both tools. A static check cannot see scripts or the cascade, so the Tab walk is the proof.

Patterns and measurements are in `references/patterns.md` and `references/appendix.md`.

## When to use this skill, and when not to

Use it when keyboard users cannot reach a control, cannot see where focus is, lose focus after an action, or find focus behind a sticky header or banner.

| Request | Use instead |
|---|---|
| Contrast of the focus ring or text | `colour-contrast.md` |
| Accessible names, roles, `aria-hidden` on focusable content | `aria-names-roles.md` |
| Choosing `<button>` or `<a>`, headings, landmarks | `semantic-html.md` |
| Native `<dialog>` and popover markup, Esc, backdrop | `dialog-and-popover.md` |
| Form labels and error text | `html-forms.md` |
| Screen reader testing, switch devices, voice control | Out of scope. Needs a human with the device |

## Guardrails

1. **Propose, then change.** Report and plan first. Edit only after the user approves a batch, on a branch under version control. Never push, merge or deploy.
2. **Never delete focus code you do not understand.** Scripts that call `focus()`, set `tabindex` or trap focus can belong to a modal or a widget. Read the handler before changing it.
3. **Pages you own only.** The Tab walk opens a page in a browser. Do not run it on sites the user does not own. Never ask for session cookies, tokens or passwords. For a page behind a login, ask the user for a static HTML copy with test data.
4. **Data handled.** `kf_check.py` reads files. `tab_walk.js` prints counts, selectors and element names, never page text, form values or storage. Pages can contain personal data: do not request screenshots, and never paste page text into reports.
5. **Nothing is invented.** Every rule carries a source and a date. Version-dependent behaviour carries **verify before relying on this**.
6. **Not a conformance statement.** A clean run means the checked paths work. It is not a WCAG conformance claim and not legal advice. Any conformance claim needs a human reviewer who has used the page with a keyboard and a screen reader.
7. **Refuses to:** add positive `tabindex`, remove an outline with no replacement, use `aria-hidden` to hide focusable content, or report a fix as verified without the Tab walk.

## Inputs the agent must have before starting

Ask for every missing item. Do not assume.

| Input | Ask exactly |
|---|---|
| Files | "List the HTML templates and every stylesheet, in load order." |
| Page | "Which page may I open in a browser? Give a local file or a URL you own, with test data." |
| Widgets | "Which components take keys: menus, tabs, toolbars, comboboxes, carousels, modals?" |
| Sticky parts | "Which elements are sticky or fixed: header, cookie banner, chat button, bottom bar? What are their heights?" |
| Tooling | "Is Node and Playwright installed? If not, may I install them in a temporary folder?" |
| Browsers | "Which browsers and devices must work? Safari needs a separate check." |
| Approval | "May I edit on a branch in batches, showing each diff first?" |

## Workflow

### Step 0: Confirm scope

List templates, stylesheets and widgets. Templates in a framework (JSX, Vue, Twig) are checked on rendered HTML: ask for the output, not the source.

### Step 1: Run the static checker

```
python3 scripts/kf_check.py page.html styles.css | head -40
```

Read only its output. Do not read the script. The first line has the counts and the lines are sorted errors, warnings, info. `--strict` exits 1 on warnings.

| Code | Level | Meaning | Fix |
|---|---|---|---|
| `tabindex-positive` | error | `tabindex` above 0 overrides DOM order (APG advises against it) | Step 3 |
| `click-no-keyboard` | error | A click handler on an element that cannot take focus | Step 3 |
| `role-no-focus` | error | A button, link or tab role on an element that cannot take focus | Step 3 |
| `nested-interactive` | error | A control inside another control | Step 3 |
| `outline-removed` | error | `outline: none` with no `:focus` or `:focus-visible` replacement | Step 4 |
| `focus-shadow-only` | warn | The replacement is a box-shadow only, which forced-colours mode overrides | Step 4 |
| `sticky-no-scroll-padding` | warn | A sticky or fixed element and no `scroll-padding` in any file | Step 6 |
| `tabindex-no-role` | warn | `tabindex="0"` on a generic element with no role and no handler | Step 3 |
| `skip-target-missing` | error | A skip link points to an `id` that does not exist | Step 8 |
| `skip-link-missing` | info | Navigation before the content and no skip link | Step 8 |
| `focus-ring-thin` | info | Outline under 2px. Concerns AAA 2.4.13 only | Ignore at AA |
| `outline-removed-check` | info | A non-obvious selector removes an outline | Check the element takes focus |

The checker reads HTML and `<style>` blocks and CSS files you pass. It does not run scripts, so attributes added in JavaScript are invisible to it.

### Step 2: Walk the page with the Tab key

```
node scripts/tab_walk.js page.html 120
node scripts/tab_walk.js page.html 120 back
```

Needs Node and Playwright (`require('playwright')` must resolve). It presses Tab (or Shift+Tab with `back`) up to the cap, and prints one summary line plus problem lines. Read only that output.

| Output | Meaning | Action |
|---|---|---|
| `no-ring=N` | N stops where no outline, shadow, border, background, underline or colour changed on focus | Step 4. A ring drawn with a pseudo-element or filter can read as a false positive: confirm by eye |
| `obscured=N` | At those stops all five sample points of the focused element were covered by another element | Step 6 |
| `offscreen=N` | Focus landed outside the viewport | Check scroll handling |
| `unreachable=N` | Focusable elements never reached in one pass | Legitimate if hidden in a closed menu. Otherwise a missing `tabindex` or a trap |
| `capped=true` with `left-page=false` | The walk hit the cap without a cycle or leaving the page | Probable keyboard trap. Find the handler that cancels Tab |
| `stop N ... tabindex=3` | A positive `tabindex` | Step 3 |

Run forward and `back`. Browsers scroll a focused element into view in different ways by direction, and a covered element appears in only one of them.

### Step 3: Fix reachability and order

| Situation | Fix |
|---|---|
| `click-no-keyboard`, `role-no-focus` | Replace with `<button type="button">` for an action or `<a href>` for navigation (`semantic-html.md`). Last resort when the element cannot change, and only after approval: `tabindex="0"`, a role, and handlers for Enter and Space |
| `tabindex-positive` | Remove it. Fix the DOM order, or the CSS that reorders it visually. Use only `0` and `-1` |
| `tabindex-no-role` | Remove it, unless the element is a scrollable region that needs keyboard scrolling |
| `nested-interactive` | Make the controls siblings. One card with two actions needs two controls, not a button inside a link |
| Focus order differs from reading order | Reorder the DOM. Do not patch with `tabindex` |
| A control that must leave the tab sequence but stay focusable by script | `tabindex="-1"` |

### Step 4: Focus indicator

| Rule | Reason |
|---|---|
| Never `outline: none` without a replacement | MDN: removing focus styles makes keyboard navigation inaccessible to sighted users |
| Use `:focus-visible`, not `:focus`, for the ring | MDN: the browser decides when to show it. A mouse click on a button or link usually shows no ring, and text inputs show one after a click. Baseline widely available since March 2022 |
| Keep an outline as the main indicator | Soueidan: outlines survive forced-colours mode, box shadows and backgrounds do not |
| Ring contrast 3:1 against adjacent colours | W3C Understanding 1.4.11. Measure with `colour-contrast.md` |
| Two-colour ring on varied backgrounds | A dark line with a light halo. Recipe: `references/patterns.md` |
| AA does not need a 2px perimeter | AAA 2.4.13 does: a 2 CSS px thick perimeter at 3:1 (Soueidan). Use 2px or more anyway |
| Do not clip the ring | `overflow: hidden` on a parent hides outside rings. Use an inner outline with a negative offset there |

### Step 5: Composite widgets

| Widget | Pattern | Why |
|---|---|---|
| Toolbar, tab list, menu, radio-like group, grid | Roving tabindex | Real focus moves, so the browser scrolls the item into view (APG) |
| Combobox, autocomplete, listbox attached to a text field | `aria-activedescendant` | DOM focus must stay in the text field (APG) |
| Native controls (`<select>`, radio inputs, `<details>`) | Nothing. Use the native behaviour | Native keys already work |

These two rows are defaults derived from the APG text, which presents both patterns as valid. Follow the user's existing pattern if it works.

Rules for both (APG): exactly one item is in the tab sequence. Entry on Tab goes to the last focused, selected or first item, depending on the widget. Reset the entry item before focus leaves. A click or tap must set the same state as the arrow keys, or pointer and keyboard users end up in different places. Code: `references/patterns.md`. Arrow-key maps for each widget type are outside this skill: take them from the APG pattern for that widget.

### Step 6: Focus not obscured

WCAG 2.2 SC 2.4.11 (AA): the focused component must not be entirely hidden by author-created content. Partly visible passes. Sticky headers, sticky footers and non-modal dialogs are the usual causes (W3C Understanding 2.4.11).

| Case | Fix |
|---|---|
| Sticky header | `scroll-padding-top` on `html` (and `body` for Safari), in `rem`, equal to the header height (TetraLogical). Avoid `scroll-margin` |
| Bottom bar or cookie banner | `scroll-padding-bottom` while it exists, or make the banner modal, or close it when focus leaves (W3C) |
| Banner already covering a link that needs no scroll | Padding does nothing. Move the banner, add space at the page bottom or make it dismissible with the keyboard |
| Sticky height changes with content | A fixed value goes stale. Update it with a script and record that in the report |
| Skip link hidden behind the header | Give the focused skip link `z-index` above the header |

AAA 2.4.12 asks that no part is hidden. Do not claim it.

### Step 7: Focus after changes

| Event | Rule (APG) |
|---|---|
| A dialog closes | Return focus to the control that opened it (see `dialog-and-popover.md` for native dialogs) |
| A list item is deleted | Move focus to the following item, or the previous one if last |
| Content swaps without a page load | Move focus to the new content's heading or container only if the old focus target is gone |
| A modal opens | Focus moves inside. Background content is `inert`, not `aria-hidden` over focusable content (`aria-names-roles.md`) |

### Step 8: Skip link

First focusable element: `<a href="#main">Skip to main content</a>`, target `<main id="main">`. axe passes `bypass` with a main landmark or a skip link, so a missing skip link is info only. A skip link whose target `id` is missing is an error.

### Step 9: Verify and report

1. Re-run `kf_check.py`. Errors must be 0 and each warning fixed or listed with a reason.
2. Re-run `tab_walk.js` forward and `back`. `no-ring`, `obscured` and `unreachable` must be 0 or explained, and `capped=false`.
3. By hand: open and close every dialog and menu and check where focus lands. Press Shift+Tab through the same page.
4. Check Safari separately if it is a target. Do not claim it from Chromium.
5. State what is untested: screen readers, switch and voice control, zoom, touch.
6. Use the report template. Do not echo unchanged markup back.

## Worked example

An invented page of 14 lines of HTML and 5 of CSS: a sticky header, a skip-less nav, a div with `onclick`, a `span role="button"`, an `<a onclick>`, a button inside a link, an input with `tabindex="3"`, a focusable div, and `button, input { outline: none }`.

```
7 errors, 3 warnings, 3 info
page.css:2  error  outline-removed  button removes the outline and no :focus or :focus-visible replaces it (SC 2.4.7)
page.css:2  error  outline-removed  input removes the outline and no :focus or :focus-visible replaces it (SC 2.4.7)
page.html:11  error  tabindex-positive  <input tabindex="3"> overrides DOM order. APG advises against positive values.
page.html:13  error  nested-interactive  <button> inside <a> (line 13)
page.html:7  error  click-no-keyboard  <div> has a click handler and cannot take focus.
page.html:8  error  role-no-focus  role="button" on <span> is not keyboard reachable.
page.html:9  error  click-no-keyboard  <a> without href and tabindex is not focusable.
page.css:1  warn  sticky-no-scroll-padding  .site-header is sticky and no scroll-padding exists in any file
page.css:3  warn  focus-shadow-only  .menu:focus is replaced by box-shadow only ...
page.html:12  warn  tabindex-no-role  <div tabindex="0"> is focusable with no role and no handler.
```

(Messages shortened here. Three info lines follow.) The Tab walk prints `focusables=12 stops=12 cycle=12 left-page=true capped=false no-ring=3 obscured=0 offscreen=0 unreachable=0`, with the input (`tabindex=3`) first in the tab order.

One batch: the three fake buttons become `<button type="button">`, `tabindex="3"` and the panel `tabindex` go, the nested button becomes a link styled as a button, a skip link and `html { scroll-padding-top: 5rem }` are added, and the outline rules are replaced by `:focus-visible { outline: 3px solid #000; box-shadow: 0 0 0 6px #fff; }`. The re-run prints `0 errors, 0 warnings, 0 info`. The first Tab walk after the batch printed `obscured=1` with `stop 1 a.skip obscured-by a`: the skip link was painted under the sticky header's first link. Adding `z-index: 10` to the focused skip link gave `focusables=14 ... no-ring=0 obscured=0`. A separate page with a fixed 90px cookie banner printed `obscured=1 stop 2 a obscured-by div.banner`, which no static check can find.

## Templates

### Report

```
Keyboard focus report: <page or repo>, <date>
Files: <list>. Browser for the walk: <name, version>. Widgets: <list>.
kf_check before: N errors, N warnings. After: N errors, N warnings.
tab_walk forward before/after: no-ring N/N, obscured N/N, unreachable N/N, capped <true|false>.
tab_walk back before/after: <same fields>.
Changes: <file:line  issue  fix>   (one line each)
Left as is: <item  reason>
Untested: screen readers, switch and voice control, Safari, zoom, touch (edit to match)
Not a WCAG conformance claim. A human must test with a keyboard and a screen reader.
```

### Starting prompt

```
Use the keyboard-focus skill. Templates and stylesheets: <list>. Page I own for the Tab walk: <path or URL>.
Widgets with keys: <list>. Sticky or fixed elements and heights: <list>. Node and Playwright: <yes|no>.
Edit on a branch in batches of one component or 20 declarations and show each diff first.
```

## Failure modes and anti-patterns

| Mistake | How to spot it | Do instead |
|---|---|---|
| `outline: none` on `*`, `button` or `a` | `outline-removed`, or `no-ring` in the walk | A `:focus-visible` ring (step 4) |
| Positive `tabindex` to fix order | `tabindex-positive`, a numbered stop first in the walk | Reorder the DOM (step 3) |
| A `div` made clickable | `click-no-keyboard`, `role-no-focus` | A native `<button>` or `<a href>` |
| Sticky header or banner hiding focus | `sticky-no-scroll-padding`, `obscured` in the walk | `scroll-padding` or a modal banner (step 6) |
| Roving tabindex that ignores clicks | After a click, Shift+Tab then Tab lands on a different item | Set the same state on click (step 5) |

## Quality bar

Run every item before handing over.

1. `kf_check.py` errors are 0, and every warning and info line is fixed or listed with a reason.
2. `tab_walk.js` ran forward and `back` on the changed page, with `capped=false`.
3. No stop has `no-ring`, `obscured` or an unexplained `unreachable`.
4. No positive `tabindex` remains.
5. Every click handler sits on a native control.
6. Every widget has one tab stop, and click sets the same state as the arrow keys.
7. Focus after closing a dialog and deleting an item was checked by hand.
8. The report lists what was not tested and states it is not a WCAG conformance claim.

## Cost

Figures are estimates from this skill's own count (words × 1.35 for prose, characters ÷ 3.5 for code and tables), not from a model tokeniser. Rival figures are estimates from the visible text of pages I read.

| Item | Tokens | Status | Read condition |
|---|---|---|---|
| A. Description (always loaded) | 105 | Estimate | Every session with the skill installed |
| B. `SKILL.md` body | 4,943 | Estimate | When the skill fires |
| C. `references/appendix.md` | 1,298 | Estimate | Only for measured values, axe rule overlaps or source notes |
| C. `references/patterns.md` | 964 | Estimate | Step 5 widget code, the focus ring recipe or skip link code |
| Script output (typical run) | 600 | Estimate | Checker output measured on the worked example, plus the stated extra |
| D. Typical run (A + B + script output) | 5,648 | Estimate | Common path, no references |
| D. Worst case (A + B + all references + 40 lines of output + extra) | 9,027 | Estimate | Large input, every branch |
| E. Cheaper path | 5,568 | Estimate | Run the checker, fix flagged lines, skip the Tab walk detail and the references |

Closest rivals read about 450 to 3,100 tokens (estimates from visible text), but none ships a checker or a Tab walk. This skill costs more to load, and in return it measures focus instead of asking the agent to judge it.

## Handoffs

`colour-contrast.md` measures the ring. `aria-names-roles.md` covers names and `aria-hidden`. `semantic-html.md` covers native elements. `dialog-and-popover.md` covers native modals and focus return.

## Appendix

Measurements, axe rules and source notes are in `references/appendix.md`. Code is in `references/patterns.md`. Read them only when a step points there.

## Sources

Last verified 29 October 2026 for every source.

- Anthropic, Skill authoring best practices: https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices
- W3C, Understanding SC 2.4.11 Focus Not Obscured (Minimum): https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html
- W3C, WAI-ARIA Authoring Practices, Keyboard interface: https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/
- MDN, `:focus-visible`: https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible
- TetraLogical, Sticky content: focus in view (8 June 2023): https://tetralogical.com/blog/2023/06/08/focus-in-view/
- Sara Soueidan, A guide to designing accessible, WCAG-conformant focus indicators: https://sarasoueidan.com/blog/focus-indicators/
- Deque, axe-core rule descriptions (4.13): https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md
- owl-listener, keyboard-review. Rival implementation: https://skillselion.com/skills/owl-listener/inclusive-design-skills/keyboard-review
- mae616, accessibility-engineer. Rival implementation: https://playbooks.com/skills/mae616/design-skills/accessibility-engineer
- OpenDesign, accessibility. Rival implementation: https://open-design.ai/skills/accessibility/

---
Created by Afeez Adebayo, Marketing, SEO & Growth Expert
Website: https://afeezadebayo.com
LinkedIn: https://www.linkedin.com/in/afeez-adebayo/
X: https://x.com/za_icon
Source: https://afeezadebayo.com/skills-md/frontend-development/accessibility/keyboard-focus/

Licence: CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/). You may copy, share and adapt this skill, including for commercial use, as long as you credit Afeez Adebayo and link to the source URL above. Keep this notice in any copy or adapted version.

Workflow

Step 0: Confirm scope

List templates, stylesheets and widgets. Templates in a framework (JSX, Vue, Twig) are checked on rendered HTML: ask for the output, not the source.

Step 1: Run the static checker

python3 scripts/kf_check.py page.html styles.css | head -40

Read only its output. Do not read the script. The first line has the counts and the lines are sorted errors, warnings, info. --strict exits 1 on warnings.

CodeLevelMeaningFix
tabindex-positiveerrortabindex above 0 overrides DOM order (APG advises against it)Step 3
click-no-keyboarderrorA click handler on an element that cannot take focusStep 3
role-no-focuserrorA button, link or tab role on an element that cannot take focusStep 3
nested-interactiveerrorA control inside another controlStep 3
outline-removederroroutline: none with no :focus or :focus-visible replacementStep 4
focus-shadow-onlywarnThe replacement is a box-shadow only, which forced-colours mode overridesStep 4
sticky-no-scroll-paddingwarnA sticky or fixed element and no scroll-padding in any fileStep 6
tabindex-no-rolewarntabindex="0" on a generic element with no role and no handlerStep 3
skip-target-missingerrorA skip link points to an id that does not existStep 8
skip-link-missinginfoNavigation before the content and no skip linkStep 8
focus-ring-thininfoOutline under 2px. Concerns AAA 2.4.13 onlyIgnore at AA
outline-removed-checkinfoA non-obvious selector removes an outlineCheck the element takes focus

The checker reads HTML and <style> blocks and CSS files you pass. It does not run scripts, so attributes added in JavaScript are invisible to it.

Step 2: Walk the page with the Tab key

node scripts/tab_walk.js page.html 120
node scripts/tab_walk.js page.html 120 back

Needs Node and Playwright (require('playwright') must resolve). It presses Tab (or Shift+Tab with back) up to the cap, and prints one summary line plus problem lines. Read only that output.

OutputMeaningAction
no-ring=NN stops where no outline, shadow, border, background, underline or colour changed on focusStep 4. A ring drawn with a pseudo-element or filter can read as a false positive: confirm by eye
obscured=NAt those stops all five sample points of the focused element were covered by another elementStep 6
offscreen=NFocus landed outside the viewportCheck scroll handling
unreachable=NFocusable elements never reached in one passLegitimate if hidden in a closed menu. Otherwise a missing tabindex or a trap
capped=true with left-page=falseThe walk hit the cap without a cycle or leaving the pageProbable keyboard trap. Find the handler that cancels Tab
stop N ... tabindex=3A positive tabindexStep 3

Run forward and back. Browsers scroll a focused element into view in different ways by direction, and a covered element appears in only one of them.

Step 3: Fix reachability and order

SituationFix
click-no-keyboard, role-no-focusReplace with <button type="button"> for an action or <a href> for navigation (semantic-html.md). Last resort when the element cannot change, and only after approval: tabindex="0", a role, and handlers for Enter and Space
tabindex-positiveRemove it. Fix the DOM order, or the CSS that reorders it visually. Use only 0 and -1
tabindex-no-roleRemove it, unless the element is a scrollable region that needs keyboard scrolling
nested-interactiveMake the controls siblings. One card with two actions needs two controls, not a button inside a link
Focus order differs from reading orderReorder the DOM. Do not patch with tabindex
A control that must leave the tab sequence but stay focusable by scripttabindex="-1"

Step 4: Focus indicator

RuleReason
Never outline: none without a replacementMDN: removing focus styles makes keyboard navigation inaccessible to sighted users
Use :focus-visible, not :focus, for the ringMDN: the browser decides when to show it. A mouse click on a button or link usually shows no ring, and text inputs show one after a click. Baseline widely available since March 2022
Keep an outline as the main indicatorSoueidan: outlines survive forced-colours mode, box shadows and backgrounds do not
Ring contrast 3:1 against adjacent coloursW3C Understanding 1.4.11. Measure with colour-contrast.md
Two-colour ring on varied backgroundsA dark line with a light halo. Recipe: references/patterns.md
AA does not need a 2px perimeterAAA 2.4.13 does: a 2 CSS px thick perimeter at 3:1 (Soueidan). Use 2px or more anyway
Do not clip the ringoverflow: hidden on a parent hides outside rings. Use an inner outline with a negative offset there

Step 5: Composite widgets

WidgetPatternWhy
Toolbar, tab list, menu, radio-like group, gridRoving tabindexReal focus moves, so the browser scrolls the item into view (APG)
Combobox, autocomplete, listbox attached to a text fieldaria-activedescendantDOM focus must stay in the text field (APG)
Native controls (<select>, radio inputs, <details>)Nothing. Use the native behaviourNative keys already work

These two rows are defaults derived from the APG text, which presents both patterns as valid. Follow the user's existing pattern if it works.

Rules for both (APG): exactly one item is in the tab sequence. Entry on Tab goes to the last focused, selected or first item, depending on the widget. Reset the entry item before focus leaves. A click or tap must set the same state as the arrow keys, or pointer and keyboard users end up in different places. Code: references/patterns.md. Arrow-key maps for each widget type are outside this skill: take them from the APG pattern for that widget.

Step 6: Focus not obscured

WCAG 2.2 SC 2.4.11 (AA): the focused component must not be entirely hidden by author-created content. Partly visible passes. Sticky headers, sticky footers and non-modal dialogs are the usual causes (W3C Understanding 2.4.11).

CaseFix
Sticky headerscroll-padding-top on html (and body for Safari), in rem, equal to the header height (TetraLogical). Avoid scroll-margin
Bottom bar or cookie bannerscroll-padding-bottom while it exists, or make the banner modal, or close it when focus leaves (W3C)
Banner already covering a link that needs no scrollPadding does nothing. Move the banner, add space at the page bottom or make it dismissible with the keyboard
Sticky height changes with contentA fixed value goes stale. Update it with a script and record that in the report
Skip link hidden behind the headerGive the focused skip link z-index above the header

AAA 2.4.12 asks that no part is hidden. Do not claim it.

Step 7: Focus after changes

EventRule (APG)
A dialog closesReturn focus to the control that opened it (see dialog-and-popover.md for native dialogs)
A list item is deletedMove focus to the following item, or the previous one if last
Content swaps without a page loadMove focus to the new content's heading or container only if the old focus target is gone
A modal opensFocus moves inside. Background content is inert, not aria-hidden over focusable content (aria-names-roles.md)

First focusable element: <a href="#main">Skip to main content</a>, target <main id="main">. axe passes bypass with a main landmark or a skip link, so a missing skip link is info only. A skip link whose target id is missing is an error.

Step 9: Verify and report

  1. Re-run kf_check.py. Errors must be 0 and each warning fixed or listed with a reason.
  2. Re-run tab_walk.js forward and back. no-ring, obscured and unreachable must be 0 or explained, and capped=false.
  3. By hand: open and close every dialog and menu and check where focus lands. Press Shift+Tab through the same page.
  4. Check Safari separately if it is a target. Do not claim it from Chromium.
  5. State what is untested: screen readers, switch and voice control, zoom, touch.
  6. Use the report template. Do not echo unchanged markup back.

Worked example

An invented page of 14 lines of HTML and 5 of CSS: a sticky header, a skip-less nav, a div with onclick, a span role="button", an <a onclick>, a button inside a link, an input with tabindex="3", a focusable div, and button, input { outline: none }.

7 errors, 3 warnings, 3 info
page.css:2  error  outline-removed  button removes the outline and no :focus or :focus-visible replaces it (SC 2.4.7)
page.css:2  error  outline-removed  input removes the outline and no :focus or :focus-visible replaces it (SC 2.4.7)
page.html:11  error  tabindex-positive  <input tabindex="3"> overrides DOM order. APG advises against positive values.
page.html:13  error  nested-interactive  <button> inside <a> (line 13)
page.html:7  error  click-no-keyboard  <div> has a click handler and cannot take focus.
page.html:8  error  role-no-focus  role="button" on <span> is not keyboard reachable.
page.html:9  error  click-no-keyboard  <a> without href and tabindex is not focusable.
page.css:1  warn  sticky-no-scroll-padding  .site-header is sticky and no scroll-padding exists in any file
page.css:3  warn  focus-shadow-only  .menu:focus is replaced by box-shadow only ...
page.html:12  warn  tabindex-no-role  <div tabindex="0"> is focusable with no role and no handler.

(Messages shortened here. Three info lines follow.) The Tab walk prints focusables=12 stops=12 cycle=12 left-page=true capped=false no-ring=3 obscured=0 offscreen=0 unreachable=0, with the input (tabindex=3) first in the tab order.

One batch: the three fake buttons become <button type="button">, tabindex="3" and the panel tabindex go, the nested button becomes a link styled as a button, a skip link and html { scroll-padding-top: 5rem } are added, and the outline rules are replaced by :focus-visible { outline: 3px solid #000; box-shadow: 0 0 0 6px #fff; }. The re-run prints 0 errors, 0 warnings, 0 info. The first Tab walk after the batch printed obscured=1 with stop 1 a.skip obscured-by a: the skip link was painted under the sticky header's first link. Adding z-index: 10 to the focused skip link gave focusables=14 ... no-ring=0 obscured=0. A separate page with a fixed 90px cookie banner printed obscured=1 stop 2 a obscured-by div.banner, which no static check can find.

Templates

Report

Keyboard focus report: <page or repo>, <date>
Files: <list>. Browser for the walk: <name, version>. Widgets: <list>.
kf_check before: N errors, N warnings. After: N errors, N warnings.
tab_walk forward before/after: no-ring N/N, obscured N/N, unreachable N/N, capped <true|false>.
tab_walk back before/after: <same fields>.
Changes: <file:line  issue  fix>   (one line each)
Left as is: <item  reason>
Untested: screen readers, switch and voice control, Safari, zoom, touch (edit to match)
Not a WCAG conformance claim. A human must test with a keyboard and a screen reader.

Starting prompt

Use the keyboard-focus skill. Templates and stylesheets: <list>. Page I own for the Tab walk: <path or URL>.
Widgets with keys: <list>. Sticky or fixed elements and heights: <list>. Node and Playwright: <yes|no>.
Edit on a branch in batches of one component or 20 declarations and show each diff first.

Failure modes and anti-patterns

MistakeHow to spot itDo instead
outline: none on *, button or aoutline-removed, or no-ring in the walkA :focus-visible ring (step 4)
Positive tabindex to fix ordertabindex-positive, a numbered stop first in the walkReorder the DOM (step 3)
A div made clickableclick-no-keyboard, role-no-focusA native <button> or <a href>
Sticky header or banner hiding focussticky-no-scroll-padding, obscured in the walkscroll-padding or a modal banner (step 6)
Roving tabindex that ignores clicksAfter a click, Shift+Tab then Tab lands on a different itemSet the same state on click (step 5)

Quality bar

Run every item before handing over.

  1. kf_check.py errors are 0, and every warning and info line is fixed or listed with a reason.
  2. tab_walk.js ran forward and back on the changed page, with capped=false.
  3. No stop has no-ring, obscured or an unexplained unreachable.
  4. No positive tabindex remains.
  5. Every click handler sits on a native control.
  6. Every widget has one tab stop, and click sets the same state as the arrow keys.
  7. Focus after closing a dialog and deleting an item was checked by hand.
  8. The report lists what was not tested and states it is not a WCAG conformance claim.

Cost

Figures are estimates from this skill's own count (words × 1.35 for prose, characters ÷ 3.5 for code and tables), not from a model tokeniser. Rival figures are estimates from the visible text of pages I read.

ItemTokensStatusRead condition
A. Description (always loaded)105EstimateEvery session with the skill installed
B. SKILL.md body4,943EstimateWhen the skill fires
C. references/appendix.md1,298EstimateOnly for measured values, axe rule overlaps or source notes
C. references/patterns.md964EstimateStep 5 widget code, the focus ring recipe or skip link code
Script output (typical run)600EstimateChecker output measured on the worked example, plus the stated extra
D. Typical run (A + B + script output)5,648EstimateCommon path, no references
D. Worst case (A + B + all references + 40 lines of output + extra)9,027EstimateLarge input, every branch
E. Cheaper path5,568EstimateRun the checker, fix flagged lines, skip the Tab walk detail and the references

Closest rivals read about 450 to 3,100 tokens (estimates from visible text), but none ships a checker or a Tab walk. This skill costs more to load, and in return it measures focus instead of asking the agent to judge it.

Handoffs

colour-contrast.md measures the ring. aria-names-roles.md covers names and aria-hidden. semantic-html.md covers native elements. dialog-and-popover.md covers native modals and focus return.

Appendix

Measurements, axe rules and source notes are in references/appendix.md. Code is in references/patterns.md. Read them only when a step points there.

Sources

Last verified 29 October 2026 for every source.

What this skill does

WebAIM’s 2026 survey of one million home pages (testing February 2026, WAVE API) found WCAG 2 failures on 95.9% of them, and pages that used ARIA averaged 59.1 detected errors against 42 for pages without it. Automated checks also miss a whole class of keyboard faults, such as focus covered by a sticky header, so a clean report proves little until someone presses Tab.

The skill runs scripts/kf_check.py over your HTML and CSS. It flags positive tabindex, click handlers on elements that cannot take focus, controls nested inside controls, outlines removed with no replacement, and sticky elements with no scroll-padding. Then scripts/tab_walk.js opens a page you own in Chromium, presses Tab and Shift+Tab, and prints counts: stops with no visible focus change, stops covered by another element, unreachable controls and likely keyboard traps. The agent fixes markup first, then the focus indicator, then widgets, and edits only in batches you approve.

Who it is for

Use it if you are a frontend developer, a theme builder or a QA tester, or if you review interfaces an AI tool generated. It fits when a menu cannot be reached with the keyboard, the focus ring has disappeared, focus lands behind a sticky header or cookie banner, or a custom widget needs arrow-key support. It does not cover contrast ratios, accessible names or screen reader testing.

What is inside

The file is 3,347 words (version 1.0.0) with two scripts and two reference files. It holds:

  • Seven guardrails, including editing only on a branch after approval, running the Tab walk only on pages you own and never claiming WCAG conformance.
  • Seven inputs the agent must have first, with the exact question for each.
  • A workflow of steps 0 to 9, with a table that maps each checker code to a fix.
  • Two tools: scripts/kf_check.py, a standard-library checker with twelve problem codes, and scripts/tab_walk.js, a Node and Playwright walk that prints counts and selectors but no page text.
  • A worked example with real output (7 errors and 3 warnings before, 0 after), including a skip link the Tab walk found hidden under a sticky header.
  • A report template, a starting prompt, five failure modes and an eight-item quality bar.
  • Two reference files: widget, skip link and scroll-padding code, and an appendix with measured values and axe rule overlaps.

The token costs below are estimates from the skill’s own count: words times 1.35 for prose and characters divided by 3.5 for code and tables. They are not from a model tokeniser.

ItemTokensStatus
Description (always loaded)105Estimate
SKILL.md body (when the skill fires)4,943Estimate
references/appendix.md, read only for measured values, axe rule overlaps or source notes1,298Estimate
references/patterns.md, read only for widget code, the focus ring recipe or skip link code964Estimate
Checker and Tab walk output, typical run600Estimate
Typical run (description, body and script output)5,648Estimate
Cheaper path (checker only, no references)5,568Estimate
Worst case (all references and 40 lines of checker output)9,027Estimate

How to install and use it in Claude, Cursor and other agents

Claude. Keep the folder as keyboard-focus/ with SKILL.md, scripts/ and references/ inside, and zip it. In Claude, open Customize, then Skills, then Upload a skill. In Claude Code, put the folder in ~/.claude/skills/ or .claude/skills/ in a project.

Cursor and other agents. Put the folder where your tool reads skills, or paste the body into its rules and keep the scripts beside your code. The Tab walk needs Node and Playwright. Then send the starting prompt from the Templates section.

Before you install. Read the scripts first. The checker reads files and writes nothing. The Tab walk opens a page you own and prints selectors and numbers, never page text. The skill tells the agent to edit only on a branch in batches you approve. Install skills only from sources you trust.

A worked example

An invented page has a sticky header, a div with onclick, a span with role="button", a button inside a link, an input with tabindex="3" and a stylesheet that removes every outline. The checker reports 7 errors, 3 warnings and 3 info lines, and the Tab walk shows 3 stops with no visible focus change.

One batch replaces the fake buttons with button elements, drops the positive tabindex, adds a skip link and scroll-padding-top, and restores a :focus-visible ring. The checker then prints 0 errors and 0 warnings. The first re-walk still reported one stop covered by another element: the skip link sat under the header, and a z-index fixed it. A fixed cookie banner on a second page produced the same warning, which no static check can find.

Common mistakes

The skill is built around five ways this goes wrong:

  • Removing the outline. outline: none with no replacement leaves keyboard users blind. The skill requires a :focus-visible ring.
  • Fixing order with positive tabindex. It overrides the DOM and breaks as the page grows. The skill reorders the markup instead.
  • Making a div clickable. It cannot take focus or respond to Enter. The skill swaps in a native button or link.
  • Ignoring sticky content. Focus slides under a header or banner. The skill sets scroll-padding and confirms with the walk.
  • Roving tabindex that ignores clicks. Pointer and keyboard users end up on different items. The skill sets the same state on click.

Colour Contrast measures whether the focus ring itself is visible enough. Dialog and Popover covers native modals and where focus returns. Semantic HTML covers the native buttons and links that make most of these fixes unnecessary.

Frequently asked questions

Focus must be visible under success criterion 2.4.7, and under 2.4.11 the focused component must not be entirely hidden by author-created content such as a sticky header, according to the W3C. A partly visible element passes at AA. The stricter AAA criteria ask for no hidden part and a 2px indicator. The skill checks the AA rules.

Only with a replacement. MDN says removing focus styles makes keyboard navigation inaccessible to sighted users. The skill flags any outline: none that has no matching :focus or :focus-visible rule, and recommends an outline over a box-shadow because outlines survive forced-colours mode, as Sara Soueidan explains.

The W3C Authoring Practices present both as valid. The skill defaults to roving tabindex when items need real DOM focus, such as toolbars and tab lists, and to aria-activedescendant when focus must stay in a text field, such as a combobox. If your existing pattern works, it keeps it.

A value above 0 pulls an element ahead of the natural DOM order, and the W3C Authoring Practices strongly discourage it. Every new control then needs a number, and the order drifts from the visual layout. The checker reports it as an error, and the skill fixes the markup order instead.

Set scroll-padding-top on html, in rem, equal to the header height, as TetraLogical advises, and avoid scroll-margin. A fixed banner that already covers a link needs moving or closing, not padding. The skill proposes the fix, then re-runs the Tab walk to confirm no stop is covered.

No. A clean run means the checked paths work in Chromium. It is not a WCAG conformance claim. The skill tells the agent to list what it did not test, including screen readers, switch and voice control, Safari and touch, and it asks a human reviewer to try the page.

The description is about 105 tokens and loads in every session. The SKILL.md body is about 4,943 tokens when the skill fires. A typical run is about 5,648 tokens with checker and Tab walk output, the cheaper path is about 5,568, and the worst case is about 9,027. All figures are estimates, not from a model tokeniser.

Changelog

  • 1.0.0, 29 October 2026: first release.

Use, adapt and share this skill under CC BY 4.0: credit Afeez Adebayo and link to this page.