Skip to main content

VertaaUX Articles

The European Accessibility Act Is the New GDPR — And You're Already Late

The EAA is now law. Fines are rolling in. France sued four major retailers. Norway imposed daily penalties on a health app. If your digital products serve EU customers and aren't WCAG 2.1 AA compliant, you're already in violation. Here's what to fix — with code.

Petri Lahdelma18 min read18 min remaining

Last updated February 23, 2026

AccessibilityGDPREAAWCAGComplianceEnterprise
Run This On Your SiteListen: Unavailable

In November 2025, two French disability rights organizations filed emergency injunctions against four of France's largest grocery retailers — Auchan, Carrefour, E. Leclerc, and Picard Surgeles. The charge: digital discrimination against 2 million visually impaired people in France who cannot use these retailers' websites and mobile apps to shop independently.

In Norway, the Health Authority imposed compulsory daily fines of 50,000 kroner (~$5,360/day) on HelsaMi, a medical app used by nearly half a million residents, for failing to meet digital accessibility standards.

Sweden's Post and Telecom Agency launched its first regulatory cases targeting e-commerce. The Netherlands warned companies that incomplete compliance reports will trigger priority audits in early 2026. Italy began issuing 90-day remediation notices with fines up to 5% of annual turnover.

The European Accessibility Act is now law. Enforcement has begun. And most organizations are in the denial phase.

If you've been through GDPR, you've seen this movie before.


We've Seen This Movie Before

The GDPR and EAA timelines are almost identical:

MilestoneGDPREAA
AdoptedApril 2016June 2019 (Directive 2019/882)
Enforcement dateMay 25, 2018June 28, 2025
Grace period used by most companiesIgnored until ~6 months beforeIgnored until ~6 months before
First wave of enforcement2019 — France fined Google $57M2025 — France sued 4 retailers
Cumulative fines (first 5 years)$4.5B+TBD — but the curve will be identical
Extraterritorial scopeYes — applies to any org processing EU dataYes — applies to any org serving EU customers

Here's the number that should concern every executive: 5,100+ digital accessibility lawsuits were filed globally in 2025, a 20% increase over 2024. And that's before EAA enforcement reached full speed.

Companies that waited until GDPR enforcement to begin compliance work spent 3-5x more than those who prepared during the grace period. The same dynamic is unfolding right now with the EAA.

The question isn't whether your organization will need to comply. It's whether you'll do it proactively — or under a court order.


What Executives Miss

If you're forwarding this article to your leadership team, this is the section that matters.

1. It's Extraterritorial

Like GDPR, the EAA applies based on where your customers are, not where your company is headquartered. If you sell digital products or services to anyone in the EU, you must comply. US companies, APAC companies, everyone.

2. The Fines Are Real and Varied

Each EU member state sets its own penalty structure:

CountryMaximum FineNotable Details
Germany$500,000Per violation
France$250,000Plus mandatory public disclosure
Italy5% of annual turnover90-day remediation window
Spain$1,000,000Graduated severity scale
Ireland$60,000Plus up to 18 months imprisonment
NorwayDaily fines ($5,360+/day)Accumulating until remediation

3. Products Can Be Banned Entirely

This is the one executives don't see coming. Member states can prohibit the distribution of non-compliant products and services on their national market. Not just a fine — a market ban.

4. Daily Penalties Compound Fast

Norway's HelsaMi case is instructive. After the initial compliance order, daily fines of 50,000 kroner began accumulating. That's over $150,000/month of inaction. Multiply this across multiple products and markets, and the exposure grows exponentially.

5. The Risk Exposure Math

Here's a simplified formula every executive should run:

Code
Risk Exposure = Digital Products x Avg Violations x Fine Per Violation

Example (mid-size SaaS):
  3 products (web app, mobile app, customer portal)
  x 15 violations each (average for an unaudited product)
  x $10,000 per violation (conservative EU average)
  = $450,000 minimum exposure

  + daily penalties if not remediated
  + potential market ban
  + reputational damage
  + legal costs

That's the conservative estimate. The real number is usually 3-5x higher.


The 7 Violations Hiding in Your Codebase

Here's where we get technical. These are the most common EAA violations we see in production codebases, along with the exact code to fix them.

EAA Requirement: EN 301 549 (WCAG 2.1.1 Keyboard, 4.1.2 Name Role Value)

This one is a double violation — it breaks both the EAA and GDPR simultaneously. An inaccessible consent banner means users with disabilities cannot give or withhold consent. Under GDPR, that means you don't have valid consent for data processing.

Before — the violation:

HTML
<!-- No keyboard navigation, no screen reader support -->
<div class="cookie-banner" style="position: fixed; bottom: 0;">
  <p>We use cookies to improve your experience.</p>
  <div class="cookie-buttons">
    <div onclick="acceptCookies()" class="btn-accept">Accept All</div>
    <div onclick="rejectCookies()" class="btn-reject">Reject</div>
  </div>
</div>

After — the fix:

HTML
<!-- Keyboard accessible, screen reader announced, focus trapped -->
<dialog
  id="cookie-consent"
  role="alertdialog"
  aria-labelledby="cookie-title"
  aria-describedby="cookie-desc"
  aria-modal="true"
>
  <h2 id="cookie-title">Cookie Preferences</h2>
  <p id="cookie-desc">
    We use cookies to improve your experience.
    You can accept all or reject non-essential cookies.
  </p>
  <div class="cookie-actions">
    <button onclick="rejectCookies()" class="btn-secondary">
      Reject Non-Essential
    </button>
    <button onclick="acceptCookies()" class="btn-primary" autofocus>
      Accept All
    </button>
  </div>
</dialog>

Key changes:

  • <dialog> element provides native focus trapping and keyboard dismiss (Escape)
  • role="alertdialog" announces the banner to screen readers on open
  • <button> instead of <div> gives keyboard access and implicit role
  • aria-labelledby and aria-describedby provide context
  • Reject button is equally prominent (GDPR requirement)

Violation 2: Missing Form Labels and Autocomplete

EAA Requirement: EN 301 549 (WCAG 1.3.5 Identify Input Purpose, 3.3.2 Labels or Instructions)

Before — the violation:

TSX
// Placeholder-only inputs — invisible to screen readers
function CheckoutForm() {
  return (
    <form>
      <input type="text" placeholder="Full name" />
      <input type="text" placeholder="Email" />
      <input type="text" placeholder="Address" />
      <input type="text" placeholder="City" />
      <span className="error">Please fill in all fields</span>
    </form>
  );
}

After — the fix:

TSX
function CheckoutForm() {
  const [errors, setErrors] = useState<Record<string, string>>({});

  return (
    <form noValidate>
      <div className="field">
        <label htmlFor="name">Full name</label>
        <input
          id="name"
          type="text"
          autoComplete="name"
          aria-required="true"
          aria-invalid={!!errors.name}
          aria-describedby={errors.name ? "name-error" : undefined}
        />
        {errors.name && (
          <span id="name-error" role="alert">{errors.name}</span>
        )}
      </div>

      <div className="field">
        <label htmlFor="email">Email address</label>
        <input
          id="email"
          type="email"
          autoComplete="email"
          aria-required="true"
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? "email-error" : undefined}
        />
        {errors.email && (
          <span id="email-error" role="alert">{errors.email}</span>
        )}
      </div>

      <div className="field">
        <label htmlFor="address">Street address</label>
        <input
          id="address"
          type="text"
          autoComplete="street-address"
          aria-required="true"
        />
      </div>

      <div className="field">
        <label htmlFor="city">City</label>
        <input
          id="city"
          type="text"
          autoComplete="address-level2"
          aria-required="true"
        />
      </div>
    </form>
  );
}

Key changes:

  • <label> with htmlFor provides persistent, visible labels (placeholders disappear on input)
  • autoComplete attributes identify input purpose for assistive tech
  • aria-invalid and aria-describedby connect errors to their fields
  • role="alert" ensures errors are announced immediately

Violation 3: Insufficient Color Contrast

EAA Requirement: EN 301 549 (WCAG 1.4.3 Contrast Minimum)

The minimum contrast ratios: 4.5:1 for normal text, 3:1 for large text (18px+ or 14px+ bold).

Before — the violation:

CSS
/* These all fail WCAG contrast requirements */
.muted-text {
  color: #999999; /* on white: 2.85:1 — FAIL */
}

.placeholder {
  color: #aaaaaa; /* on white: 2.32:1 — FAIL */
}

.button-secondary {
  color: #7c7c7c; /* on #f5f5f5: 2.96:1 — FAIL */
  background: #f5f5f5;
}

.link {
  color: #5b9bd5; /* on white: 3.07:1 — FAIL for normal text */
}

After — the fix:

CSS
/* All pass WCAG 1.4.3 contrast requirements */
:root {
  --color-text-muted: #595959;    /* on white: 7.0:1 — PASS AAA */
  --color-text-placeholder: #767676; /* on white: 4.54:1 — PASS AA */
  --color-surface-subtle: #f5f5f5;
  --color-text-on-subtle: #4a4a4a; /* on #f5f5f5: 5.74:1 — PASS AA */
  --color-link: #1a6fb5;           /* on white: 5.08:1 — PASS AA */
}

.muted-text {
  color: var(--color-text-muted);
}

.placeholder {
  color: var(--color-text-placeholder);
}

.button-secondary {
  color: var(--color-text-on-subtle);
  background: var(--color-surface-subtle);
}

.link {
  color: var(--color-link);
}

You can calculate contrast ratios programmatically:

Code
function getContrastRatio(rgb1: [number, number, number], rgb2: [number, number, number]): number {
  const luminance = (rgb: [number, number, number]) => {
    const [r, g, b] = rgb.map((c) => {
      const s = c / 255;
      return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
    });
    return 0.2126 * r + 0.7152 * g + 0.0722 * b;
  };

  const l1 = luminance(rgb1);
  const l2 = luminance(rgb2);
  const lighter = Math.max(l1, l2);
  const darker = Math.min(l1, l2);

  return (lighter + 0.05) / (darker + 0.05);
}

// Example usage
const ratio = getContrastRatio([153, 153, 153], [255, 255, 255]);
console.log(ratio); // 2.85 — FAIL

Violation 4: No Focus Management After SPA Route Changes

EAA Requirement: EN 301 549 (WCAG 2.4.3 Focus Order)

Single-page applications (React, Next.js, Vue) don't trigger a full page load on navigation. Without explicit focus management, keyboard and screen reader users get lost after every route change.

Before — the violation:

TSX
// Focus stays on the clicked link after navigation
// Screen reader users have no idea the page changed
function App() {
  return (
    <Router>
      <nav>
        <Link to="/dashboard">Dashboard</Link>
        <Link to="/settings">Settings</Link>
      </nav>
      <Routes>
        <Route path="/dashboard" element={<Dashboard />} />
        <Route path="/settings" element={<Settings />} />
      </Routes>
    </Router>
  );
}

After — the fix:

TSX
import { useEffect, useRef } from "react";
import { useLocation } from "react-router-dom";

function useRouteAnnouncer() {
  const location = useLocation();
  const mainRef = useRef<HTMLElement>(null);

  useEffect(() => {
    // Move focus to main content on route change
    if (mainRef.current) {
      mainRef.current.focus();
    }

    // Announce the new page to screen readers
    const title = document.title;
    const announcer = document.getElementById("route-announcer");
    if (announcer) {
      announcer.textContent = "Navigated to " + title;
    }
  }, [location.pathname]);

  return mainRef;
}

function App() {
  const mainRef = useRouteAnnouncer();

  return (
    <Router>
      {/* Screen reader announcement region */}
      <div
        id="route-announcer"
        role="status"
        aria-live="assertive"
        aria-atomic="true"
        className="sr-only"
      />

      <nav aria-label="Main navigation">
        <Link to="/dashboard">Dashboard</Link>
        <Link to="/settings">Settings</Link>
      </nav>

      <main ref={mainRef} tabIndex={-1}>
        <Routes>
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/settings" element={<Settings />} />
        </Routes>
      </main>
    </Router>
  );
}

Key changes:

  • useRouteAnnouncer hook moves focus to <main> on every route change
  • aria-live="assertive" region announces the new page title
  • tabIndex={-1} on <main> makes it focusable without adding to tab order
  • aria-label on <nav> provides landmark context

Violation 5: Missing Skip Navigation

EAA Requirement: EN 301 549 (WCAG 2.4.1 Bypass Blocks)

Without a skip link, keyboard users must tab through your entire header, navigation, and sidebar on every single page load. On a typical enterprise app, that's 40+ tab stops before reaching the main content.

Before — the violation:

HTML
<!-- No way to bypass the navigation -->
<body>
  <header>
    <nav>
      <!-- 25 links in the navigation -->
    </nav>
  </header>
  <aside>
    <!-- 15 links in the sidebar -->
  </aside>
  <main>
    <!-- User must tab 40+ times to reach this -->
  </main>
</body>

After — the fix:

HTML
<body>
  <a href="#main-content" class="skip-link">
    Skip to main content
  </a>
  <header>
    <nav aria-label="Main navigation">
      <!-- 25 links -->
    </nav>
  </header>
  <aside aria-label="Sidebar">
    <!-- 15 links -->
  </aside>
  <main id="main-content" tabindex="-1">
    <!-- Immediately accessible via skip link -->
  </main>
</body>
CSS
.skip-link {
  position: absolute;
  top: -100%;
  left: 0;
  z-index: 9999;
  padding: 1rem 1.5rem;
  background: #000;
  color: #fff;
  font-weight: 600;
  text-decoration: none;
  transition: top 0.2s;
}

.skip-link:focus-visible {
  top: 0;
}

The skip link is invisible until a keyboard user hits Tab on page load — then it appears at the top of the viewport as the first focusable element.


Violation 6: Images and Icons Without Accessible Names

EAA Requirement: EN 301 549 (WCAG 1.1.1 Non-text Content)

Before — the violation:

TSX
// Images with no alt text, icon buttons with no labels
function ProductCard({ product }: { product: Product }) {
  return (
    <div className="card">
      <img src={product.image} />
      <h3>{product.name}</h3>
      <p>{product.price}</p>
      <div className="actions">
        <button onClick={addToCart}>
          <svg viewBox="0 0 24 24">
            <path d="M7 18c-1.1 0-1.99.9-1.99 2S5.9 22 7 22s2-.9 2-2-.9-2-2-2z" />
          </svg>
        </button>
        <button onClick={addToWishlist}>
          <svg viewBox="0 0 24 24">
            <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
          </svg>
        </button>
      </div>
    </div>
  );
}

After — the fix:

TSX
function ProductCard({ product }: { product: Product }) {
  return (
    <article className="card">
      <img
        src={product.image}
        alt={product.name + " — " + product.color + " variant"}
      />
      <h3>{product.name}</h3>
      <p>{product.price}</p>
      <div className="actions">
        <button onClick={addToCart} aria-label={"Add " + product.name + " to cart"}>
          <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
            <path d="M7 18c-1.1 0-1.99.9-1.99 2S5.9 22 7 22s2-.9 2-2-.9-2-2-2z" />
          </svg>
        </button>
        <button onClick={addToWishlist} aria-label={"Save " + product.name + " to wishlist"}>
          <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
            <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
          </svg>
        </button>
      </div>
    </article>
  );
}

Key changes:

  • alt text describes the image content, not just "product image"
  • aria-label on icon buttons provides context — "Add Nike Air Max to cart" not just "cart"
  • aria-hidden="true" on SVGs prevents duplicate announcements
  • <article> instead of <div> provides semantic card structure

Violation 7: Inaccessible Error Messages

EAA Requirement: EN 301 549 (WCAG 4.1.3 Status Messages)

Before — the violation:

TSX
// Error appears visually but is never announced
function LoginForm() {
  const [error, setError] = useState("");

  return (
    <form onSubmit={handleSubmit}>
      <input type="email" placeholder="Email" />
      <input type="password" placeholder="Password" />
      {error && <div className="error-text">{error}</div>}
      <button type="submit">Sign in</button>
    </form>
  );
}

After — the fix:

TSX
function LoginForm() {
  const [error, setError] = useState("");
  const errorRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (error && errorRef.current) {
      errorRef.current.focus();
    }
  }, [error]);

  return (
    <form onSubmit={handleSubmit} noValidate>
      <div className="field">
        <label htmlFor="login-email">Email address</label>
        <input
          id="login-email"
          type="email"
          autoComplete="email"
          aria-required="true"
        />
      </div>

      <div className="field">
        <label htmlFor="login-password">Password</label>
        <input
          id="login-password"
          type="password"
          autoComplete="current-password"
          aria-required="true"
        />
      </div>

      {/* Live region — announced automatically when content changes */}
      <div
        ref={errorRef}
        role="alert"
        aria-live="assertive"
        tabIndex={-1}
        className={error ? "error-banner" : "sr-only"}
      >
        {error}
      </div>

      <button type="submit">Sign in</button>
    </form>
  );
}

Key changes:

  • role="alert" with aria-live="assertive" announces errors immediately
  • Focus moves to the error message so keyboard users know what happened
  • Persistent <label> elements (not placeholder-only)
  • autoComplete attributes for password managers

The EAA Compliance Checklist

Use this checklist to assess your current exposure. The severity levels reflect how enforcement agencies are prioritizing audits.

Critical — Blocks Compliance

These are the violations enforcement agencies check first:

  • All interactive elements are keyboard accessible (no mouse-only interactions)
  • Color contrast meets 4.5:1 for text, 3:1 for large text and UI components
  • All form inputs have programmatic labels (not placeholder-only)
  • Cookie/consent dialogs are fully keyboard and screen reader accessible
  • Content is accessible without relying solely on color to convey information

High — Likely Flagged in Enforcement Audit

These will appear in any formal accessibility audit:

  • Focus is managed correctly on SPA route changes
  • Skip navigation link is present and functional
  • Error messages are announced to assistive technology
  • All images have appropriate text alternatives
  • Page language is declared (<html lang="...">)
  • Heading hierarchy is logical (no skipped levels)

Medium — Best Practice, Reduces Exposure

These demonstrate compliance maturity:

  • ARIA landmarks on all pages (<main>, <nav>, <aside>)
  • Touch targets are at least 44x44px on mobile
  • prefers-reduced-motion media query is respected
  • Focus indicators are clearly visible (not just outline: none)
  • Content reflows correctly at 400% zoom
  • Captions provided for video content

How to Audit Programmatically

You can run a baseline check with axe-core:

TS
import { AxeBuilder } from "@axe-core/playwright";
import { test, expect } from "@playwright/test";

test("homepage has no critical accessibility violations", async ({ page }) => {
  await page.goto("https://your-product.eu");

  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
    .analyze();

  // Log violations for debugging
  for (const violation of results.violations) {
    console.log(
      "[" + violation.impact + "] " + violation.id + ": " + violation.description
    );
    console.log("  Affected: " + violation.nodes.length + " element(s)");
  }

  expect(results.violations.filter((v) => v.impact === "critical")).toHaveLength(0);
});

But remember — axe-core catches 20-35% of WCAG issues. The violations above (cookie banners, SPA focus management, error announcements) often require deeper analysis.


Automate or Drown

The manual approach to EAA compliance looks like this:

  • Timeline: 2-4 weeks per product
  • Cost: $15,000-50,000 per audit (external consultancy)
  • Result: A PDF report that's outdated by the time you read it
  • Repeat: Every release, every product, every market

For organizations with multiple digital products, this is unsustainable. You're paying for point-in-time snapshots while shipping new violations with every sprint.

The alternative is continuous compliance monitoring. Run audits on every deploy. Catch violations before they reach production. Track compliance trends over time.

BASH
# Run a full UX and accessibility audit
npx vertaaux audit https://your-product.eu

Integrate it into your CI/CD pipeline:

YAML
# .github/workflows/accessibility.yml
name: EAA Compliance Check

on:
  pull_request:
    branches: [main]

jobs:
  accessibility-audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Start application
        run: npm run build && npm start &

      - name: Run VertaaUX audit
        run: npx vertaaux audit http://localhost:3000
          --format json
          --fail-on critical
          --output audit-results.json

      - name: Upload results
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: accessibility-audit
          path: audit-results.json

This catches violations in pull requests — before they ever reach production, before they ever reach enforcement agencies.


The Bottom Line

The European Accessibility Act is not a future concern. It's current law with active enforcement. France, Norway, Sweden, the Netherlands, and Italy have already taken action. Germany, Spain, and Ireland have penalty frameworks ready.

The companies that treated GDPR as a checkbox exercise — scrambling after enforcement began, paying consultants emergency rates, settling regulatory actions — paid the highest fines and spent the most money.

The EAA enforcement curve will be identical. The only variable is where your organization lands on it.

Start with the 7 violations above. They cover the most common failures we see across production codebases. Fix them, and you've addressed the majority of what enforcement audits are checking today.

Then automate. Because compliance isn't a one-time project — it's an ongoing requirement for every release, every product, and every market you serve.


VertaaUX detects EAA and WCAG violations automatically across your digital products. One URL. One audit. Full compliance visibility. Run your first audit free →