Skip to content

Accessibility

Accessibility ensures your theme works for all users, including those using assistive technologies, keyboard navigation, or experiencing temporary disabilities. Good accessibility also improves SEO and overall usability.

Semantic HTML provides meaning to assistive technologies:

{# Good: Semantic structure #}
<article class="event">
<header>
<h1>{{ event.title }}</h1>
<time datetime="{{ event.startDate }}">
{{ event.startDate | date: '%B %d, %Y' }}
</time>
</header>
<main>
{% stageblocks event %}
</main>
<footer>
<a href="/events">Back to Events</a>
</footer>
</article>
{# Avoid: Divs for everything #}
<div class="event">
<div class="event-header">
<div class="event-title">{{ event.title }}</div>
<div class="event-date">{{ event.startDate | date: '%B %d, %Y' }}</div>
</div>
</div>

Maintain a logical heading structure:

{# Good: Logical hierarchy #}
<h1>{{ page.title }}</h1>
<section>
<h2>Upcoming Events</h2>
{% for event in events %}
<article>
<h3>{{ event.title }}</h3>
</article>
{% endfor %}
</section>
<section>
<h2>About Us</h2>
<h3>Our Mission</h3>
<h3>Our Team</h3>
</section>

Use landmark elements for page regions:

<body>
<header role="banner">
{% render 'components/global-header' %}
</header>
<nav aria-label="Main navigation">
{% render 'components/global-navigation' %}
</nav>
<main id="main-content">
{{ content_for_layout }}
</main>
<aside aria-label="Related content">
{% render 'components/sidebar' %}
</aside>
<footer role="contentinfo">
{% render 'components/global-footer' %}
</footer>
</body>

Every image needs appropriate alt text:

{# Images that convey information need descriptive alt #}
<img
src="{{ person.image | image_url: width: 300 }}"
alt="{{ person.name }}, {{ person.role }}"
>
<img
src="{{ event.image | image_url: width: 800 }}"
alt="{{ event.image.alt | default: event.title }}"
>

Icons need context for screen readers:

{# Icon with visible text - hide icon from AT #}
<a href="/events">
<svg aria-hidden="true">...</svg>
<span>View Events</span>
</a>
{# Icon-only button - needs accessible label #}
<button type="button" aria-label="Close menu">
<svg aria-hidden="true">
<use xlink:href="#icon-close"></use>
</svg>
</button>
{# Icon with visually hidden text #}
<a href="/search">
<svg aria-hidden="true">...</svg>
<span class="visually-hidden">Search</span>
</a>

Include a visually hidden class in your CSS:

.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}

Ensure all interactive elements are keyboard accessible:

{# Good: Native button element #}
<button type="button" onclick="toggleMenu()">
Menu
</button>
{# Avoid: Div pretending to be a button #}
<div class="button" onclick="toggleMenu()">
Menu
</div>

Never remove focus outlines without providing alternatives:

/* Bad: Removes focus indication */
*:focus {
outline: none;
}
/* Good: Custom focus indicator */
*:focus {
outline: 2px solid var(--focus-color);
outline-offset: 2px;
}
/* Good: Focus-visible for keyboard users only */
*:focus:not(:focus-visible) {
outline: none;
}
*:focus-visible {
outline: 2px solid var(--focus-color);
outline-offset: 2px;
}

Provide a skip link to bypass navigation:

{# At the very top of the body #}
<a href="#main-content" class="skip-link">
Skip to main content
</a>
{# ... navigation ... #}
<main id="main-content">
{{ content_for_layout }}
</main>
.skip-link {
position: absolute;
top: -40px;
left: 0;
padding: 8px 16px;
background: var(--brand-primary);
color: white;
z-index: 100;
}
.skip-link:focus {
top: 0;
}

Every input needs an associated label:

{# Good: Explicit label association #}
<label for="email">Email Address</label>
<input type="email" id="email" name="email" required>
{# Good: Implicit association #}
<label>
Email Address
<input type="email" name="email" required>
</label>
{# Bad: No label association #}
<span>Email Address</span>
<input type="email" name="email">

Make form errors accessible:

<div class="form-field {% if errors.email %}form-field--error{% endif %}">
<label for="email">Email Address</label>
<input
type="email"
id="email"
name="email"
{% if errors.email %}
aria-invalid="true"
aria-describedby="email-error"
{% endif %}
required
>
{% if errors.email %}
<p id="email-error" class="error-message" role="alert">
{{ errors.email }}
</p>
{% endif %}
</div>

Indicate required fields clearly:

<label for="name">
Full Name
<span class="required" aria-hidden="true">*</span>
<span class="visually-hidden">(required)</span>
</label>
<input type="text" id="name" name="name" required aria-required="true">

Follow WCAG contrast requirements:

Content TypeMinimum Ratio
Normal text4.5:1
Large text (18px+ or 14px+ bold)3:1
UI components & graphics3:1

Provide additional indicators beyond color:

{# Good: Color + icon + text #}
<span class="status status--success">
<svg aria-hidden="true">...</svg>
Available
</span>
<span class="status status--error">
<svg aria-hidden="true">...</svg>
Sold Out
</span>
{# Bad: Color only #}
<span class="status" style="color: green;">Available</span>
<span class="status" style="color: red;">Sold Out</span>

Native HTML is preferred over ARIA:

{# Good: Native HTML button #}
<button type="button">Click me</button>
{# Unnecessary: ARIA on native element #}
<button type="button" role="button">Click me</button>
{# When ARIA is needed: Custom components #}
<div
role="tablist"
aria-label="Event details"
>
<button
role="tab"
aria-selected="true"
aria-controls="panel-1"
id="tab-1"
>
Details
</button>
<button
role="tab"
aria-selected="false"
aria-controls="panel-2"
id="tab-2"
>
Schedule
</button>
</div>
<button
type="button"
aria-expanded="false"
aria-controls="accordion-content"
onclick="toggleAccordion(this)"
>
Show More
</button>
<div id="accordion-content" hidden>
{{ content }}
</div>
{# Announce dynamic content changes #}
<div aria-live="polite" aria-atomic="true" class="visually-hidden">
{{ status_message }}
</div>
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-desc"
>
<h2 id="modal-title">Confirm Purchase</h2>
<p id="modal-desc">Are you sure you want to purchase 2 tickets?</p>
<button type="button">Confirm</button>
<button type="button">Cancel</button>
</div>
  1. Keyboard navigation: Tab through the page, can you reach everything?
  2. Screen reader: Use VoiceOver (Mac), NVDA (Windows), or JAWS
  3. Zoom: Does the page work at 200% zoom?
  4. Color: Can you understand the page in grayscale?
  • axe DevTools - Browser extension
  • WAVE - wave.webaim.org
  • Lighthouse - Accessibility audit in Chrome DevTools
  • Page has one <h1> that describes the page
  • Headings are in logical order (no skipped levels)
  • Landmark regions are used (<header>, <nav>, <main>, <footer>)
  • Skip link provided to bypass navigation
  • All images have appropriate alt text
  • Decorative images have alt=""
  • Complex images have extended descriptions
  • All interactive elements are keyboard accessible
  • Focus indicator is visible
  • Focus order is logical
  • No keyboard traps
  • All inputs have associated labels
  • Required fields are indicated
  • Error messages are associated with inputs
  • Form errors are announced to screen readers
  • Text contrast meets 4.5:1 ratio
  • Information isn’t conveyed by color alone
  • Focus indicators have sufficient contrast
  • ARIA only used when native HTML insufficient
  • ARIA attributes are valid and complete
  • Dynamic content uses aria-live regions