Give it a try
-
To see your bookmarks bar, press the keys
(Ctrl/Cmd + Shift + B). - Drag the "SpotCheck - Tab Order Visualizer" link to your bookmarks bar or bookmark folder.
- Open any page, then click the bookmark to run it.
- Click it again or click the close button in the popup to clear the results.
Example of the popup that will appear in the bottom right corner of your browser with a summary of the results.
What the colors mean
A numbered badge is added to every focusable element showing its position in tab order.
The element is reached through natural document order (tabindex of 0 or none).
Reserved for elements that are focusable but need a closer look, such as ones with roles that imply interactivity.
The element uses a positive tabindex, which forces it ahead of the natural order and usually creates a confusing tab sequence.
Try it here
Run the check against the sample markup below without leaving this page.
Applicable WCAG 2.2 success criteria
Keyboard users rely on tab order matching the visual and logical order of the page; anything else forces them to guess where focus will land next.
-
2.4.3
Focus Order(Level A)
Focusable components need to receive focus in an order that preserves meaning and operability.
-
2.1.1
Keyboard(Level A)
Every interactive element that appears in the tab order must actually be operable from a keyboard.
-
2.4.7
Focus Visible(Level AA)
As focus moves through the tab sequence, there needs to be a visible indicator of which element currently has it.
Read the source
The bookmarklet contains the TL;DR version of the code. Here's the readable version.
(function () {
var NS = '__a11yTabOrder';
if (window[NS] && window[NS].active) {
window[NS].teardown();
return;
}
var SEL = 'a[href],button,input,select,textarea,details,[tabindex],[contenteditable="true"]';
var REPLACED = { INPUT: 1, TEXTAREA: 1, SELECT: 1, IMG: 1 };
function isVisible(el) {
var style = getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
var rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function isFocusable(el) {
if (el.hasAttribute('disabled')) return false;
if (el.tabIndex < 0) return false;
if (el.tagName === 'INPUT' && el.type === 'hidden') return false;
return isVisible(el);
}
var all = Array.prototype.filter.call(document.querySelectorAll(SEL), isFocusable);
var positive = all.filter(function (el) { return el.tabIndex > 0; })
.sort(function (a, b) { return a.tabIndex - b.tabIndex; });
var natural = all.filter(function (el) { return el.tabIndex <= 0; });
// natural order follows DOM order already since querySelectorAll returns document order
var ordered = positive.concat(natural);
var records = [];
var counts = { green: 0, gold: 0, red: 0 };
// Form controls, images, etc. are "replaced elements": browsers don't
// render light-DOM children appended into them. For those, badges are
// appended to <body> instead and positioned to line up with the element.
function attachBadge(el, badge, corner) {
if (!REPLACED[el.tagName]) {
el.appendChild(badge);
return badge;
}
document.body.appendChild(badge);
var rect = el.getBoundingClientRect();
var badgeRect = badge.getBoundingClientRect();
var top = corner ? rect.top - 12 : rect.top - badgeRect.height;
var left = corner ? rect.left - 12 : rect.left;
badge.style.position = 'fixed';
badge.style.top = top + 'px';
badge.style.left = left + 'px';
return badge;
}
function mark(el, order, level) {
records.push({ el: el, outline: el.style.outline, outlineOffset: el.style.outlineOffset, position: el.style.position, badge: null, noteBadge: null });
var color = level === 'green' ? '#1a7d4f' : level === 'gold' ? '#8b6800' : '#be412a';
el.style.outline = '3px solid ' + color;
el.style.outlineOffset = '2px';
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
var badge = document.createElement('span');
badge.textContent = order;
badge.setAttribute('aria-hidden', 'true');
badge.style.cssText = [
'position:absolute', 'top:-12px', 'left:-12px',
'background:' + color, 'color:#fff', 'font:700 14px monospace',
'min-width:22px', 'height:22px', 'line-height:22px', 'text-align:center',
'border-radius:50%', 'z-index:2147483000', 'pointer-events:none'
].join(';');
attachBadge(el, badge, true);
records[records.length - 1].badge = badge;
if (level === 'red') {
var noteBadge = document.createElement('span');
noteBadge.textContent = 'tabindex=' + el.tabIndex + ', positive tabindex';
noteBadge.setAttribute('aria-hidden', 'true');
noteBadge.style.cssText = [
'position:absolute', 'top:0', 'left:0', 'transform:translateY(-100%)',
'background:' + color, 'color:#fff', 'font:600 14px monospace',
'padding:1px 5px', 'border-radius:3px 3px 0 0', 'z-index:2147483000',
'pointer-events:none', 'white-space:nowrap'
].join(';');
if (REPLACED[el.tagName]) {
attachBadge(el, noteBadge, false);
} else {
el.insertBefore(noteBadge, el.firstChild);
}
records[records.length - 1].noteBadge = noteBadge;
}
counts[level]++;
}
ordered.forEach(function (el, i) {
var level = el.tabIndex > 0 ? 'red' : 'green';
mark(el, i + 1, level);
});
var panel = document.createElement('div');
panel.setAttribute('data-a11y-bm-panel', 'tab-order');
panel.setAttribute('role', 'status');
panel.setAttribute('aria-live', 'polite');
panel.style.cssText = [
'position:fixed', 'bottom:16px', 'right:16px', 'z-index:2147483001',
'background:#1b2430', 'color:#e7eaed', 'font:13px/1.5 -apple-system,sans-serif',
'padding:12px 32px 12px 16px', 'border-radius:6px', 'box-shadow:0 4px 16px rgba(0,0,0,.3)',
'max-width:260px'
].join(';');
panel.innerHTML =
'<strong style="display:block;margin-bottom:4px;">Tab Order Visualizer</strong>' +
'<span style="color:#3fbf7f;">●</span> ' + counts.green + ' natural order ' +
'<span style="color:#e06a4f;">●</span> ' + counts.red + ' positive tabindex';
var closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.textContent = '\u00d7';
closeBtn.setAttribute('aria-label', 'Close');
closeBtn.style.cssText = [
'position:absolute', 'top:4px', 'right:6px',
'background:transparent', 'border:none', 'color:#e7eaed',
'font:16px/1 -apple-system,sans-serif', 'cursor:pointer',
'padding:2px 6px', 'border-radius:4px', 'line-height:1'
].join(';');
closeBtn.addEventListener('click', function () { window[NS].teardown(); });
panel.appendChild(closeBtn);
document.body.appendChild(panel);
function teardown() {
records.forEach(function (r) {
r.el.style.outline = r.outline;
r.el.style.outlineOffset = r.outlineOffset;
r.el.style.position = r.position;
if (r.badge && r.badge.parentNode) r.badge.parentNode.removeChild(r.badge);
if (r.noteBadge && r.noteBadge.parentNode) r.noteBadge.parentNode.removeChild(r.noteBadge);
});
if (panel.parentNode) panel.parentNode.removeChild(panel);
}
window[NS] = {
active: true,
teardown: function () {
teardown();
window[NS].active = false;
}
};
})();
FYI
-
A positive
tabindexis a code smell. It's better to reorder the markup than to renumber focus withtabindex. -
tabindex="-1"is intentionally excluded, since those elements are focusable only via script and never appear in the Tab sequence. - This tool checks order, not visibility, so pair it with a manual keyboard walkthrough to confirm the focus indicator itself is visible at every stop.