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.
Last updated February 23, 2026
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:
| Milestone | GDPR | EAA |
|---|---|---|
| Adopted | April 2016 | June 2019 (Directive 2019/882) |
| Enforcement date | May 25, 2018 | June 28, 2025 |
| Grace period used by most companies | Ignored until ~6 months before | Ignored until ~6 months before |
| First wave of enforcement | 2019 — France fined Google $57M | 2025 — France sued 4 retailers |
| Cumulative fines (first 5 years) | $4.5B+ | TBD — but the curve will be identical |
| Extraterritorial scope | Yes — applies to any org processing EU data | Yes — 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:
| Country | Maximum Fine | Notable Details |
|---|---|---|
| Germany | $500,000 | Per violation |
| France | $250,000 | Plus mandatory public disclosure |
| Italy | 5% of annual turnover | 90-day remediation window |
| Spain | $1,000,000 | Graduated severity scale |
| Ireland | $60,000 | Plus up to 18 months imprisonment |
| Norway | Daily 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:
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 costsThat'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.
Violation 1: Inaccessible Cookie Consent Banner
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:
<!-- 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:
<!-- 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 rolearia-labelledbyandaria-describedbyprovide 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:
// 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:
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>withhtmlForprovides persistent, visible labels (placeholders disappear on input)autoCompleteattributes identify input purpose for assistive techaria-invalidandaria-describedbyconnect errors to their fieldsrole="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:
/* 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:
/* 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:
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 — FAILViolation 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:
// 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:
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:
useRouteAnnouncerhook moves focus to<main>on every route changearia-live="assertive"region announces the new page titletabIndex={-1}on<main>makes it focusable without adding to tab orderaria-labelon<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:
<!-- 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:
<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>.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:
// 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:
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:
alttext describes the image content, not just "product image"aria-labelon 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:
// 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:
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"witharia-live="assertive"announces errors immediately- Focus moves to the error message so keyboard users know what happened
- Persistent
<label>elements (not placeholder-only) autoCompleteattributes 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-motionmedia 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:
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.
# Run a full UX and accessibility audit
npx vertaaux audit https://your-product.euIntegrate it into your CI/CD pipeline:
# .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.jsonThis 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 →