Focus order must follow the visual order
When the Tab key jumps from the header to the footer and back to a product, keyboard and screen reader users lose track of where they are, miss options and struggle to fill in forms in the right order.
- Rule id
- vs-focus-order
- Source
- Validiscan check
- Impact
- Moderate
- Fix tier
- MANUAL
- on Shopify, Shopware, WooCommerce and Magento
- Status
- runs
What the check does
Compares the Tab sequence with the visual layout and flags jumps where focus moves in an order that does not match the screen, for example because of positive tabindex values or content repositioned with CSS (2.4.3).
The check collects up to 300 visible, keyboard-reachable elements and their position on the page. It works out the tab order from the markup rather than by pressing Tab: positive tabindex values first, ascending, then document order. It runs at every scan viewport, by default 1440 and 390 px wide.
Every element with a positive tabindex is a finding. For steps between elements without one, the check compares each element with the one before it in the tab order. When its bottom edge sits more than 100 px above the previous element’s top edge, focus jumps back up the page, and the step goes to needs review, at most 25 per page. CSS that reorders content, such as flex order, grid placement or absolute positioning, can cause such a jump, and a person has to decide whether the order still makes sense. The scanner does not turn needs review into findings today, so only positive tabindex values produce findings. Jumps sideways or down the page are not measured.
How to fix it
Make the source order of the templates match the visual order, remove positive tabindex values, and avoid reordering content with CSS order, flex-direction or absolute positioning where it changes the sequence people read and tab through.
WCAG success criteria
- 2.4.3 Focus Order, level A
EN 301 549 clauses
| WCAG criterion | Version 3.2.1 | Version 4.1.1 |
|---|---|---|
| 2.4.3 | 9.2.4.3 | 9.2.4.3 |
Recipes that list this rule
This rule has the tier MANUAL. In a project, a finding of it takes the MANUAL way, and these recipes are not applied to it.
Shopify: recipe remove-positive-tabindex
Show the before and after (1)
sections/header.liquid Changes to sections/header.liquid: 1 added, 1 removed
| Line before | Line after | Change | Code |
|---|---|---|---|
| 37 | 37 | ||
| 38 | 38 | <div class="header__search"> | |
| 39 | 39 | <form action="{{ routes.search_url }}" method="get" role="search" class="search"> | |
| 40 | Removed | <input class="search__input field__input" type="search" name="q" value="" placeholder="{{ 'general.search.placeholder' | t }}" tabindex="1"> | |
| 40 | Added | <input class="search__input field__input" type="search" name="q" value="" placeholder="{{ 'general.search.placeholder' | t }}"> | |
| 41 | 41 | <button type="submit" class="search__button"> | |
| 42 | 42 | {%- render 'icon', icon: 'search', size: 20 -%} | |
| 43 | 43 | </button> |
Shopware: recipe remove-positive-tabindex
Show the before and after (1)
Resources/views/storefront/page/product-detail/buy-widget.html.twig Changes to Resources/views/storefront/page/product-detail/buy-widget.html.twig: 1 added, 1 removed
| Line before | Line after | Change | Code |
|---|---|---|---|
| 82 | 82 | </div> | |
| 83 | 83 | <div class="buy-widget-submit"> | |
| 84 | 84 | <button class="btn btn-primary btn-buy" | |
| 85 | Removed | title="{{ 'detail.addProduct'|trans }}" tabindex="1"> | |
| 85 | Added | title="{{ 'detail.addProduct'|trans }}"> | |
| 86 | 86 | {{ 'detail.addProduct'|trans }} | |
| 87 | 87 | </button> | |
| 88 | 88 | </div> |
WooCommerce: recipe remove-positive-tabindex
Show the before and after (1)
header.php Changes to header.php: 1 added, 1 removed
| Line before | Line after | Change | Code |
|---|---|---|---|
| 49 | 49 | <form role="search" method="get" class="woocommerce-product-search" action="<?php echo esc_url( home_url( '/search' ) ); ?>"> | |
| 50 | 50 | <label class="screen-reader-text" for="woocommerce-product-search-field-0"><?php esc_html_e( 'Search for:', 'woocommerce' ); ?></label> | |
| 51 | 51 | <?php // VALIDISCAN-SEED vs-focus-order + tabindex: tabindex="1" "so the search is always the first stop". ?> | |
| 52 | Removed | <input type="search" id="woocommerce-product-search-field-0" class="search-field" placeholder="<?php echo esc_attr__( 'Search products…', 'woocommerce' ); ?>" value="<?php echo get_search_query(); ?>" name="s" tabindex="1"> | |
| 52 | Added | <input type="search" id="woocommerce-product-search-field-0" class="search-field" placeholder="<?php echo esc_attr__( 'Search products…', 'woocommerce' ); ?>" value="<?php echo get_search_query(); ?>" name="s"> | |
| 53 | 53 | <?php // VALIDISCAN-SEED button-name: icon-only submit button without text. ?> | |
| 54 | 54 | <button type="submit" value="<?php echo esc_attr_x( 'Search', 'submit button', 'woocommerce' ); ?>" class="kb-search-submit"><?php kb_icon( 'search' ); ?></button> | |
| 55 | 55 | <input type="hidden" name="post_type" value="product"> |
Magento: recipe remove-positive-tabindex
Show the before and after (1)
app/design/frontend/Validiscan/fixture/Magento_Customer/templates/form/captcha-math.phtml Changes to app/design/frontend/Validiscan/fixture/Magento_Customer/templates/form/captcha-math.phtml: 1 added, 1 removed
| Line before | Line after | Change | Code |
|---|---|---|---|
| 14 | 14 | <div class="field captcha required" role="group" aria-labelledby="captcha-question" data-captcha-type="math"> | |
| 15 | 15 | <label for="captcha_user_login" class="label" id="captcha-question"><span><?= $escaper->escapeHtml(__('Security question: what is %1 + %2?', $a, $b)) ?></span></label> | |
| 16 | 16 | <div class="control captcha"> | |
| 17 | Removed | <input name="captcha[user_login]" type="text" class="input-text required-entry" id="captcha_user_login" autocomplete="off" inputmode="numeric" tabindex="3" data-captcha-answer-hash="<?= $escaper->escapeHtmlAttr(md5((string) ($a + $b))) ?>"> | |
| 17 | Added | <input name="captcha[user_login]" type="text" class="input-text required-entry" id="captcha_user_login" autocomplete="off" inputmode="numeric" data-captcha-answer-hash="<?= $escaper->escapeHtmlAttr(md5((string) ($a + $b))) ?>"> | |
| 18 | 18 | </div> | |
| 19 | 19 | </div> |
app/design/frontend/Validiscan/fixture/Magento_Customer/templates/form/login.phtml Changes to app/design/frontend/Validiscan/fixture/Magento_Customer/templates/form/login.phtml: 3 added, 3 removed
| Line before | Line after | Change | Code |
|---|---|---|---|
| 27 | 27 | <div class="field email required"> | |
| 28 | 28 | <label class="label" for="email"><span><?= $escaper->escapeHtml(__('Email')) ?></span></label> | |
| 29 | 29 | <div class="control"> | |
| 30 | Removed | <input name="login[username]" value="<?= $escaper->escapeHtmlAttr($username) ?>" autocomplete="off" id="email" type="email" class="input-text mage-error" title="<?= $escaper->escapeHtmlAttr(__('Email')) ?>" aria-invalid="true" tabindex="1" onpaste="return false;" data-mage-init='{"mage/trim-input":{}}'> | |
| 30 | Added | <input name="login[username]" value="<?= $escaper->escapeHtmlAttr($username) ?>" autocomplete="off" id="email" type="email" class="input-text mage-error" title="<?= $escaper->escapeHtmlAttr(__('Email')) ?>" aria-invalid="true" onpaste="return false;" data-mage-init='{"mage/trim-input":{}}'> | |
| 31 | 31 | <div for="email" generated="true" class="mage-error" id="email-error"><?= $escaper->escapeHtml(__('Please enter a valid email address (Ex: johndoe@domain.com).')) ?></div> | |
| 32 | 32 | </div> | |
| 33 | 33 | </div> | |
| 35 | 35 | <div class="field password required"> | |
| 36 | 36 | <label for="pass" class="label"><span><?= $escaper->escapeHtml(__('Password')) ?></span></label> | |
| 37 | 37 | <div class="control"> | |
| 38 | Removed | <input name="login[password]" type="password" autocomplete="off" class="input-text" id="pass" title="<?= $escaper->escapeHtmlAttr(__('Password')) ?>" tabindex="2" onpaste="return false;" oncopy="return false;"> | |
| 38 | Added | <input name="login[password]" type="password" autocomplete="off" class="input-text" id="pass" title="<?= $escaper->escapeHtmlAttr(__('Password')) ?>" onpaste="return false;" oncopy="return false;"> | |
| 39 | 39 | </div> | |
| 40 | 40 | </div> | |
| 41 | 41 | <!-- VALIDISCAN-SEEDED vs-accessible-auth: arithmetic CAPTCHA with no alternative --> | |
| 42 | 42 | <?= $block->getChildHtml('form_additional_info') /* math CAPTCHA module: div.field.captcha, input#captcha_user_login tabindex="3" */ ?> | |
| 43 | 43 | <div class="actions-toolbar"> | |
| 44 | Removed | <div class="primary"><button type="submit" class="action login primary" name="send" id="send2" tabindex="4"><span><?= $escaper->escapeHtml(__('Sign In')) ?></span></button></div> | |
| 44 | Added | <div class="primary"><button type="submit" class="action login primary" name="send" id="send2"><span><?= $escaper->escapeHtml(__('Sign In')) ?></span></button></div> | |
| 45 | 45 | <div class="secondary"><a class="action remind" href="<?= $escaper->escapeUrl($block->getUrl('contact')) ?>"><span><?= $escaper->escapeHtml(__('Forgot Your Password? Contact us')) ?></span></a></div> | |
| 46 | 46 | </div> | |
| 47 | 47 | </fieldset> |
Start with a free check of your shop.
Send your shop’s address. Validiscan scans it and replies with every failure the scan can prove, with the page and the code.
Request a free checkOr write to info@validiscan.com.
What the free check covers, and what it leaves out- You send
- your shop’s address
- Pages scanned
- up to 15
- You get
- every failure the scan can prove, with the page and the code
- On your shop
- public pages only; no login, nothing bought, no forms sent
- What it is not
- a full audit