Accessibility Improvements
Accessibility Improvements
Deep analysis and implementation guide for improving WCAG 2.1 AA compliance and overall accessibility.
Table of Contents
- Skip Navigation Link
- Focus Indicator Consistency
- Color Contrast Audit
- Keyboard Navigation Enhancements
- ARIA Live Regions
- Image Alt Text Audit
- Language Toggle Accessibility
- Color Picker Accessibility
- Lightbox Accessibility
1. Skip Navigation Link
Current State
File: _sass/_utilities.scss (lines 99-113)
The .skip-link class is defined in CSS but never instantiated in any layout HTML:
.skip-link {
position: fixed;
z-index: 20;
margin: 0;
font-family: $sans-serif;
white-space: nowrap;
}
The .screen-reader-shortcut:focus styles (lines 80-94) exist to make skip links visible on focus:
.screen-reader-text:focus,
.screen-reader-shortcut:focus {
clip: auto !important;
height: auto !important;
width: auto !important;
display: block;
font-size: 1em;
font-weight: bold;
padding: 15px 23px 14px;
background: #fff;
z-index: 100000;
}
Implementation
Add a skip link as the first focusable element in _layouts/default.html:
<!-- _layouts/default.html - add as first child of <body> -->
<body>
<a class="skip-link screen-reader-shortcut" href="#main">
Skip to main content
</a>
<div class="gradient-mesh" aria-hidden="true">
<!-- ... existing mesh divs ... -->
</div>
<div class="masthead">
<!-- Scroll progress bar -->
<div class="scroll-progress" aria-hidden="true"></div>
<div class="masthead__inner-wrap">
<div class="masthead__menu">
<nav id="site-nav" class="site-nav">
<a class="site-title" href="https://efrenrodriguezrodriguez.com/">Efrén Rodríguez Rodríguez</a>
<button class="nav-toggle" aria-label="Toggle navigation" aria-expanded="false">
<span class="navicon"></span>
</button>
<ul class="nav-links">
<li>
<a href="https://efrenrodriguezrodriguez.com/publications/">
<span class="lang-en" lang="en">Research</span>
<span class="lang-es" lang="es">Investigación</span>
</a>
</li>
<li>
<a href="https://efrenrodriguezrodriguez.com/portfolio/">
<span class="lang-en" lang="en">Projects</span>
<span class="lang-es" lang="es">Proyectos</span>
</a>
</li>
<li>
<a href="https://efrenrodriguezrodriguez.com/talks/">
<span class="lang-en" lang="en">Talks</span>
<span class="lang-es" lang="es">Charlas</span>
</a>
</li>
<li>
<a href="https://efrenrodriguezrodriguez.com/year-archive/">
<span class="lang-en" lang="en">Blog</span>
<span class="lang-es" lang="es">Blog</span>
</a>
</li>
<li>
<a href="https://efrenrodriguezrodriguez.com/cv/">
<span class="lang-en" lang="en">CV</span>
<span class="lang-es" lang="es">CV</span>
</a>
</li>
<li>
<a href="https://efrenrodriguezrodriguez.com/contact/">
<span class="lang-en" lang="en">Contact</span>
<span class="lang-es" lang="es">Contacto</span>
</a>
</li>
<li>
<a href="https://efrenrodriguezrodriguez.com/personal/">
<span class="lang-en" lang="en">Personal</span>
<span class="lang-es" lang="es">Personal</span>
</a>
</li>
</ul>
<button class="search-toggle" aria-label="Search" title="Search">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
</button>
<button class="theme-toggle" aria-label="Toggle dark mode" title="Toggle dark mode">
<svg class="sun-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
<svg class="moon-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</button>
<button class="lang-toggle" aria-label="Toggle language" title="Toggle language">
<span class="lang-label lang-label--en">EN</span>
<span class="lang-label lang-label--es">ES</span>
</button>
<button class="color-toggle" aria-label="Change accent color" aria-expanded="false" aria-controls="color-picker" title="Change accent color">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="4" fill="currentColor"/></svg>
</button>
<div id="color-picker" class="color-picker" role="radiogroup" aria-label="Accent color scheme">
<button class="color-swatch" role="radio" aria-checked="true" data-primary="#52adc8" data-accent="#8B5CF6" aria-label="Ocean Purple" tabindex="0" style="background:linear-gradient(135deg,#52adc8,#8B5CF6)"></button>
<button class="color-swatch" role="radio" aria-checked="false" data-primary="#10b981" data-accent="#06b6d4" aria-label="Emerald Cyan" tabindex="-1" style="background:linear-gradient(135deg,#10b981,#06b6d4)"></button>
<button class="color-swatch" role="radio" aria-checked="false" data-primary="#f59e0b" data-accent="#ef4444" aria-label="Amber Red" tabindex="-1" style="background:linear-gradient(135deg,#f59e0b,#ef4444)"></button>
<button class="color-swatch" role="radio" aria-checked="false" data-primary="#ec4899" data-accent="#8b5cf6" aria-label="Pink Purple" tabindex="-1" style="background:linear-gradient(135deg,#ec4899,#8b5cf6)"></button>
<button class="color-swatch" role="radio" aria-checked="false" data-primary="#6366f1" data-accent="#14b8a6" aria-label="Indigo Teal" tabindex="-1" style="background:linear-gradient(135deg,#6366f1,#14b8a6)"></button>
</div>
</nav>
</div>
</div>
</div>
<!-- ... rest of layout ... -->
Update the CSS to make it properly functional:
// _sass/_utilities.scss - replace .skip-link definition
.skip-link {
position: fixed;
top: -100%;
left: 1rem;
z-index: 10000;
padding: 0.75rem 1.5rem;
background: var(--color-primary, #52adc8);
color: #fff;
font-family: $sans-serif;
font-weight: 600;
font-size: 0.875rem;
text-decoration: none;
border-radius: 0 0 0.5rem 0.5rem;
white-space: nowrap;
transition: top 0.2s ease;
&:focus {
top: 0;
outline: 2px solid var(--color-accent, #8B5CF6);
outline-offset: 2px;
}
}
Ensure the #main target has proper scroll behavior:
// _sass/_base.scss - add
#main:target {
scroll-margin-top: 4rem; // account for sticky masthead
}
2. Focus Indicator Consistency
Current State
Focus styles are defined in multiple places with inconsistent patterns:
| Location | Focus Style | Issues |
|---|---|---|
_utilities.scss:1235 |
outline: 2px solid var(--color-primary) with outline-offset: 3px |
Good - global focus-visible |
_base.scss:131 |
%tab-focus mixin (dotted outline, warning color) |
Conflicts with global style |
_forms.scss:224 |
outline: 0 with box-shadow ring |
Removes outline, shadow only |
_masthead.scss:57 |
Hover state only, no focus-specific style | Missing focus indicator |
_navigation.scss:295 |
Hover underline animation, no focus | Missing focus indicator |
Implementation
Standardize on a single focus style
Remove conflicting focus definitions and ensure the global focus-visible is the baseline:
// _sass/_utilities.scss - keep and enhance the global rule
*:focus-visible {
outline: 2px solid var(--color-primary, #52adc8);
outline-offset: 3px;
border-radius: 2px;
}
// High contrast for dark mode
[data-theme="dark"] *:focus-visible {
outline-color: var(--color-primary, #67d4f1);
}
Fix navigation focus states
// _sass/_navigation.scss - add focus state alongside hover
.nav-links a {
// ... existing styles ...
&:hover,
&:focus-visible {
color: var(--color-primary, $info-color);
&:before {
transform: scaleX(1);
}
}
}
Fix masthead button focus states
// _sass/_masthead.scss - add focus states to toggle buttons
.theme-toggle,
.lang-toggle,
.color-toggle {
// ... existing styles ...
&:hover,
&:focus-visible {
color: var(--color-primary, $info-color);
background: var(--color-background-secondary, $lighter-gray);
}
// Remove transform on focus (rotation can be disorienting)
&:focus-visible {
transform: none;
}
}
Fix form focus states
// _sass/_forms.scss - ensure outline is visible, not just box-shadow
input:focus-visible,
textarea:focus-visible {
border-color: var(--color-primary, $primary-color);
outline: 2px solid var(--color-primary, $primary-color);
outline-offset: -2px;
box-shadow: 0 0 0 3px rgba(82, 173, 200, 0.25);
}
Remove conflicting %tab-focus mixin
In _sass/_mixins.scss, the %tab-focus mixin (lines 5-11) uses a warning color and dotted outline that conflicts with the cleaner global style. Either:
- Remove the mixin entirely
- Or update it to match the global pattern
// _sass/_mixins.scss - update to match global style
%tab-focus {
outline: 2px solid var(--color-primary, #52adc8);
outline-offset: 3px;
}
3. Color Contrast Audit
Current State
File: _sass/_variables.scss
The site offers 5 accent color schemes. Each must meet WCAG AA contrast ratios (4.5:1 for normal text, 3:1 for large text).
Light Mode Analysis (background: #fff)
| Color | Hex | Contrast vs #fff | AA Normal | AA Large |
|---|---|---|---|---|
| Primary (Ocean) | #52adc8 |
~3.2:1 | FAIL | PASS |
| Accent (Purple) | #8B5CF6 |
~3.9:1 | FAIL | PASS |
| Text | #1e293b |
~15:1 | PASS | PASS |
| Text Light | #64748b |
~4.7:1 | PASS | PASS |
Dark Mode Analysis (background: #0f172a)
| Color | Hex | Contrast vs #0f172a | AA Normal | AA Large |
|---|---|---|---|---|
| Primary (Ocean) | #67d4f1 |
~8.5:1 | PASS | PASS |
| Accent (Purple) | #a78bfa |
~5.5:1 | PASS | PASS |
| Text | #e2e8f0 |
~14:1 | PASS | PASS |
| Text Light | #94a3b8 |
~6.5:1 | PASS | PASS |
Issues
Light mode primary color #52adc8 fails AA for normal text. This color is used for:
- Links
- Navigation active state
- Buttons
- Accent highlights
Implementation
Darken the light-mode primary
// _sass/_variables.scss - update light mode primary
:root {
--color-primary: #3a8fa8; // Darkened from #52adc8 (now ~4.6:1 contrast)
--color-accent: #7c3aed; // Darkened from #8B5CF6 (now ~5.5:1 contrast)
}
Audit all 5 accent color schemes
The accent colors are defined in JavaScript (_main.js) and injected as CSS variables. Each scheme must be checked:
// Current accent colors from _main.js
const colorSchemes = {
'ocean-purple': { primary: '#52adc8', accent: '#8B5CF6' }, // Needs fixing
'emerald-cyan': { primary: '#10b981', accent: '#06b6d4' }, // Check contrast
'amber-red': { primary: '#f59e0b', accent: '#ef4444' }, // Check contrast
'pink-purple': { primary: '#ec4899', accent: '#8b5cf6' }, // Check contrast
'indigo-teal': { primary: '#6366f1', accent: '#14b8a6' } // Check contrast
};
Recommended fixes for light mode:
| Scheme | Current Primary | Fixed Primary | Current Accent | Fixed Accent |
|---|---|---|---|---|
| Ocean Purple | #52adc8 |
#3a8fa8 |
#8B5CF6 |
#7c3aed |
| Emerald Cyan | #10b981 |
#059669 |
#06b6d4 |
#0891b2 |
| Amber Red | #f59e0b |
#d97706 |
#ef4444 |
#dc2626 |
| Pink Purple | #ec4899 |
#db2777 |
#8b5cf6 |
#7c3aed |
| Indigo Teal | #6366f1 |
#4f46e5 |
#14b8a6 |
#0d9488 |
Rule of thumb: If a color is used as text on a white background, it needs a contrast ratio of at least 4.5:1. Use WebAIM Contrast Checker to verify each.
4. Keyboard Navigation Enhancements
Current State
File: assets/js/_main.js
- Mobile nav toggle updates
aria-expanded(good) - Sidebar follow button updates
aria-expanded(good) - No keyboard trap prevention for modals
- Color picker swatches lack keyboard arrow navigation
- Scroll-to-top button is keyboard accessible
Implementation
Add Escape key to close mobile nav
// assets/js/_main.js - add to initMobileNav()
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && nav.classList.contains('is-visible')) {
nav.classList.remove('is-visible');
toggle.setAttribute('aria-expanded', 'false');
toggle.focus(); // Return focus to toggle button
}
});
Add keyboard navigation to color picker
// assets/js/_main.js - add to color picker initialization
const swatches = document.querySelectorAll('.color-swatch');
swatches.forEach((swatch, index) => {
swatch.setAttribute('role', 'radio');
swatch.setAttribute('tabindex', index === 0 ? '0' : '-1');
swatch.addEventListener('keydown', function(e) {
let nextIndex;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
e.preventDefault();
nextIndex = (index + 1) % swatches.length;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
e.preventDefault();
nextIndex = (index - 1 + swatches.length) % swatches.length;
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
swatch.click();
return;
} else {
return;
}
swatches[index].setAttribute('tabindex', '-1');
swatches[nextIndex].setAttribute('tabindex', '0');
swatches[nextIndex].focus();
});
});
Also add role="radiogroup" to the color picker container:
<!-- _includes/masthead.html -->
<div class="color-picker" role="radiogroup" aria-label="Accent color">
Focus management after Swup page transitions
// assets/js/_main.js - add to Swup page:view hook
swup.hooks.on('page:view', function() {
// Move focus to the main content area after navigation
const main = document.getElementById('main');
if (main) {
main.setAttribute('tabindex', '-1');
main.focus({ preventScroll: true });
}
});
5. ARIA Live Regions
Current State
No aria-live regions exist. When the language or theme is toggled, screen readers receive no announcement.
Implementation
Add a live region announcer
<!-- _layouts/default.html - add before </body> -->
<div id="sr-announcer" class="visually-hidden" aria-live="polite" aria-atomic="true"></div>
Announce theme changes
// assets/js/_main.js - add to theme toggle handler
function announce(message) {
const announcer = document.getElementById('sr-announcer');
if (announcer) {
announcer.textContent = message;
// Clear after announcement is read
setTimeout(() => { announcer.textContent = ''; }, 1000);
}
}
// In theme toggle:
announce(isDark ? 'Dark mode enabled' : 'Light mode enabled');
// In language toggle:
announce(lang === 'es' ? 'Idioma cambiado a español' : 'Language changed to English');
// In accent color change:
announce('Accent color changed to ' + colorName);
6. Image Alt Text Audit
Current State
File: _includes/archive-single.html (line 73)
Teaser images have empty alt="":
<img src="..." alt="" loading="lazy">
File: _includes/sidebar.html (line 14)
Sidebar images have conditional alt text that may be empty:
alt=""
Implementation
Fix archive teaser alt text
<!-- _includes/archive-single.html - update img tag -->
<img src="..."
alt="Project thumbnail"
loading="lazy"
decoding="async">
Fix sidebar images
<!-- _includes/sidebar.html - provide fallback alt -->
alt="Sidebar image"
Gallery images
Ensure gallery items in front matter always include alt keys:
# In portfolio/publication front matter
gallery1:
- url: webp/image.webp
image_path: webp/image.webp
alt: "Descriptive alt text for the image" # Always provide this
Decorative images
For purely decorative images (gradient orbs, dividers), ensure they have:
<img src="decorative.png" alt="" role="presentation">
7. Language Toggle Accessibility
Current State
File: _includes/masthead.html (line 36)
<button class="lang-toggle" aria-label="Toggle language" title="Toggle language">
Issues:
- No indication of the currently active language
- Screen reader users don’t know which language is selected
- The button label doesn’t describe the action clearly
Implementation
Update button with current state
<!-- _includes/masthead.html -->
<button class="lang-toggle"
aria-label="Switch language"
aria-pressed="false"
data-lang-label-en="Switch to Spanish"
data-lang-label-es="Cambiar a inglés"
title="Switch language">
<span class="lang-en" aria-hidden="true">EN</span>
<span class="lang-es" aria-hidden="true">ES</span>
</button>
Update JavaScript to manage state
// assets/js/_main.js - in language toggle handler
langToggle.addEventListener('click', function() {
const currentLang = document.documentElement.getAttribute('data-lang');
const newLang = currentLang === 'en' ? 'es' : 'en';
document.documentElement.setAttribute('data-lang', newLang);
// Update accessible label
const label = newLang === 'en'
? this.dataset.langLabelEn
: this.dataset.langLabelEs;
this.setAttribute('aria-label', label);
// Announce change
announce(newLang === 'es' ? 'Idioma cambiado a español' : 'Language changed to English');
localStorage.setItem('language', newLang);
});
8. Color Picker Accessibility
Current State
File: _includes/masthead.html (lines 40-49)
<button class="color-toggle" aria-label="Change accent color" title="Change accent color">
<i class="fas fa-palette" aria-hidden="true"></i>
</button>
<div class="color-picker" aria-hidden="true">
<button class="color-swatch" data-color="ocean-purple" aria-label="Ocean Purple" style="..."></button>
<!-- ... 4 more swatches -->
</div>
Issues:
- Swatches lack
role="radio"for group selection semantics - No
aria-checkedto indicate the active color - No keyboard arrow navigation between swatches
aria-hidden="true"on picker doesn’t toggle when opened
Implementation
<!-- _includes/masthead.html - updated color picker -->
<button class="color-toggle"
aria-label="Change accent color"
aria-expanded="false"
aria-controls="color-picker">
<i class="fas fa-palette" aria-hidden="true"></i>
</button>
<div id="color-picker" class="color-picker" role="radiogroup" aria-label="Accent color scheme" hidden>
<button class="color-swatch" role="radio" aria-checked="true"
data-color="ocean-purple" aria-label="Ocean Purple"
tabindex="0" style="..."></button>
<button class="color-swatch" role="radio" aria-checked="false"
data-color="emerald-cyan" aria-label="Emerald Cyan"
tabindex="-1" style="..."></button>
<button class="color-swatch" role="radio" aria-checked="false"
data-color="amber-red" aria-label="Amber Red"
tabindex="-1" style="..."></button>
<button class="color-swatch" role="radio" aria-checked="false"
data-color="pink-purple" aria-label="Pink Purple"
tabindex="-1" style="..."></button>
<button class="color-swatch" role="radio" aria-checked="false"
data-color="indigo-teal" aria-label="Indigo Teal"
tabindex="-1" style="..."></button>
</div>
JavaScript updates:
// Toggle picker visibility
colorToggle.addEventListener('click', function() {
const picker = document.getElementById('color-picker');
const isHidden = picker.hidden;
picker.hidden = !isHidden;
this.setAttribute('aria-expanded', String(isHidden));
if (isHidden) {
// Focus the currently selected swatch
const checked = picker.querySelector('[aria-checked="true"]');
if (checked) checked.focus();
}
});
// Update aria-checked on color selection
function selectColor(swatch) {
document.querySelectorAll('.color-swatch').forEach(s => {
s.setAttribute('aria-checked', 'false');
s.setAttribute('tabindex', '-1');
});
swatch.setAttribute('aria-checked', 'true');
swatch.setAttribute('tabindex', '0');
}
9. Lightbox Accessibility
Current State
File: assets/js/_main.js (lines 160-166)
The lightbox uses a native <dialog> element (good), but needs verification:
// Creates dialog dynamically
const dialog = document.createElement('dialog');
dialog.classList.add('lightbox');
// ... adds image ...
document.body.appendChild(dialog);
dialog.showModal();
Implementation
Ensure the lightbox is fully accessible:
// assets/js/_main.js - enhanced lightbox
function openLightbox(imgSrc, imgAlt) {
const dialog = document.createElement('dialog');
dialog.classList.add('lightbox');
dialog.setAttribute('aria-label', imgAlt || 'Image preview');
const img = document.createElement('img');
img.src = imgSrc;
img.alt = imgAlt || '';
const closeBtn = document.createElement('button');
closeBtn.className = 'lightbox__close';
closeBtn.setAttribute('aria-label', 'Close image preview');
closeBtn.innerHTML = '<i class="fas fa-times" aria-hidden="true"></i>';
closeBtn.addEventListener('click', () => dialog.close());
dialog.appendChild(closeBtn);
dialog.appendChild(img);
document.body.appendChild(dialog);
dialog.showModal();
closeBtn.focus(); // Focus close button on open
// Announce to screen readers
announce('Image preview opened: ' + (imgAlt || 'image'));
dialog.addEventListener('close', function() {
dialog.remove();
// Return focus to the trigger element
if (this._trigger) this._trigger.focus();
announce('Image preview closed');
});
// Close on backdrop click (native dialog behavior)
dialog.addEventListener('click', function(e) {
if (e.target === dialog) dialog.close();
});
}
Testing Checklist
After implementing, verify with these tools:
Automated Testing
# Install axe-cli
npm install -g @axe-core/cli
# Run accessibility audit
axe https://efrenrodriguezrodriguez.com --tags wcag2a,wcag2aa
# Or use Lighthouse
npx lighthouse https://efrenrodriguezrodriguez.com --only-categories=accessibility
Manual Testing
- Tab through entire page - all interactive elements reachable
- Skip link visible on first Tab press
- Focus indicators visible on every interactive element
- Escape closes mobile nav and color picker
- Screen reader announces theme/language changes
- Color picker navigable with arrow keys
- Lightbox traps focus and returns focus on close
- All images have meaningful alt text
- Site usable with 200% browser zoom
- Content readable with Windows High Contrast mode
Screen Readers to Test With
- macOS: VoiceOver (built-in, Cmd+F5)
- Windows: NVDA (free) or JAWS
- Chrome: ChromeVox extension
Priority Matrix
| Improvement | Impact | Effort | Priority |
|---|---|---|---|
| Skip navigation link | High | Low | P0 |
| Focus indicator consistency | High | Medium | P0 |
| Color contrast fixes | High | Low | P0 |
| Image alt text | Medium | Low | P1 |
| ARIA live regions | Medium | Low | P1 |
| Keyboard nav (Escape key) | Medium | Low | P1 |
| Language toggle state | Medium | Low | P1 |
| Color picker a11y | Medium | Medium | P2 |
| Lightbox accessibility | Medium | Medium | P2 |
| Focus management after Swup | Low | Medium | P2 |