Swoff Swoff

Accessibility

Make offline-first apps accessible to all users — screen readers, keyboard navigation, and assistive technology.

Accessibility

Offline-first apps must be accessible to everyone. This guide covers general web accessibility and PWA-specific considerations.

General Web Accessibility

These principles apply to all web apps, including offline-first:

Semantic HTML

Use proper HTML elements:

<!-- Bad -->
<div onclick="submit()">Submit</div>

<!-- Good -->
<button type="submit">Submit</button>
<!-- Bad -->
<div class="header">Title</div>

<!-- Good -->
<header>Title</header>

Focus Management

Ensure interactive elements are focusable:

/* Always visible focus indicator */
:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}

/* Remove default outline for mouse users */
:focus:not(:focus-visible) {
  outline: none;
}

Color Contrast

Meet WCAG AA standards (4.5

for normal text):

/* Ensure sufficient contrast */
.text-primary {
  color: #1a1a2e; /* Dark on light - passes AA */
}

.text-warning {
  color: #f59e0b; /* Must have dark background */
  background: #1a1a2e;
}

ARIA Labels

Add context for screen readers:

<!-- Icon-only button needs label -->
<button aria-label="Close menu">
  <svg>...</svg>
</button>

<!-- Form inputs need labels -->
<label for="email">Email</label>
<input id="email" type="email" />

<!-- Status updates need aria-live -->
<div aria-live="polite" aria-atomic="true">
  {syncStatus}
</div>

Offline-First Specific

These considerations are unique to offline/PWA apps:

Screen Readers + Service Workers

Service Workers are transparent to screen readers:

// Screen reader users browse normally
// SW caches content, but it's just "content on the page"
// No special ARIA needed for SW itself

// What DOES need attention:
function updateUIForOffline() {
  // Announce to screen reader
  const status = document.getElementById('status');
  status.setAttribute('aria-live', 'polite');
  status.textContent = 'You are now offline. Changes will sync when reconnected.';
}

Offline Status Announcements

Use aria-live regions for dynamic updates:

function OfflineBanner() {
  const isOnline = useNetworkStatus();

  return (
    <div
      role="status"
      aria-live="polite"
      aria-atomic="true"
      className={isOnline ? 'hidden' : 'visible'}
    >
      <span className="sr-only">Notice: </span>
      You are offline. Your changes will be saved and synced when you reconnect.
    </div>
  );
}

Important: Use aria-live="polite" (not "assertive") so users aren't interrupted.

Sync Status Indicators

Make pending sync count accessible:

function SyncIndicator() {
  const { pendingCount } = useMutationQueue();

  if (pendingCount === 0) return null;

  return (
    <div
      role="status"
      aria-live="polite"
      aria-label={`${pendingCount} changes pending synchronization`}
    >
      <span aria-hidden="true">🔄</span>
      <span className="sr-only">{pendingCount} changes pending</span>
    </div>
  );
}

// Add sr-only CSS class
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

Keyboard Navigation in Offline Mode

Ensure all functionality works with keyboard:

function OfflineTodoApp() {
  // All interactions must be keyboard accessible
  return (
    <div>
      {/* Buttons work with Enter/Space */}
      <button onClick={addTodo}>Add task</button>

      {/* Forms work with Tab and Enter */}
      <form onSubmit={handleSubmit}>
        <input />
        <button type="submit">Save</button>
      </form>

      {/* Dialogs manage focus correctly */}
      {showDialog && (
        <Dialog onClose={close}>
          {/* Focus trapped in dialog */}
          <button onClick={close}>Close</button>
        </Dialog>
      )}
    </div>
  );
}

Focus Management After Transitions

Restore focus after navigation:

function App() {
  const mainRef = useRef(null);

  useEffect(() => {
    // On route change, move focus to main content
    mainRef.current?.focus();
  }, [location]);

  return (
    <main ref={mainRef} tabIndex={-1}>
      <Router>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/app" element={<App />} />
        </Routes>
      </Router>
    </main>
  );
}

Reduced Motion

Respect prefers-reduced-motion:

@media (prefers-reduced-motion: reduce) {
  /* Disable SW progress animations */
  .sw-progress-bar {
    transition: none;
    animation: none;
  }

  /* Disable cache transition animations */
  .cache-updated {
    transition: none;
  }
}

PWA Install Accessibility

Make install prompt accessible:

function InstallPrompt({ onAccept, onDismiss }) {
  return (
    <dialog open>
      <div role="alertdialog" aria-labelledby="install-title" aria-describedby="install-desc">
        <h2 id="install-title">Install App</h2>
        <p id="install-desc">Install this app for offline access and better performance.</p>
        <button onClick={onAccept}>Install</button>
        <button onClick={onDismiss} aria-label="Dismiss">Not now</button>
      </div>
    </dialog>
  );
}

Touch Targets

Ensure adequate size for touch (44x44px minimum):

/* Buttons and interactive elements */
button,
a,
input[type="checkbox"] {
  min-height: 44px;
  min-width: 44px;
  padding: 12px;
}

Testing Accessibility

Automated Testing

# Install axe-core
npm install @axe-core/react

// In test
import { axe } from "jest-axe";

test("should have no accessibility violations", async () => {
  const { container } = render(<App />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Manual Testing Checklist

TestHow
Screen readerNavigate with VoiceOver (Mac) or NVDA (Windows)
KeyboardTab through all elements, use Enter/Space/Escape
ZoomTest at 200% zoom
Reduced motionEnable in system preferences, verify no animations

Common Issues in Offline Apps

IssueFix
Status not announcedAdd aria-live regions
Form errors hiddenUse aria-describedby for error messages
Focus lost after dialogReturn focus to trigger element
Loading states unclearAdd aria-busy and accessible labels

Accessibility Checklist

  • All images have alt text
  • Form inputs have labels
  • Color contrast meets 4.5
  • Focus indicators visible
  • Keyboard navigation works
  • Offline status announced with aria-live
  • Sync status accessible to screen readers
  • Focus managed after transitions
  • Reduced motion respected
  • Touch targets 44x44px minimum

Next Steps

On this page