4 min read

Enforcing Accessibility in Code, Not Just Culture

In the best of organizations, I’ve seen accessibility often treated as a cultural value. It's something that’s cared about and something folks try and prioritize. But like tests, types, and lint rules, it can also be enforced.

Although Looping isn’t live yet and doesn't have any users or a launch date, I’ve just finished a complete accessibility (a11y) overhaul of the frontend architecture (built in Vue, but the patterns apply broadly). I invested this time now because I didn’t want accessibility to be built on shaky ground in the future. I wanted accessibility to be foundational.

But this post isn’t about Looping, it’s about the architecture I put in place and how you can apply some of the same patterns to your own app.

If you’re building a frontend and care about getting accessibility right—or want to start caring—I hope this helps.

What I Built

Over the past few weeks, I:

  • Centralized all screen reader announcements using a Pinia store
  • Structured forms with consistent ARIA patterns
  • Migrated every component to the new architecture
  • Added accessibility-specific rules to Cursor so they’re enforced in the editor
  • Wrote tests for every expected screen reader announcement and interaction
  • Verified support across Safari and Chromium with VoiceOver

Centralized Announcements

Rather than having aria-live regions in individual components, I'm using a single global announcer, fed by a useAccessibilityAnnouncementsStore() Pinia store.

Here’s the pattern:

const announcementsStore = useAccessibilityAnnouncementsStore()
const toastsStore = useToastsStore()

announcementsStore.announceSuccess('accessibility.nameUpdated')
toastsStore.addSuccess('message.nameUpdatedSuccessfully')

components/Account/Settings/UpdateName.vue

Visual and screen reader feedback serve different users, and both are required. Cursor enforces this pattern by flagging toasts without corresponding announcements, and vice versa.

All messages go through an i18n layer, and the announcement and toast stores handle translation internally. Passing raw strings is blocked.

Accessible Forms, Standardized

Forms required the most restructuring. I created ARIA helpers using a createAriaHelpers() helper to generate consistent IDs and associations across labels, help text, and errors:

const createAriaHelpers = (baseId) => {
  const id = baseId || generateId()

  return {
    id,
    describedBy: `${id}-description`,
    labelledBy: `${id}-label`,
    errorId: `${id}-error`,
    helpId: `${id}-help`,

    // Helper functions for common patterns
    getFieldAttributes: (hasError = false) => ({
      id,
      'aria-describedby': hasError ? `${id}-error` : `${id}-help`,
      'aria-invalid': hasError
    }),

    getErrorAttributes: () => ({
      id: `${id}-error`,
      role: 'alert'
    }),

    getHelpAttributes: () => ({
      id: `${id}-help`
    })
  }
}

utils/accessibility.js

Here’s what that looks like in practice for a single name field:

import { useAccessibility } from '~/utils/accessibility.js'
const accessibility = useAccessibility()

const nameAriaHelpers = accessibility.createAriaHelpers('name-field')

components/Account/Settings/UpdateName.vue

And in the template:

<fieldset :disabled="isSubmitting">
  <legend class="sr-only">{{ $t('accessibility.updateNameForm') }}</legend>

  <div v-if="formError" class="notification is-danger">
    {{ formError }}
  </div>

  <div class="field">
    <label class="label" :for="nameAriaHelpers.id">
      {{ $t('message.forms.name') }}
      <span class="sr-only">{{ $t('accessibility.fieldRequired') }}</span>
    </label>
    <div class="control">
      <Field name="name" v-slot="{ field, meta }">
        <input
          v-bind="field"
          type="text"
          class="input"
          :class="{ 'is-invalid': !!errors.name }"
          :id="nameAriaHelpers.id"
          :aria-describedby="errors.name ? nameAriaHelpers.errorId : nameAriaHelpers.helpId"
          :aria-invalid="!!errors.name"
          :aria-required="true"
        />
      </Field>
    </div>
    <div :id="nameAriaHelpers.helpId" class="sr-only">
      {{ $t('accessibility.nameHelp') }}
    </div>
    <ErrorMessage name="name" v-slot="{ message }">
      <div :id="nameAriaHelpers.errorId" class="help is-danger">
        {{ message }}
      </div>
    </ErrorMessage>
  </div>

  ...
</fieldset>

components/Account/Settings/UpdateName.vue

Every field follows this pattern: consistent ARIA structure, clear associations between input, labels, and errors, and screen reader-only text to communicate required fields and additional help. All IDs are generated once and reused, reducing human error and making testing easier.

This structure is also enforced via Cursor rules: missing aria-describedby, unlabeled fields, and raw IDs are all flagged before code can ship.

Enforced as Rules

Looping’s accessibility patterns aren’t just in my head, they’re written as always-on Cursor rules in .cursor/rules/accessibility-guidelines.mdc.

Some examples:

  • ❌ Disallow announceToScreenReader()
    All announcements must go through useAccessibilityAnnouncementsStore().
  • ❌ Disallow raw strings in announcements
    Only translation keys (from the accessibility namespace) are allowed.
  • ✅ Require dual feedback
    Any time toastsStore.addSuccess() is called, there must be a matching announcementsStore.announceSuccess().
  • ✅ Enforce structured forms
    Forms must use fieldset, legend, aria-describedby, and screen reader indicators for required fields.
  • ✅ Require accessible buttons
    Buttons must support aria-label, aria-busy, and reflect current state.

Cursor always surfaces these issues.

Fully Tested

Every component includes accessibility tests using @testing-library/vue and mocked announcements:

expect(mockAnnouncementsStore.announceSuccess).toHaveBeenCalledWith('accessibility.nameUpdated')

test/components/Account/Settings/UpdateName.test.js

I also manually tested across Safari and Chromium with VoiceOver. That revealed issues with Vue’s reactivity and :key mismatches I had in an initial version that broke screen reader updates—issues that were invisible to sighted users but critical for accessibility.

Quick Wins You Can Borrow

Even if you’re not ready for a full overhaul, here are some lightweight improvements you can copy:

  • Mirror toasts with screen reader announcements
    Use a centralized announcement system instead of scattering aria-live regions.
  • Add aria-busy and a screen reader-only span to submit buttons
    Help screen reader users understand when a form is processing.
  • Use aria-describedby to connect inputs with help or error text
    Especially important when showing inline validation.
  • Add tests for screen reader announcements
    They’re just as important as visual UI changes—and easier to miss.

Was This Over-Engineered?

If you’re coming from a place where accessibility means manually adding a few ARIA labels here and there, this might seem like a lot. But the goal wasn’t to over-engineer, it was to reduce friction later.

I’ve built enough frontends to know how easy it is to forget accessibility when it isn’t visible. By centralizing announcements, standardizing form structure, and enforcing rules in-editor, I’ve made it harder to do the wrong thing accidentally.

Accessibility Isn’t an Add-On

Looping’s accessibility system isn’t a plugin or a checklist. It’s part of the frontend architecture, just like routing, state management, or internationalization.

And once the patterns are in place, accessibility isn’t harder, it’s just part of how I continue to build the fronted moving forward.