Accessibility fixes in your Shopify theme’s code

Validiscan fixes accessibility failures in your Shopify theme’s Liquid templates, stylesheets, scripts and locale files. The changes arrive as an unpublished copy of your theme, a pull request or a patch bundle, and nothing reaches your live theme until you or your developer publish, merge or apply it.

Last updated:

Which files in a Shopify theme does Validiscan change?

Validiscan changes your theme’s own files and nothing else: the Liquid files in layout, templates, sections, snippets and blocks, the stylesheets and scripts in assets, the storefront text in locales and the settings in config. A change to any other file is rejected before Validiscan reviews it.

Shopify’s checkout is not part of your theme’s code

A full scan opens the first checkout step, but Shopify no longer supports checkout.liquid, the theme layout file for the checkout, for the information, shipping and payment steps. A failure that a scan finds in those steps therefore cannot be fixed with a change to your theme files.

Details: what a change may add, and what is outside this work

Every change is an ordinary edit to a theme file that you or your developer can read line by line. A few fixes add some lines to the theme’s own script, for example to connect a pause button to a slideshow. No fix adds an app or an overlay, and nothing is injected that corrects your pages in the visitor’s browser. Code that is not part of your theme’s files is outside this work, and third-party embeds such as payment or chat widgets are not included in a project.

How does Validiscan get access to your Shopify theme?

Validiscan reaches your Shopify theme in one of three ways: through your GitHub repository, with a Theme Access password, or from a theme file you download yourself. Which one fits depends on where your theme’s code lives, and it also decides how the changes come back to you.

The three ways into a Shopify theme
Way in You give Changes come back as
Through your GitHub repository Access to the repository, through Validiscan’s GitHub App or an access token A pull request
With a Theme Access password A Theme Access password and your store’s myshopify.com address An unpublished copy of your theme
From a theme file you download Your theme’s ZIP file, up to 100 MB, uploaded in the customer portal by an admin of your organization A patch bundle

Through your GitHub repository

If your theme’s code is kept in a GitHub repository, for example through Shopify’s GitHub integration, you give Validiscan access to that repository. You install Validiscan’s GitHub App on it, or give Validiscan an access token. The App asks for read and write access to the repository’s contents and pull requests, and read access to its metadata. The theme’s folders, such as layout, sections and snippets, must be at the top level of the repository, because a change to any other path is rejected. The changes then arrive as a pull request.

With a Theme Access password

Shopify’s free Theme Access app creates passwords that let a developer work on a store’s themes with Shopify CLI. As the store owner, or as a staff member with the Themes permission, you install the app and create a password for Validiscan; Shopify emails Validiscan a link to it, and the password can be viewed only once. You also give Validiscan your store’s myshopify.com address. Validiscan keeps the password encrypted and uses it with Shopify CLI to download your live theme and to upload a new, unpublished copy of it. A Theme Access password allows changes to the store’s themes, so Validiscan’s delivery code refuses any command that would write to the live theme or publish a theme. You end the access by deleting the password in the Theme Access app; as of 6 October 2026, Shopify’s documentation says that deleting a password revokes the access. Validiscan uses the password again after a delivery to check whether the copy has been published, so delete it when the project is over, and create a new one for a later delivery.

From a theme file you download

If neither of the above suits you, you download your theme yourself. The customer portal gives these steps:

  1. In your Shopify admin, open Online Store, then Themes.
  2. In the menu of the theme you use, choose “Download theme file”.
  3. Shopify emails you the ZIP file.

Shopify’s own instructions are on its help page about downloading themes. An admin of your organization in the customer portal, who can be your developer, then uploads the file there, as one ZIP file of up to 100 MB. Validiscan prepares the changes on that copy of your theme and sends them back as a patch bundle.

How do the changes reach your store?

Validiscan delivers the changes in one of three ways: as an unpublished copy of your Shopify theme, as a pull request or as a patch bundle. It delivers only changes it has reviewed and approved, and none of the three ways touches your live theme.

Publishing the theme copy, in five steps

With a Theme Access password, Validiscan downloads your live theme, applies the approved changes and uploads the result as a new, unpublished theme named Validiscan A11y <date>, for example Validiscan A11y 2026-09-24. You get an email when it is ready. Then:

  1. In the Shopify admin, open Online Store, then Themes, and find the unpublished theme Validiscan A11y <date> among your draft themes.
  2. Preview it. Your visitors keep seeing the live theme while you do.
  3. Check the affected pages in the preview, also with the keyboard alone.
  4. When you are satisfied, choose Publish. Shopify describes this step on its help page about publishing themes.
  5. Keep your previous theme until you are sure. You can switch back to it.

An unpublished copy of your theme

The copy is your live theme as it was on the day Validiscan downloaded it. If you change the live theme after that day, in its content, settings or code, make the same change in the copy before you publish it, or the change is lost. The customer portal shows whether the copy is waiting to be published, has been published, or was deleted without being published. Shopify’s Help Center on updating themes says that updates are available only for themes installed from the Shopify Theme Store and that automatic updates are not supported for uploaded themes. The copy is uploaded with Shopify CLI, so before you publish it, check under Online Store, then Themes, whether Shopify still offers updates for it. Where fixes go in a Shopify theme, and what a theme update can undo

A pull request

With access to your repository, Validiscan creates a new branch named validiscan/a11y-<date>-<id>, commits each fix on its own and opens one pull request with a summary table, an explanation of each change and a review checklist. Nothing is pushed to your default branch. With Shopify’s GitHub integration, a theme follows the branch it is connected to: your developer can connect the pull request’s branch to an unpublished theme to preview it, and merging into the branch your live theme follows changes the live theme.

A patch bundle

From an uploaded theme file, Validiscan prepares a zip archive for your developer. It contains PATCH.md with every change and how to apply it, all changes in one patch file, one patch file per fix, the complete changed files and a README.md. Your developer applies the changes, tests them and publishes the theme. What a pull request and a patch bundle contain

What do the recipes change in a Shopify theme?

Each of Validiscan’s 20 recipes for Shopify themes is a deterministic change for one kind of failure: the same code always gets the same change. A recipe makes a change only where the theme’s code shows what the right change is; otherwise the finding is left to a person.

Page structure and navigation

  • Page language from your store’s language html-lang A layout whose <html> element has a missing, empty or invalid lang attribute gets the language your store is showing.

    A layout whose <html> element has no lang attribute, an empty one or an invalid one gets the language your store is showing, so screen readers pronounce the page in the right language. Where the theme already keeps a page’s own language in a metafield, for example for German legal pages in an English store, the layout uses that metafield when it is set. Without such a field the recipe does not guess a page’s language.

    Rules it serves
    • html-has-lang AUTO checked through vs-lang
    • html-lang-valid AUTO checked through vs-lang
    • vs-lang AUTO
    File types it changes
    .liquid
    Before and after Example data

    layout/checkout.liquid Changes to layout/checkout.liquid: 1 added, 1 removed

    Changes to layout/checkout.liquid: 1 added, 1 removed
    Line before Line after Change Code
    3 3 Checkout layout (the Shopify Plus `checkout.liquid` era). Only the first step
    4 4 (contact, delivery, billing) is modelled; nothing is ever submitted.
    5 5 {% endcomment %}
    6 Removed <html class="no-js">
    6 Added <html class="no-js" lang="{{ request.locale.iso_code }}">
    7 7 <head>
    8 8 <meta charset="utf-8">
    9 9 <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">

    layout/theme.liquid Changes to layout/theme.liquid: 3 added, 0 removed

    Changes to layout/theme.liquid: 3 added, 0 removed
    Line before Line after Change Code
    6 6 {% endcomment %}
    7 7 {%- liquid
    8 8 assign page_lang = request.locale.iso_code
    9 Added if page.metafields.custom.content_language != blank
    10 Added assign page_lang = page.metafields.custom.content_language
    11 Added endif
    9 12 -%}
    10 13 <html class="no-js" lang="{{ page_lang }}">
    11 14 <head>
  • A title on every page document-title-template Layouts whose <title> is missing, empty or the same fixed text on every page get a title built from Shopify’s variables.

    Every layout whose <title> is missing, empty or the same fixed text on every page gets a title built from Shopify’s variables. If your main layout already has a title that names the page, the other layouts get the same one. Otherwise the title holds the page title, the page number of a paginated list and the shop name, with the word for “page” translated through the theme’s locale files.

    Rules it serves
    • document-title AUTO
    File types it changes
    .liquid
    Before and after Example data

    layout/theme.focused.liquid Changes to layout/theme.focused.liquid: 1 added, 1 removed

    Changes to layout/theme.focused.liquid: 1 added, 1 removed
    Line before Line after Change Code
    11 11 <meta charset="utf-8">
    12 12 <meta http-equiv="X-UA-Compatible" content="IE=edge">
    13 13 <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
    14 Removed <title></title>
    14 Added <title>{{ page_title }}{% unless page_title contains shop.name %} &ndash; {{ shop.name }}{% endunless %}</title>
    15 15 <link rel="canonical" href="{{ canonical_url }}">
    16 16 <!-- Theme assets: //cdn.shopify.com/s/files/1/0712/3456/7890/t/12/assets/ (served locally by the fixture server) -->
    17 17 {% render 'meta-tags' %}
  • Headings without skipped levels heading-levels A heading that skips a level gets the next level below the heading before it; the recipe adds no hidden heading.

    A heading that skips a level, such as an h4 right after an h2, gets the next level below the heading before it, in the section file where it is written. Where the theme styles headings by their level, a small style rule keeps the heading’s old look. A page without a main heading gets the element that already looks like its title turned into the h1; on the home page that is the theme’s own option to show the logo as the h1. If a page has no such element, the recipe adds no hidden heading, because that is a content decision.

    Rules it serves
    • heading-order AUTO checked through vs-headings-landmarks
    • page-has-heading-one AUTO checked through vs-headings-landmarks
    • vs-headings-landmarks AUTO
    File types it changes
    .liquid
    Before and after Example data

    sections/featured-collection.liquid Changes to sections/featured-collection.liquid: 1 added, 1 removed

    Changes to sections/featured-collection.liquid: 1 added, 1 removed
    Line before Line after Change Code
    1 1 {%- liquid
    2 2 assign featured = section.settings.collection
    3 Removed assign card_heading = 'h4'
    3 Added assign card_heading = 'h3'
    4 4 -%}
    5 5 <div class="featured-collection page-width section-spacing">
    6 6 <div class="title-wrapper">

    sections/header.liquid Changes to sections/header.liquid: 3 added, 0 removed

    Changes to sections/header.liquid: 3 added, 0 removed
    Line before Line after Change Code
    3 3 assign menu = linklists[section.settings.menu]
    4 4 assign icon_size = 18
    5 5 assign logo_is_heading = false
    6 Added if request.page_type == 'index'
    7 Added assign logo_is_heading = true
    8 Added endif
    6 9 -%}
    7 10 <div class="header-wrapper">
    8 11 <header class="header page-width"{% if header_lang != '' %} lang="{{ header_lang }}"{% endif %}>

    sections/main-collection.liquid Changes to sections/main-collection.liquid: 1 added, 1 removed

    Changes to sections/main-collection.liquid: 1 added, 1 removed
    Line before Line after Change Code
    6 6 wishlist heart nested inside the product image link.
    7 7 {%- endcomment -%}
    8 8 {%- liquid
    9 Removed assign card_heading = 'h4'
    9 Added assign card_heading = 'h3'
    10 10 assign price_max = collection.price_max_rounded
    11 11 assign in_stock_count = collection.products | where: 'available', true | size
    12 12 assign out_of_stock_count = collection.products_count | minus: in_stock_count

    sections/main-product.liquid Changes to sections/main-product.liquid: 1 added, 1 removed

    Changes to sections/main-product.liquid: 1 added, 1 removed
    Line before Line after Change Code
    13 13 assign featured = product.featured_image
    14 14 assign form_id = 'product-form-' | append: section.id
    15 15 assign qty_id = 'Quantity-' | append: section.id
    16 Removed assign sub_heading = 'h4'
    16 Added assign sub_heading = 'h2'
    17 17 assign size_option = nil
    18 18 for option in product.options_with_values
    19 19 if option.name == 'Size'

    sections/main-search.liquid Changes to sections/main-search.liquid: 1 added, 1 removed

    Changes to sections/main-search.liquid: 1 added, 1 removed
    Line before Line after Change Code
    1 1 {%- liquid
    2 Removed assign card_heading = 'h4'
    2 Added assign card_heading = 'h3'
    3 3 -%}
    4 4 <div class="template-search page-width">
    5 5 <h1 class="template-search__title">{{ 'templates.search.title' | t }}</h1>

Keyboard use and focus

  • A visible focus outline focus-visible-styles One outline for keyboard focus, in your theme’s accent colour or a fallback, at a contrast of at least 3:1 against the page background.

    The main stylesheet gets one outline for keyboard focus: 2 pixels, solid, 2 pixels away from the element, in your theme’s accent colour (or its text colour, or black or white, where the theme has no accent colour), made lighter or darker in the same hue until it has a contrast of at least 3:1 against the page background. Theme rules that would still hide the outline lose their outline: none, unless they show focus in another way, such as a border or a shadow. The outline has one colour for the whole theme, so on sections with a dark colour scheme it may need a lighter colour, which your developer checks in the preview.

    Rules it serves
    • vs-focus-visible AUTO
    File types it changes
    .css.liquid
    Before and after Example data

    assets/base.css.liquid Changes to assets/base.css.liquid: 7 added, 0 removed

    Changes to assets/base.css.liquid: 7 added, 0 removed
    Line before Line after Change Code
    1866 1866 }
    1867 1867
    1868 1868 /* ----------------------------------------------------------------- motion */
    1869 Added
    1870 Added /* validiscan:focus-visible-styles */
    1871 Added :focus-visible {
    1872 Added outline: 2px solid #1f4d3a;
    1873 Added outline-offset: 2px;
    1874 Added }
    1875 Added /* /validiscan:focus-visible-styles */
  • A tab order that follows the page remove-positive-tabindex A tabindex above 0 is removed or set to 0, so the Tab key follows the order of the page, unless the theme’s template code writes it.

    A tabindex above 0 pulls an element to the front of the keyboard order. The recipe removes it from links, buttons, form fields and other elements that can take focus anyway, and sets it to 0 on other elements, so they stay reachable while the Tab key follows the order of the page. A tabindex that the theme’s template code writes is left to a person. The recipe also handles the findings of the focus order check vs-focus-order that are about a tabindex above 0; the rest of that check stays MANUAL.

    Rules it serves
    • tabindex AUTO
    • vs-focus-order MANUAL
    File types it changes
    .liquid
    Before and after Example data

    sections/header.liquid Changes to sections/header.liquid: 1 added, 1 removed

    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>
  • Mouse-only controls turned into buttons button-semantics Mouse-only <div> and <span> controls that a script listens to, and whose purpose is clear, become real buttons.

    Clickable elements that only work with a mouse, such as slideshow dots or the choices in a cookie banner built from <div> or <span> elements, become real <button> elements. Every class and attribute stays, so the theme’s script keeps working, and Enter and Space now do what a click does. A button without a name gets one from its purpose, for example “Load slide 2 of 3”, and a small style reset keeps its look. An element that no script listens to, or whose purpose is unclear, is left as it is.

    Rules it serves
    • vs-carousel AUTO
    • vs-cookie-banner ASSISTED
    File types it changes
    .css.liquid.liquid
    Before and after Example data

    assets/base.css.liquid Changes to assets/base.css.liquid: 14 added, 0 removed

    Changes to assets/base.css.liquid: 14 added, 0 removed
    Line before Line after Change Code
    1866 1866 }
    1867 1867
    1868 1868 /* ----------------------------------------------------------------- motion */
    1869 Added
    1870 Added /* validiscan:button-semantics */
    1871 Added :where(button.slider-counter__link) {
    1872 Added appearance: none;
    1873 Added margin: 0;
    1874 Added padding: 0;
    1875 Added border: 0;
    1876 Added background: none;
    1877 Added color: inherit;
    1878 Added font: inherit;
    1879 Added text-align: inherit;
    1880 Added cursor: pointer;
    1881 Added }
    1882 Added /* /validiscan:button-semantics */

    sections/slideshow.liquid Changes to sections/slideshow.liquid: 1 added, 1 removed

    Changes to sections/slideshow.liquid: 1 added, 1 removed
    Line before Line after Change Code
    62 62 </button>
    63 63 <div class="slider-counter">
    64 64 {%- for block in section.blocks %}
    65 Removed <span class="slider-counter__link" data-slide-to="{{ forloop.index0 }}"><span class="dot"></span></span>
    65 Added <button class="slider-counter__link" data-slide-to="{{ forloop.index0 }}" type="button"><span class="dot"></span><span class="visually-hidden">{{ 'accessibility.load_slide' | t: index: forloop.index, count: forloop.length }}</span></button>
    66 66 {%- endfor %}
    67 67 </div>
    68 68 <button type="button" class="slider-button slider-button--next" name="next" data-slide-next>

Zoom, tap targets and contrast

  • Zoom allowed on phones meta-viewport-zoom user-scalable=no and any maximum-scale below 5 are removed from the viewport tags, so visitors can zoom up to 500 %, unless the theme’s template code sets the limit.

    The recipe removes user-scalable=no and any maximum-scale below 5 from the viewport tags in the theme’s Liquid files, so visitors can pinch-zoom your store up to 500 %. The other viewport settings stay exactly as they were. A zoom limit that the theme’s template code sets is left to a person.

    Rules it serves
    • meta-viewport AUTO
    • meta-viewport-large AUTO
    File types it changes
    .liquid
    Before and after Example data

    layout/checkout.liquid Changes to layout/checkout.liquid: 1 added, 1 removed

    Changes to layout/checkout.liquid: 1 added, 1 removed
    Line before Line after Change Code
    6 6 <html class="no-js">
    7 7 <head>
    8 8 <meta charset="utf-8">
    9 Removed <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
    9 Added <meta name="viewport" content="width=device-width,initial-scale=1">
    10 10 <meta name="robots" content="noindex">
    11 11 <link rel="canonical" href="{{ canonical_url }}">
    12 12 <!-- Checkout assets: //cdn.shopify.com/s/files/1/0712/3456/7890/t/12/assets/ (served locally by the fixture server) -->

    layout/theme.focused.liquid Changes to layout/theme.focused.liquid: 1 added, 1 removed

    Changes to layout/theme.focused.liquid: 1 added, 1 removed
    Line before Line after Change Code
    10 10 <head>
    11 11 <meta charset="utf-8">
    12 12 <meta http-equiv="X-UA-Compatible" content="IE=edge">
    13 Removed <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
    13 Added <meta name="viewport" content="width=device-width,initial-scale=1">
    14 14 <title></title>
    15 15 <link rel="canonical" href="{{ canonical_url }}">
    16 16 <!-- Theme assets: //cdn.shopify.com/s/files/1/0712/3456/7890/t/12/assets/ (served locally by the fixture server) -->

    layout/theme.liquid Changes to layout/theme.liquid: 1 added, 1 removed

    Changes to layout/theme.liquid: 1 added, 1 removed
    Line before Line after Change Code
    11 11 <head>
    12 12 <meta charset="utf-8">
    13 13 <meta http-equiv="X-UA-Compatible" content="IE=edge">
    14 Removed <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
    14 Added <meta name="viewport" content="width=device-width,initial-scale=1">
    15 15 <meta name="theme-color" content="{{ settings.color_background }}">
    16 16 <link rel="canonical" href="{{ canonical_url }}">
    17 17 {%- comment -%} On the live store theme assets load from //cdn.shopify.com/s/files/1/0712/3456/7890/t/12/assets/; the fixture serves them from {{ routes.root_url }}assets/. {%- endcomment %}
  • Larger tap targets target-size-padding Controls the scan found too small, such as header icons or colour swatches, get a minimum size of 24 by 24 pixels.

    Controls that the scan found too small, such as header icons, colour swatches or quantity buttons, get a minimum size of 24 by 24 pixels in the main stylesheet, addressed by their class. The icons inside them keep their size. A link or other element that the theme leaves inline is made inline-flex with its icon centred, because inline elements ignore a minimum size.

    Rules it serves
    • vs-target-size AUTO
    File types it changes
    .css.liquid
    Before and after Example data

    assets/base.css.liquid Changes to assets/base.css.liquid: 10 added, 0 removed

    Changes to assets/base.css.liquid: 10 added, 0 removed
    Line before Line after Change Code
    1866 1866 }
    1867 1867
    1868 1868 /* ----------------------------------------------------------------- motion */
    1869 Added
    1870 Added /* validiscan:target-size-padding */
    1871 Added .header__icon--account,
    1872 Added .header__icon--cart,
    1873 Added .swatch--button,
    1874 Added .quantity__button {
    1875 Added min-width: 24px;
    1876 Added min-height: 24px;
    1877 Added }
    1878 Added /* /validiscan:target-size-padding */
  • Text colours with enough contrast contrast-tokens A failing text colour defined in one place, and not shared, moves to the nearest shade of its hue with the required contrast.

    When text fails the contrast check and its colour is defined in a single place, that colour moves to the nearest lighter or darker shade of the same hue that reaches the required contrast against every background the scan reported for it. The place can be the stylesheet rule that styles the text, or a CSS variable or a colour setting in config/settings_data.json that nothing else in the theme uses. A colour that is computed in Liquid or defined more than once is left to a person, and so is a shared colour, because changing it would affect colour pairs the scan did not measure.

    Rules it serves
    • color-contrast ASSISTED
    File types it changes
    .css.liquid
    Before and after Example data

    assets/base.css.liquid Changes to assets/base.css.liquid: 3 added, 3 removed

    Changes to assets/base.css.liquid: 3 added, 3 removed
    Line before Line after Change Code
    745 745 font-size: 0.8125rem;
    746 746 letter-spacing: 0.04em;
    747 747 text-transform: uppercase;
    748 Removed color: #929292;
    748 Added color: #767676;
    749 749 }
    750 750
    751 751 .rating {
    1013 1013
    1014 1014 .copyright__content {
    1015 1015 margin: 0;
    1016 Removed color: #929292;
    1016 Added color: #6f6f6f;
    1017 1017 }
    1018 1018
    1019 1019 .policies {
    1077 1077
    1078 1078 .cookie-banner__text,
    1079 1079 .cookie-banner__link {
    1080 Removed color: #9a9a9a;
    1080 Added color: #767676;
    1081 1081 }
    1082 1082
    1083 1083 .cookie-banner__icon-button {

Motion, slideshows and sound

  • Less motion when a visitor asks for it reduced-motion-guard For visitors whose device asks for reduced motion, CSS animations and transitions are cut short, smooth scrolling is off, and a slideshow’s autoplay that the theme’s script starts is skipped.

    When a visitor’s device is set to reduce motion, a block at the end of the main stylesheet lets every CSS animation run only once, too briefly to see, shortens transitions and turns smooth scrolling off. Where the theme’s script starts a slideshow’s autoplay itself, the start is skipped for those visitors. Motion that a third-party script drives is not changed by this recipe.

    Rules it serves
    • vs-motion AUTO
    File types it changes
    .css.liquid
    Before and after Example data

    assets/base.css.liquid Changes to assets/base.css.liquid: 13 added, 0 removed

    Changes to assets/base.css.liquid: 13 added, 0 removed
    Line before Line after Change Code
    1866 1866 }
    1867 1867
    1868 1868 /* ----------------------------------------------------------------- motion */
    1869 Added
    1870 Added /* validiscan:reduced-motion-guard */
    1871 Added @media (prefers-reduced-motion: reduce) {
    1872 Added *,
    1873 Added *::before,
    1874 Added *::after {
    1875 Added animation-duration: 0.01ms !important;
    1876 Added animation-iteration-count: 1 !important;
    1877 Added transition-duration: 0.01ms !important;
    1878 Added scroll-behavior: auto !important;
    1879 Added }
    1880 Added }
    1881 Added /* /validiscan:reduced-motion-guard */
  • A pause button for moving slideshows slider-pause-control A slideshow that moves on its own gets a visible, translated pause and play button connected to its own autoplay.

    A slideshow that moves on its own gets a visible, translated button to pause and play it, among its controls and shown only when autoplay is on. A short script in the theme’s main script file connects the button to the slider’s own autoplay: Dawn’s slideshow, Swiper, Slick, Flickity, Splide, or a CSS animation. For visitors whose device asks for reduced motion, the slideshow starts paused.

    Rules it serves
    • vs-carousel AUTO
    File types it changes
    .css.liquid.js.json.liquid
    Before and after Example data

    assets/base.css.liquid Changes to assets/base.css.liquid: 21 added, 0 removed

    Changes to assets/base.css.liquid: 21 added, 0 removed
    Line before Line after Change Code
    1866 1866 }
    1867 1867
    1868 1868 /* ----------------------------------------------------------------- motion */
    1869 Added
    1870 Added /* validiscan:slider-pause-control */
    1871 Added [data-autoplay-paused],
    1872 Added [data-autoplay-paused] * {
    1873 Added animation-play-state: paused !important;
    1874 Added }
    1875 Added
    1876 Added .slider-pause {
    1877 Added display: inline-flex;
    1878 Added align-items: center;
    1879 Added justify-content: center;
    1880 Added min-width: 2.75rem;
    1881 Added min-height: 2.75rem;
    1882 Added padding: 0.5rem 1rem;
    1883 Added border: 1px solid currentColor;
    1884 Added background: transparent;
    1885 Added color: inherit;
    1886 Added font: inherit;
    1887 Added cursor: pointer;
    1888 Added }
    1889 Added /* /validiscan:slider-pause-control */

    assets/global.js Changes to assets/global.js: 53 added, 0 removed

    Changes to assets/global.js: 53 added, 0 removed
    Line before Line after Change Code
    412 412 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
    413 413 else boot();
    414 414 })();
    415 Added
    416 Added /* validiscan:slider-pause-control */
    417 Added (function () {
    418 Added 'use strict';
    419 Added var ROOTS = 'slideshow-component, slider-component, .swiper, .swiper-container, .slick-slider, .flickity-enabled, .splide, [data-autoplay]';
    420 Added var reduce = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : null;
    421 Added
    422 Added function rootOf(button) {
    423 Added return button.closest(ROOTS) || button.previousElementSibling;
    424 Added }
    425 Added
    426 Added function setPaused(button, paused) {
    427 Added var root = rootOf(button);
    428 Added if (!root) return;
    429 Added if (paused) root.setAttribute('data-autoplay-paused', '');
    430 Added else root.removeAttribute('data-autoplay-paused');
    431 Added if (root.swiper && root.swiper.autoplay) {
    432 Added if (paused) root.swiper.autoplay.stop();
    433 Added else root.swiper.autoplay.start();
    434 Added }
    435 Added var $ = window.jQuery;
    436 Added if ($ && $(root).hasClass('slick-initialized')) $(root).slick(paused ? 'slickPause' : 'slickPlay');
    437 Added var flickity = window.Flickity && window.Flickity.data(root);
    438 Added if (flickity) {
    439 Added if (paused) flickity.pausePlayer();
    440 Added else flickity.unpausePlayer();
    441 Added }
    442 Added var autoplay = root.splide && root.splide.Components && root.splide.Components.Autoplay;
    443 Added if (autoplay) {
    444 Added if (paused) autoplay.pause();
    445 Added else autoplay.play();
    446 Added }
    447 Added if (root.localName.indexOf('-') > 0 && typeof root.pause === 'function' && typeof root.play === 'function') {
    448 Added if (paused) root.pause();
    449 Added else root.play();
    450 Added }
    451 Added button.setAttribute('data-paused', paused ? 'true' : 'false');
    452 Added button.textContent = button.getAttribute(paused ? 'data-label-play' : 'data-label-pause') || button.textContent;
    453 Added }
    454 Added
    455 Added function init() {
    456 Added Array.prototype.forEach.call(document.querySelectorAll('[data-slider-pause]'), function (button) {
    457 Added button.addEventListener('click', function () {
    458 Added setPaused(button, button.getAttribute('data-paused') !== 'true');
    459 Added });
    460 Added if (reduce && reduce.matches) setPaused(button, true);
    461 Added });
    462 Added }
    463 Added
    464 Added if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    465 Added else init();
    466 Added })();
    467 Added /* /validiscan:slider-pause-control */

    locales/en.default.json Changes to locales/en.default.json: 3 added, 1 removed

    Changes to locales/en.default.json: 3 added, 1 removed
    Line before Line after Change Code
    34 34 "total_reviews": "{{ count }} reviews",
    35 35 "link_messages": {
    36 36 "new_window": "Opens in a new window."
    37 Removed }
    37 Added },
    38 Added "pause_slideshow": "Pause slideshow",
    39 Added "play_slideshow": "Play slideshow"
    38 40 },
    39 41 "sections": {
    40 42 "header": {

    sections/slideshow.liquid Changes to sections/slideshow.liquid: 3 added, 0 removed

    Changes to sections/slideshow.liquid: 3 added, 0 removed
    Line before Line after Change Code
    68 68 <button type="button" class="slider-button slider-button--next" name="next" data-slide-next>
    69 69 {%- render 'icon', icon: 'chevron-right', size: 20 -%}
    70 70 </button>
    71 Added {%- if autoplay %}
    72 Added <button type="button" class="slider-pause" data-slider-pause data-label-pause="{{ 'accessibility.pause_slideshow' | t | escape }}" data-label-play="{{ 'accessibility.play_slideshow' | t | escape }}">{{ 'accessibility.pause_slideshow' | t }}</button>
    73 Added {%- endif %}
    71 74 </div>
    72 75 </slideshow-component>
    73 76
  • No sound that starts by itself mute-autoplay Video that plays on its own is muted and audio no longer starts by itself, except in embedded YouTube and Vimeo players; on Shopify the recipe is not used for vs-media findings.

    A video that plays on its own gets muted and playsinline, so it keeps playing silently, as a background video does, and Shopify’s video_tag filter gets muted: true. Audio that starts by itself no longer does. Embedded YouTube and Vimeo players are left to a person. In a Validiscan scan, sound that starts by itself is reported by the media check vs-media, which takes the place of the axe-core rule this recipe serves. vs-media has the tier MANUAL, and on Shopify this recipe is not used for its findings.

    Rules it serves
    • no-autoplay-audio AUTO checked through vs-media
    File types it changes
    .liquid
    Before and after Example data

    sections/announcement-bar.liquid Changes to sections/announcement-bar.liquid: 2 added, 2 removed

    Changes to sections/announcement-bar.liquid: 2 added, 2 removed
    Line before Line after Change Code
    3 3 -%}
    4 4 <div class="announcement-bar color-accent"{% if bar_lang != '' %} lang="{{ bar_lang }}"{% endif %}>
    5 5 <div class="page-width">
    6 Removed <audio class="announcement-bar__chime" src="{{ 'chime.wav' | asset_url }}" autoplay></audio>
    7 Removed <video class="announcement-bar__clip" src="{{ 'chime.wav' | asset_url }}" autoplay loop width="16" height="16"></video>
    6 Added <audio class="announcement-bar__chime" src="{{ 'chime.wav' | asset_url }}"></audio>
    7 Added <video class="announcement-bar__clip" src="{{ 'chime.wav' | asset_url }}" autoplay loop width="16" height="16" muted playsinline></video>
    8 8 <p class="announcement-bar__message">{{ section.settings.text | escape }}</p>
    9 9 </div>
    10 10 </div>

Forms, prices and the cart

  • Labels connected to their fields label-association A label already visible on the page but not connected to its form field gets connected, without changing the layout.

    A form field whose label text is already visible on the page, but not connected to the field, gets connected: a <label> next to it gets a for that points at the field, or a text just before the field, such as the name of a size or colour option, becomes its label through aria-labelledby, so the layout does not change. Screen readers then read the label when the field gets focus. A field that only has a placeholder is left to a person, because writing a label is a content decision.

    Rules it serves
    • label AUTO checked through vs-forms-ecommerce
    • select-name ASSISTED
    • vs-forms-ecommerce AUTO
    File types it changes
    .liquid
    Before and after Example data

    sections/main-collection.liquid Changes to sections/main-collection.liquid: 2 added, 2 removed

    Changes to sections/main-collection.liquid: 2 added, 2 removed
    Line before Line after Change Code
    64 64 <div class="collection__products">
    65 65 <div class="collection__toolbar">
    66 66 <div class="collection__sort">
    67 Removed <label class="collection__sort-label">Sort by</label>
    68 Removed <select name="sort_by" class="select__select" form="FacetFiltersForm">
    67 Added <label class="collection__sort-label" for="SortBy-{{ section.id }}">Sort by</label>
    68 Added <select name="sort_by" class="select__select" form="FacetFiltersForm" id="SortBy-{{ section.id }}">
    69 69 <option value="manual" selected>Featured</option>
    70 70 <option value="best-selling">Best selling</option>
    71 71 <option value="price-ascending">Price, low to high</option>

    sections/main-product.liquid Changes to sections/main-product.liquid: 2 added, 2 removed

    Changes to sections/main-product.liquid: 2 added, 2 removed
    Line before Line after Change Code
    94 94 </div>
    95 95 {%- else %}
    96 96 <div class="product-form__input product-form__input--dropdown">
    97 Removed <p class="form__label">{{ option.name | escape }}</p>
    97 Added <p class="form__label" id="Options-{{ section.id }}-{{ forloop.index0 }}-label">{{ option.name | escape }}</p>
    98 98 <div class="select">
    99 Removed <select class="select__select" name="options[{{ option.name | escape }}]" data-option-index="{{ option_index }}">
    99 Added <select class="select__select" name="options[{{ option.name | escape }}]" data-option-index="{{ option_index }}" aria-labelledby="Options-{{ section.id }}-{{ forloop.index0 }}-label">
    100 100 {%- for value in option.values %}
    101 101 <option value="{{ value | escape }}"{% if value == option.selected_value %} selected{% endif %}>{{ value | escape }}</option>
    102 102 {%- endfor %}
  • Address, account and login fields that browsers can fill in autocomplete-tokens Address, account, login and contact fields the scan flagged get the autocomplete value for what they ask for, where the code shows it.

    Address, account, login and contact fields that the scan flagged get the autocomplete value for what they ask for, such as email, first and last name, street address, postal code, country, phone number or password. The value is worked out from the field’s type, name, label and placeholder. Where a snippet prints its fields from parameters, the fields print an autocomplete parameter, and each render call of the snippet passes the value its own arguments call for. Browsers can then fill the fields in, and assistive technology can tell what each one is for. A field whose purpose the code does not show is left as it is.

    Rules it serves
    • autocomplete-valid AUTO checked through vs-forms-ecommerce
    • vs-forms-ecommerce AUTO
    File types it changes
    .liquid
    Before and after Example data

    sections/contact-form.liquid Changes to sections/contact-form.liquid: 1 added, 1 removed

    Changes to sections/contact-form.liquid: 1 added, 1 removed
    Line before Line after Change Code
    14 14 {%- form 'contact', id: 'ContactForm', class: 'contact__form' %}
    15 15 <div class="contact__fields">
    16 16 <div class="field">
    17 Removed <input class="field__input" type="text" name="contact[name]" placeholder="{{ 'templates.contact.form.name' | t }}">
    17 Added <input class="field__input" type="text" name="contact[name]" placeholder="{{ 'templates.contact.form.name' | t }}" autocomplete="name">
    18 18 </div>
    19 19 <div class="field">
    20 20 <input class="field__input" type="email" name="contact[email]" placeholder="{{ 'templates.contact.form.email' | t }} *" required>

    sections/footer.liquid Changes to sections/footer.liquid: 1 added, 1 removed

    Changes to sections/footer.liquid: 1 added, 1 removed
    Line before Line after Change Code
    11 11 {%- form 'customer', id: 'ContactFooter', class: 'footer__newsletter newsletter-form' -%}
    12 12 <input type="hidden" name="contact[tags]" value="newsletter">
    13 13 <div class="newsletter-form__field-wrapper">
    14 Removed <input type="email" name="contact[email]" class="field__input" placeholder="{{ 'newsletter.label' | t }}" autocorrect="off" autocapitalize="off">
    14 Added <input type="email" name="contact[email]" class="field__input" placeholder="{{ 'newsletter.label' | t }}" autocorrect="off" autocapitalize="off" autocomplete="email">
    15 15 <button type="submit" class="button newsletter-form__button" name="commit">{{ 'newsletter.button' | t }}</button>
    16 16 </div>
    17 17 {%- endform -%}

    sections/main-login.liquid Changes to sections/main-login.liquid: 1 added, 1 removed

    Changes to sections/main-login.liquid: 1 added, 1 removed
    Line before Line after Change Code
    10 10 {%- form 'customer_login', novalidate: 'novalidate' %}
    11 11 <div class="field">
    12 12 <label class="field__label" for="CustomerEmail">{{ 'customer.login_page.email' | t }}</label>
    13 Removed <input class="field__input" type="email" name="customer[email]" id="CustomerEmail" autocorrect="off" autocapitalize="off">
    13 Added <input class="field__input" type="email" name="customer[email]" id="CustomerEmail" autocorrect="off" autocapitalize="off" autocomplete="email">
    14 14 </div>
    15 15 <div class="field">
    16 16 <label class="field__label" for="CustomerPassword">{{ 'customer.login_page.password' | t }}</label>

    snippets/checkout-field.liquid Changes to snippets/checkout-field.liquid: 4 added, 4 removed

    Changes to snippets/checkout-field.liquid: 4 added, 4 removed
    Line before Line after Change Code
    15 15 <div class="field">
    16 16 {%- case legacy %}
    17 17 {%- when 'placeholder' %}
    18 Removed <input class="field__input" type="{{ field_type }}" id="{{ field_id }}" name="{{ field_name }}" placeholder="{{ label }}"{% if required %} required{% endif %}>
    18 Added <input class="field__input" type="{{ field_type }}" id="{{ field_id }}" name="{{ field_name }}" placeholder="{{ label }}"{% if required %} required{% endif %} autocomplete="{{ autocomplete }}">
    19 19 {%- when 'starred' %}
    20 20 <label class="field__label" for="{{ field_id }}">{{ label }} *</label>
    21 Removed <input class="field__input" type="{{ field_type }}" id="{{ field_id }}" name="{{ field_name }}">
    21 Added <input class="field__input" type="{{ field_type }}" id="{{ field_id }}" name="{{ field_name }}" autocomplete="{{ autocomplete }}">
    22 22 {%- when 'off' %}
    23 23 <label class="field__label" for="{{ field_id }}">{{ label }}</label>
    24 Removed <input class="field__input" type="{{ field_type }}" id="{{ field_id }}" name="{{ field_name }}" autocomplete="off"{% if required %} required{% endif %}>
    24 Added <input class="field__input" type="{{ field_type }}" id="{{ field_id }}" name="{{ field_name }}" autocomplete="{{ autocomplete }}"{% if required %} required{% endif %}>
    25 25 {%- else %}
    26 26 <label class="field__label" for="{{ field_id }}">{{ label }}</label>
    27 Removed <input class="field__input" type="{{ field_type }}" id="{{ field_id }}" name="{{ field_name }}"{% if required %} required{% endif %}>
    27 Added <input class="field__input" type="{{ field_type }}" id="{{ field_id }}" name="{{ field_name }}"{% if required %} required{% endif %} autocomplete="{{ autocomplete }}">
    28 28 {%- endcase %}
    29 29 </div>
  • Price, stock and cart changes announced live-region-cart-variant The price, stock note or cart subtotal that the theme’s script rewrites becomes a status region that screen readers announce, unless the area also holds buttons or links.

    The part of a page that the theme’s script rewrites when a customer picks another variant or changes a quantity, such as the price, the stock note or the cart subtotal, is marked as a status region. Screen readers then announce the new value without the customer having to move there. The theme’s script itself is not changed. An area that also holds buttons or links, which would be read out with it, is left as it is.

    Rules it serves
    • vs-live-updates AUTO
    File types it changes
    .liquid
    Before and after Example data

    sections/main-cart.liquid Changes to sections/main-cart.liquid: 1 added, 1 removed

    Changes to sections/main-cart.liquid: 1 added, 1 removed
    Line before Line after Change Code
    37 37
    38 38 <div class="cart__footer">
    39 39 <h2 class="cart__summary-title">Order summary</h2>
    40 Removed <p class="totals">
    40 Added <p class="totals" role="status">
    41 41 <span class="totals__label">{{ 'sections.cart.subtotal' | t }}</span>
    42 42 <span class="totals__value" data-cart-subtotal>{{ cart.total_price | money_with_currency }}</span>
    43 43 </p>

    sections/main-product.liquid Changes to sections/main-product.liquid: 1 added, 1 removed

    Changes to sections/main-product.liquid: 1 added, 1 removed
    Line before Line after Change Code
    71 71 </div>
    72 72 {%- endif %}
    73 73
    74 Removed <div class="product__price" data-price-container>
    74 Added <div class="product__price" data-price-container role="status">
    75 75 {% render 'price', product: product, use_variant: true %}
    76 76 <p class="product__inventory" data-stock>{% if current_variant.available %}{{ 'products.product.in_stock' | t }}{% else %}{{ 'products.product.out_of_stock' | t }}{% endif %}</p>
    77 77 <p class="product__sku">{{ 'products.product.sku' | t }}: <span data-sku>{{ current_variant.sku }}</span></p>

Names for icons, images and embeds

  • Names for icon-only buttons and links icon-accessible-name Buttons and links that show only an icon get a name that says what they do, where the code shows their purpose.

    Buttons and links that show only an icon, such as search, cart, account, slideshow arrows, quantity buttons and cookie choices, get a name that says what they do, where the code shows their purpose. The name is visually hidden text inside the control, or an aria-label where the theme has no class for hidden text. A logo link whose image has no text alternative gets your shop name. A control whose name would have to come from your own content is left to a person.

    Rules it serves
    • button-name ASSISTED
    • link-name ASSISTED
    • image-alt ASSISTED
    • vs-carousel AUTO
    • vs-cookie-banner ASSISTED
    File types it changes
    .json.liquid
    buttons Example data

    locales/en.default.json Changes to locales/en.default.json: 3 added, 1 removed

    Changes to locales/en.default.json: 3 added, 1 removed
    Line before Line after Change Code
    34 34 "total_reviews": "{{ count }} reviews",
    35 35 "link_messages": {
    36 36 "new_window": "Opens in a new window."
    37 Removed }
    37 Added },
    38 Added "accept_cookies": "Accept cookies",
    39 Added "reject_cookies": "Reject cookies"
    38 40 },
    39 41 "sections": {
    40 42 "header": {

    sections/header.liquid Changes to sections/header.liquid: 1 added, 0 removed

    Changes to sections/header.liquid: 1 added, 0 removed
    Line before Line after Change Code
    40 40 <input class="search__input field__input" type="search" name="q" value="" placeholder="{{ 'general.search.placeholder' | t }}" tabindex="1">
    41 41 <button type="submit" class="search__button">
    42 42 {%- render 'icon', icon: 'search', size: 20 -%}
    43 Added <span class="visually-hidden">{{ 'general.search.search' | t }}</span>
    43 44 </button>
    44 45 </form>
    45 46 </div>

    sections/main-product.liquid Changes to sections/main-product.liquid: 1 added, 1 removed

    Changes to sections/main-product.liquid: 1 added, 1 removed
    Line before Line after Change Code
    88 88 <p class="form__label">{{ option.name | escape }}</p>
    89 89 <div class="swatch-list swatch-list--compact">
    90 90 {%- for swatch in option.swatches %}
    91 Removed <button type="button" class="swatch swatch--button{% if swatch.selected %} swatch--active{% endif %}" style="--swatch-color: {{ swatch.color }};" data-option-index="{{ option_index }}" data-value="{{ swatch.value | escape }}"></button>
    91 Added <button type="button" class="swatch swatch--button{% if swatch.selected %} swatch--active{% endif %}" style="--swatch-color: {{ swatch.color }};" data-option-index="{{ option_index }}" data-value="{{ swatch.value | escape }}"><span class="visually-hidden">{{ swatch.value | escape }}</span></button>
    92 92 {%- endfor %}
    93 93 </div>
    94 94 </div>

    sections/slideshow.liquid Changes to sections/slideshow.liquid: 2 added, 0 removed

    Changes to sections/slideshow.liquid: 2 added, 0 removed
    Line before Line after Change Code
    59 59 <div class="slideshow__controls">
    60 60 <button type="button" class="slider-button slider-button--prev" name="previous" data-slide-prev>
    61 61 {%- render 'icon', icon: 'chevron-left', size: 20 -%}
    62 Added <span class="visually-hidden">{{ 'accessibility.previous_slide' | t }}</span>
    62 63 </button>
    63 64 <div class="slider-counter">
    64 65 {%- for block in section.blocks %}
    67 68 </div>
    68 69 <button type="button" class="slider-button slider-button--next" name="next" data-slide-next>
    69 70 {%- render 'icon', icon: 'chevron-right', size: 20 -%}
    71 Added <span class="visually-hidden">{{ 'accessibility.next_slide' | t }}</span>
    70 72 </button>
    71 73 </div>
    72 74 </slideshow-component>

    snippets/cookie-banner.liquid Changes to snippets/cookie-banner.liquid: 2 added, 0 removed

    Changes to snippets/cookie-banner.liquid: 2 added, 0 removed
    Line before Line after Change Code
    23 23 <div class="cookie-banner__actions">
    24 24 <button type="button" class="cookie-banner__icon-button cookie-banner__icon-button--accept" data-cookie-accept>
    25 25 {%- render 'icon', icon: 'check', size: 20 -%}
    26 Added <span class="visually-hidden">{{ 'accessibility.accept_cookies' | t }}</span>
    26 27 </button>
    27 28 <button type="button" class="cookie-banner__icon-button" data-cookie-reject>
    28 29 {%- render 'icon', icon: 'close', size: 20 -%}
    30 Added <span class="visually-hidden">{{ 'accessibility.reject_cookies' | t }}</span>
    29 31 </button>
    30 32 </div>
    31 33 </div>

    snippets/quantity-input.liquid Changes to snippets/quantity-input.liquid: 2 added, 0 removed

    Changes to snippets/quantity-input.liquid: 2 added, 0 removed
    Line before Line after Change Code
    9 9 {%- endcomment -%}
    10 10 <div class="quantity quantity--compact" data-quantity{% if line %} data-line="{{ line }}"{% endif %}><button class="quantity__button" name="minus" type="button" data-quantity-minus>
    11 11 {%- render 'icon', icon: 'minus', size: 12 -%}
    12 Added <span class="visually-hidden">{{ 'products.product.quantity.decrease' | t: product: product_title }}</span>
    12 13 </button>
    13 14 <input class="quantity__input" type="number" name="{{ input_name }}" id="{{ input_id }}" value="{{ value | default: 1 }}" min="{% if line %}0{% else %}1{% endif %}" data-quantity-input>
    14 15 <button class="quantity__button" name="plus" type="button" data-quantity-plus>
    15 16 {%- render 'icon', icon: 'plus', size: 12 -%}
    17 Added <span class="visually-hidden">{{ 'products.product.quantity.increase' | t: product: product_title }}</span>
    16 18 </button></div>
    links Example data

    sections/footer.liquid Changes to sections/footer.liquid: 1 added, 0 removed

    Changes to sections/footer.liquid: 1 added, 0 removed
    Line before Line after Change Code
    37 37 {%- for social in shop.social %}
    38 38 <a href="{{ social.url }}" class="list-social__link link">
    39 39 {%- render 'icon', icon: social.icon, size: 20 -%}
    40 Added <span class="visually-hidden">{{ 'general.social.links.' | append: social.icon | t }}</span>
    40 41 </a>
    41 42 {%- endfor %}
    42 43 </ul>

    sections/header.liquid Changes to sections/header.liquid: 3 added, 1 removed

    Changes to sections/header.liquid: 3 added, 1 removed
    Line before Line after Change Code
    22 22 </details>
    23 23
    24 24 {% if logo_is_heading %}<h1 class="header__heading">{% else %}<div class="header__heading">{% endif %}
    25 Removed <a href="{{ routes.root_url }}" class="header__heading-link"><img src="{{ 'logo.svg' | asset_url }}" width="{{ settings.logo_width }}" height="32" class="header__heading-logo"></a>
    25 Added <a href="{{ routes.root_url }}" class="header__heading-link"><img src="{{ 'logo.svg' | asset_url }}" width="{{ settings.logo_width }}" height="32" class="header__heading-logo" alt="{{ shop.name | escape }}"></a>
    26 26 {% if logo_is_heading %}</h1>{% else %}</div>{% endif %}
    27 27
    28 28 <nav class="header__inline-menu">
    50 50 {%- endif %}
    51 51 <a href="{{ routes.account_login_url }}" class="header__icon header__icon--account link">
    52 52 {%- render 'icon', icon: 'account', size: icon_size -%}
    53 Added <span class="visually-hidden">{{ 'sections.header.account' | t }}</span>
    53 54 </a>
    54 55 <a href="{{ routes.cart_url }}" class="header__icon header__icon--cart link" id="cart-icon-bubble">
    55 56 {%- render 'icon', icon: 'cart', size: icon_size -%}
    57 58 <span class="cart-count-bubble" aria-hidden="true"><span data-cart-count>{{ cart.item_count }}</span></span>
    58 59 {%- endif -%}
    59 60
    61 Added <span class="visually-hidden">{{ 'sections.header.cart' | t }}</span>
    60 62 </a>
    61 63 </div>
    62 64 </header>

    sections/main-cart.liquid Changes to sections/main-cart.liquid: 1 added, 0 removed

    Changes to sections/main-cart.liquid: 1 added, 0 removed
    Line before Line after Change Code
    26 26 {% render 'quantity-input', input_id: qty_id, input_name: 'updates[]', value: item.quantity, product_title: item.product.title, line: line %}
    27 27 <a href="{{ routes.cart_change_url }}?line={{ line }}&amp;quantity=0" class="cart-item__remove cart-item__remove--compact" data-cart-remove>
    28 28 {%- render 'icon', icon: 'trash', size: 16 -%}
    29 Added <span class="visually-hidden">{{ 'sections.cart.remove' | t: title: item.product.title }}</span>
    29 30 </a>
    30 31 </div>
    31 32 <div class="cart-item__totals">
  • Decorative images hidden from screen readers decorative-image-alt Only images the code proves decorative are hidden from screen readers; the text alternative of every other image is left to a person.

    An image is marked as decorative only when the code proves it adds nothing: it sits in a link or button that has its own text, it is an icon next to its own text label, or it is a spacer or divider. An <img> then gets an empty alt="", and an SVG icon gets aria-hidden="true", so screen readers skip it instead of reading out a file name or the word “image”. Every other image is content, and its text alternative is left to a person.

    Rules it serves
    • image-alt ASSISTED
    • role-img-alt ASSISTED
    • svg-img-alt ASSISTED
    File types it changes
    .liquid
    Before and after Example data

    sections/footer.liquid Changes to sections/footer.liquid: 1 added, 1 removed

    Changes to sections/footer.liquid: 1 added, 1 removed
    Line before Line after Change Code
    12 12 <input type="hidden" name="contact[tags]" value="newsletter">
    13 13 <div class="newsletter-form__field-wrapper">
    14 14 <input type="email" name="contact[email]" class="field__input" placeholder="{{ 'newsletter.label' | t }}" autocorrect="off" autocapitalize="off">
    15 Removed <button type="submit" class="button newsletter-form__button" name="commit"><img src="{{ 'logo.svg' | asset_url }}" class="newsletter-form__icon" width="16" height="16">{{ 'newsletter.button' | t }}</button>
    15 Added <button type="submit" class="button newsletter-form__button" name="commit"><img src="{{ 'logo.svg' | asset_url }}" class="newsletter-form__icon" width="16" height="16" alt="">{{ 'newsletter.button' | t }}</button>
    16 16 </div>
    17 17 {%- endform -%}
    18 18 </div>

    sections/header.liquid Changes to sections/header.liquid: 1 added, 1 removed

    Changes to sections/header.liquid: 1 added, 1 removed
    Line before Line after Change Code
    46 46
    47 47 <div class="header__icons">
    48 48 {%- if section.settings.show_help_link %}
    49 Removed <a href="{{ section.settings.help_link_url }}" class="header__utility-link link"><svg class="header__utility-icon" role="img" viewBox="0 0 24 24" width="16" height="16"><circle cx="12" cy="12" r="9"/></svg>{{ section.settings.help_link_label | escape }}</a>
    49 Added <a href="{{ section.settings.help_link_url }}" class="header__utility-link link"><svg class="header__utility-icon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="9"/></svg>{{ section.settings.help_link_label | escape }}</a>
    50 50 {%- endif %}
    51 51 <a href="{{ routes.account_login_url }}" class="header__icon header__icon--account link">
    52 52 {%- render 'icon', icon: 'account', size: icon_size -%}
  • Titles for embedded maps and videos iframe-title An embedded frame without a title gets a translated title that says what it shows, where its source address or content tells.

    An embedded frame without a title, such as a map or a YouTube or Vimeo video, gets a translated title that says what it shows. The recipe reads that from the frame’s source address or content. A frame whose content the code does not show gets no generic title; it is left to a person.

    Rules it serves
    • frame-title AUTO
    File types it changes
    .json.liquid
    Before and after Example data

    locales/en.default.json Changes to locales/en.default.json: 2 added, 1 removed

    Changes to locales/en.default.json: 2 added, 1 removed
    Line before Line after Change Code
    34 34 "total_reviews": "{{ count }} reviews",
    35 35 "link_messages": {
    36 36 "new_window": "Opens in a new window."
    37 Removed }
    37 Added },
    38 Added "embed_map": "Map"
    38 39 },
    39 40 "sections": {
    40 41 "header": {

    sections/contact-form.liquid Changes to sections/contact-form.liquid: 1 added, 1 removed

    Changes to sections/contact-form.liquid: 1 added, 1 removed
    Line before Line after Change Code
    8 8 {%- endcapture -%}
    9 9 <div class="contact-section page-width page-width--narrow">
    10 10 <h2 class="contact-section__heading">{{ section.settings.map_heading | escape }}</h2>
    11 Removed <iframe class="store-map" srcdoc="{{ map_document | escape }}" width="600" height="360"></iframe>
    11 Added <iframe class="store-map" srcdoc="{{ map_document | escape }}" width="600" height="360" title="{{ 'accessibility.embed_map' | t | escape }}"></iframe>
    12 12
    13 13 <h2 class="contact-section__heading">{{ 'templates.contact.form.title' | t }}</h2>
    14 14 {%- form 'contact', id: 'ContactForm', class: 'contact__form' %}
Details: the tiers, the test theme, and text in your store’s languages

A recipe is a deterministic change for one kind of failure: the same code always gets the same change. Validiscan has 20 recipes for Shopify themes. Each one makes a change only where the theme’s code shows what the right change is; where it does not, the recipe changes nothing and the finding is left to a person.

Under each recipe you find the rules it serves, with each rule’s tier in Validiscan’s catalogue. AUTO means a recipe is the rule’s standard fix. ASSISTED means the fix needs judgement, so a change is drafted and then reviewed. MANUAL means the rule needs a person’s decision, and Validiscan gives guidance instead of a code change. In a project, recipes are applied to findings of rules of tier AUTO. Where a recipe also lists a rule of tier ASSISTED or MANUAL, a finding of that rule takes the ASSISTED or MANUAL way instead. A rule marked “checked through” another check is not run on its own in a scan: that check reports the same failure.

The file types and the before and after of each recipe come from the application’s own tests. They run on its Shopify test theme, a Liquid theme built like Shopify’s Dawn theme with accessibility failures put in on purpose, and check that the failure is gone after the change; most of them also check that the change causes no new failure of the same rule. Your theme’s files will look different. The test theme still contains a layout/checkout.liquid file and a checkout field snippet, snippets/checkout-field.liquid, so some of the diffs change them; as described above, Shopify no longer uses that file for the information, shipping and payment steps.

When a recipe adds text, such as the words of a skip link or the name of an icon button, it reuses a translation the theme already has where it can. Otherwise it adds the text in English, and in German where the theme has a German locale file. Text in the other languages of your store is not added: a theme whose default language is neither English nor German gets the English text there.

What stays MANUAL on Shopify?

Failures that need a decision about content or behaviour that a fixed code change cannot make, such as captions and transcripts for your videos and audio, stay MANUAL on Shopify. For a finding of a MANUAL rule, Validiscan gives guidance on how to fix it, not a change to your code.

The rules below run in a scan and have the tier MANUAL on Shopify.

  • meta-refresh Delayed refresh under 20 hours must not be used
  • nested-interactive Interactive controls must not be nested
  • scrollable-region-focusable Scrollable region must have keyboard access
  • server-side-image-map Server-side image maps must not be used
  • vs-keyboard-trap Keyboard focus must never get trapped
  • vs-focus-order Focus order must follow the visual order
  • vs-dragging Drag-only controls need a single-pointer alternative
  • vs-media Video needs captions, audio needs a transcript, and nothing may autoplay with sound
  • vs-documents Linked documents need a manual accessibility review
Details: the focus order check, and what a project leaves out

For a finding of a MANUAL rule, Validiscan gives guidance on how to fix it, not a change to your code. These failures need a decision about content or behaviour that a fixed code change cannot make, for example captions and transcripts for your videos and audio, a way out of a widget that traps keyboard focus, or another way to use a control that only works by dragging. The focus order check vs-focus-order has the tier MANUAL because a focus order that does not match the screen is a question of how the templates are ordered; where one of its findings is about a tabindex above 0, the tab order recipe above removes that tabindex.

The work on MANUAL findings that need your own content decisions is not part of a project, and neither are PDFs. Between the rules of tier AUTO and those of tier MANUAL are the rules of tier ASSISTED, such as text alternatives for images or names for buttons: their fixes need judgement, and Validiscan reviews every fix before it is delivered. What automated accessibility testing can and cannot prove

What do you provide?

You provide five things for a Shopify project: written permission for a full scan of your store, one way into your theme, your answer on whether AI may draft fixes of tier ASSISTED, a review of the changes, and your decisions on what stays MANUAL.

  1. Written permission for a full scan of your store. A full scan puts a product in the cart and opens the first checkout step, without completing a purchase or entering payment data.
  2. One way into your theme: access to the GitHub repository that holds it, a Theme Access password and your store’s myshopify.com address, or a theme file uploaded in the customer portal.
  3. Your answer on whether AI may draft fixes of tier ASSISTED. AI drafting is off until you agree.
  4. A review of the changes: publish the theme copy, merge the pull request or apply the patch bundle.
  5. Your decisions on what stays MANUAL, such as captions for your videos.
Details: after the changes are live, and what one project covers

Once the changes are live, a verification scan checks the affected pages again, and the Remediation Evidence Package records the result. How a project runs, step by step

A project covers one shop: one domain and one theme.

Sources

6 sources, each checked for this page on 6 October 2026.

Shopify’s documentation, Help Center and App Store 6
  1. Manage theme access · Shopify · checked
  2. Downloading themes · Shopify Help Center · checked
  3. Publishing themes · Shopify Help Center · checked
  4. Shopify GitHub integration for themes · Shopify · checked
  5. checkout.liquid · Shopify · checked
  6. Theme Access · Shopify App Store · checked

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 check

Or 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

Write to Validiscan

For a free check, give the address of your shop and its platform.

Validiscan uses what you send to answer you. Privacy notice