Skip to Content
Gutenberg

Accessible Gutenberg Blocks That Stay Fast: WCAG 2.2, INP and Regional Rules

Accessible Gutenberg Blocks That Stay Fast: WCAG 2.2, INP and Regional Rules

This guide is for WordPress developers and technical site owners who build or commission custom Gutenberg blocks. You will learn which block type to choose for performance, how to load only the assets a block needs, how to build an interactive block with the Interactivity API that keyboard and screen reader users can operate, and how to catch regressions with axe-core and Lighthouse CI. It ends with what the accessibility rules say in the US, the EU, the UK and Australia.

Last verified: October 2026.

I build custom blocks for WooCommerce and LearnDash sites, and the same two problems come up on almost every audit: blocks that ship JavaScript to the front end for no reason, and interactive blocks that only work with a mouse.

The short version

  • Use core blocks where they do the job.
  • Prefer static markup or PHP rendering. A block with no front-end JavaScript cannot hurt Interaction to Next Paint (INP).
  • Declare every asset in block.json so WordPress loads it only where the block appears.
  • For interactivity, use the Interactivity API with viewScriptModule.
  • Build to WCAG 2.2 Level AA.
  • Test with axe-core and Lighthouse CI, then by hand with a keyboard and a screen reader.

Performance targets that matter for blocks

According to web.dev’s Web Vitals guide, a page passes Core Web Vitals when, at the 75th percentile of visits, Largest Contentful Paint (LCP) is 2.5 seconds or less, INP is 200 milliseconds or less, and Cumulative Layout Shift (CLS) is 0.1 or less. INP replaced First Input Delay in March 2024. Each metric maps to a common block mistake:

MetricGood thresholdTypical block causeFix
LCP2.5 s or lessHero or slider block lazy-loads the first image or waits for JavaScriptRender the image in server markup, no loading="lazy" on it
INP200 ms or lessHeavy click handlers, large front-end bundlesShip less JavaScript, use Interactivity API actions, splitTask()
CLS0.1 or lessImages without dimensions, content injected after loadOutput width and height, render content on the server

Static, dynamic or interactive: pick the cheapest block that works

A static block saves its HTML into post_content through save(), with no per-request rendering. The trade-off: changing the markup later needs a block deprecation, and the block cannot show live data.

A dynamic block stores attributes and renders through a PHP file (render in block.json) on each request. That costs a little server time, which page caching usually absorbs, but you can change markup freely and pull in WooCommerce prices or LearnDash progress. Most blocks I build for clients are dynamic for that reason.

An interactive block is either of the above plus front-end JavaScript. It is the only type that can affect INP, so JavaScript is something a block has to earn.

Block typeFront-end JSServer costChanging markup laterGood for
Static (save())None by defaultNoneNeeds a deprecationCallouts, layout wrappers
Dynamic (render.php)None by defaultSmall, cacheableEdit the PHPProduct and course listings
Interactive (Interactivity API)Shared runtime plus your moduleAs aboveEdit PHP and storeTabs, disclosures, filters
Interactive (custom bundle)Whatever you shipAs aboveEdit everythingComplex apps the API cannot handle

Check whether HTML already solves it: <details> and <summary> give you a keyboard-operable disclosure with zero script, and <dialog> with showModal() handles Escape and makes the rest of the page inert.

Load assets only where the block appears

Each asset field in block.json controls where a file loads: editorScript and editorStyle in the editor only, style in both, viewScript and viewStyle on the front end only. viewScriptModule, added in WordPress 6.5, loads a JavaScript module on the front end when the block renders and is what the Interactivity API expects (6.5 dev note).

Whether block styles load only on pages containing the block is decided by wp_should_load_block_assets_on_demand() (WordPress 6.8+, filter should_load_block_assets_on_demand). Block themes do this by default; on a classic theme, check before assuming. On sites with many custom blocks this is the cheapest win I know.

The block.json for the example block used below:

{
	"$schema": "https://schemas.wp.org/trunk/block.json",
	"apiVersion": 3,
	"name": "rau/disclosure",
	"title": "Disclosure",
	"category": "text",
	"textdomain": "rau",
	"attributes": {
		"label": { "type": "string", "default": "Show details" }
	},
	"supports": {
		"html": false,
		"interactivity": true
	},
	"editorScript": "file:./index.js",
	"editorStyle": "file:./index.css",
	"style": "file:./style-index.css",
	"render": "file:./render.php",
	"viewScriptModule": "file:./view.js"
}

Build modules with the @wordpress/scripts experimental flag, and register the built folder on init:

npx wp-scripts build --experimental-modules
add_action( 'init', function () {
	register_block_type( __DIR__ . '/build/disclosure' );
} );

Keep editor-only libraries out of view scripts, and run npx wp-scripts build --webpack-bundle-analyzer to see what ended up in each file. I do not set a fixed kilobyte limit per block; the budget that matters is field INP and LCP on real templates.

The interactive part: Interactivity API

The Interactivity API, introduced in WordPress 6.5, adds behaviour through HTML directives that WordPress processes on the server, so the first render already has the right state. That helps LCP (content is in the HTML) and accessibility (aria-expanded is correct before JavaScript runs). Syntax follows the Interactivity API reference.

render.php:

<?php
/**
 * @var array    $attributes Block attributes.
 * @var string   $content    Inner blocks HTML.
 * @var WP_Block $block      Block instance.
 */
$panel_id = wp_unique_id( 'rau-disclosure-' );
$label    = $attributes['label'] ?? __( 'Show details', 'rau' );
?>
<div
	<?php echo get_block_wrapper_attributes(); ?>
	data-wp-interactive="rau/disclosure"
	<?php echo wp_interactivity_data_wp_context( array( 'isOpen' => false ) ); ?>
>
	<button
		type="button"
		class="rau-disclosure__toggle"
		aria-controls="<?php echo esc_attr( $panel_id ); ?>"
		data-wp-on--click="actions.toggle"
		data-wp-bind--aria-expanded="context.isOpen"
	>
		<?php echo esc_html( $label ); ?>
	</button>
	<div
		id="<?php echo esc_attr( $panel_id ); ?>"
		data-wp-bind--hidden="!context.isOpen"
	>
		<?php echo $content; ?>
	</div>
</div>

view.js:

import { store, getContext } from '@wordpress/interactivity';

store( 'rau/disclosure', {
	actions: {
		toggle() {
			const context = getContext();
			context.isOpen = ! context.isOpen;
		},
	},
} );

No extra ARIA roles are needed: a real <button> gets Enter, Space and tab order for free, aria-expanded announces the state, aria-controls links button and panel, and hidden removes the closed panel from the accessibility tree.

Two performance notes: data-wp-on handlers run asynchronously by default; if an action needs event.preventDefault(), wrap it in withSyncEvent(). For heavier actions, write a generator and yield splitTask() after the urgent work so the browser can paint first. Both avoid the long tasks INP measures.

WCAG for WordPress: what WCAG 2.2 changes for custom Gutenberg blocks

WCAG 2.2 became a W3C Recommendation on 5 October 2023. The W3C summary of what is new lists nine new success criteria and removes 4.1.1 Parsing. The Level A and AA additions, and where blocks fail them:

CriterionLevelWhere custom blocks fail
2.4.11 Focus Not Obscured (Minimum)AASticky header or cookie bar covers the focused control
2.5.7 Dragging MovementsAASliders or sortable lists that only work by dragging
2.5.8 Target Size (Minimum)AAIcon buttons and carousel dots under 24 by 24 CSS pixels without spacing
3.2.6 Consistent HelpAHelp links that move between templates
3.3.7 Redundant EntryAMulti-step forms asking for the same data twice
3.3.8 Accessible Authentication (Minimum)AALogin blocks that block paste or rely on a puzzle

The older basics still cause most failures I find: text contrast of 4.5:1 (3:1 for large text and UI components), logical heading order, alt text, labelled form fields and full keyboard operation.

Visible focus that is never hidden

Style :focus-visible instead of removing outlines, and give sticky headers room:

.rau-disclosure__toggle {
	min-height: 44px;
	min-width: 44px;
}

.rau-disclosure__toggle:focus-visible {
	outline: 3px solid currentColor;
	outline-offset: 3px;
}

/* Keeps focus clear of a 72px sticky header (WCAG 2.4.11). */
html {
	scroll-padding-top: 80px;
}

The 2.2 minimum is 24 pixels; I use 44 for primary controls because touch users need it.

Moving focus on purpose

When a block opens something, move focus into it, and return focus to the trigger on close:

const trigger = document.querySelector( '.rau-modal__open' );
const dialog = document.querySelector( '.rau-modal' );

trigger.addEventListener( 'click', () => dialog.showModal() );
dialog.addEventListener( 'close', () => trigger.focus() );

showModal() moves focus in, makes the page inert and closes on Escape. The close listener makes the return explicit.

ARIA only when HTML cannot do it

If a native element exists, use it. Add ARIA for state (aria-expanded, aria-pressed), relationships (aria-describedby for form errors) and live updates (aria-live="polite" for “Added to cart”). A div with role="button" means rebuilding keyboard support by hand. In the editor, use @wordpress/components controls with a label on every one, since content editors may use assistive technology too.

Testing: automate, then check by hand

Automated tools miss things such as focus order and announcement quality, so I run both.

axe-core with Playwright

axe does not test hidden regions, so this test opens the panel with the keyboard first, which also proves keyboard operation:

// tests/disclosure.a11y.spec.js
const { test, expect } = require( '@playwright/test' );
const { AxeBuilder } = require( '@axe-core/playwright' );

test( 'disclosure block meets WCAG 2.2 AA rules', async ( { page } ) => {
	await page.goto( 'http://localhost:8888/block-test/' );

	const toggle = page.getByRole( 'button', { name: 'Show details' } );
	await toggle.press( 'Enter' );
	await expect( toggle ).toHaveAttribute( 'aria-expanded', 'true' );

	const results = await new AxeBuilder( { page } )
		.include( '.wp-block-rau-disclosure' )
		.withTags( [ 'wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22aa' ] )
		.analyze();

	expect( results.violations ).toEqual( [] );
} );

Lighthouse CI budgets

lighthouserc.json:

{
	"ci": {
		"collect": {
			"url": [ "http://localhost:8888/block-test/" ],
			"numberOfRuns": 3
		},
		"assert": {
			"assertions": {
				"categories:accessibility": [ "error", { "minScore": 0.95 } ],
				"largest-contentful-paint": [ "error", { "maxNumericValue": 2500 } ],
				"cumulative-layout-shift": [ "error", { "maxNumericValue": 0.1 } ],
				"total-blocking-time": [ "warn", { "maxNumericValue": 200 } ]
			}
		}
	}
}

A page-load Lighthouse run has no real interactions, so it cannot report INP. Total Blocking Time is my lab stand-in; field INP comes from the Chrome UX Report or real-user monitoring.

The CI job

@wordpress/env starts WordPress on port 8888 (it needs Docker, which GitHub’s Ubuntu runners have):

name: Blocks QA
on: [pull_request]

jobs:
  qa:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - run: npx wp-scripts build --experimental-modules
      - run: npx wp-env start
      - run: >
          npx wp-env run cli wp post create
          --post_type=page --post_status=publish
          --post_name=block-test --post_title="Block test"
          --post_content='<!-- wp:rau/disclosure --><!-- wp:paragraph --><p>Panel text</p><!-- /wp:paragraph --><!-- /wp:rau/disclosure -->'
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test
      - run: npm install -g @lhci/cli@0.15.x
      - run: lhci autorun

Manual checks before release

  1. Put the mouse away. Tab through every block, operate controls with Enter, Space, Escape and arrows, and confirm focus is always visible.
  2. Zoom to 200% and 400% and check nothing overlaps or is cut off.
  3. Run the main journey with NVDA on Windows (free) or VoiceOver on macOS and iOS.
  4. Check contrast for every palette pairing your theme.json allows.
  5. Write down what you tested. A documented process helps if a complaint arrives.

WordPress accessibility compliance: the rules in the US, EU, UK and Australia

I am a developer, not a lawyer, so treat this as orientation and take legal advice for your situation. For block work the useful point is that every region points to WCAG Level AA, and building to WCAG 2.2 AA covers the older versions.

RegionMain law or ruleTechnical referenceWho it reaches
United StatesADA Title II rule; ADA Title III; Section 508WCAG 2.1 AA (Title II); WCAG 2.0 AA (508)Public bodies directly; businesses through complaints and lawsuits
European UnionEuropean Accessibility Act, from 28 June 2025EN 301 549 (V4.1.1 aligns with WCAG 2.2 AA)E-commerce, banking, e-books and more, sold to EU consumers
United KingdomEquality Act 2010; 2018 public sector regulationsWCAG 2.2 AA in public sector guidanceAll service providers; public sector sites specifically
AustraliaDisability Discrimination Act 1992WCAG 2.2 AA recommended by the AHRCProviders of goods and services, online included

United States

The DOJ’s 2024 ADA Title II rule requires state and local government websites to meet WCAG 2.1 AA. An interim final rule published on 20 April 2026 moved compliance to 26 April 2027 for entities serving 50,000 people or more and 26 April 2028 for smaller ones, with the standard unchanged (summary from the Consumer Financial Services Law Monitor). No DOJ regulation sets a technical standard for private business websites under Title III, but stores and course sites still get demand letters, and WCAG 2.1 AA is the benchmark used in practice. Section 508 applies WCAG 2.0 AA to federal agencies and their purchases.

European Union

The European Commission’s EAA page lists e-commerce, banking services and e-books among the covered services. The Act has applied since 28 June 2025, reaches providers outside the EU selling to EU consumers, and exempts service microenterprises (fewer than 10 staff and turnover or balance sheet of no more than 2 million euro), as Bird & Bird’s summary explains. The technical route is EN 301 549: V3.2.1 references WCAG 2.1, while V4.1.1, published in September 2026, aligns with WCAG 2.2 A and AA and gives a presumption of conformity once cited in the EU Official Journal. Each member state sets its own penalties.

United Kingdom

The EAA does not apply inside the UK, but a UK business selling to EU consumers is caught by it like any other non-EU seller. At home, the Equality Act 2010 requires reasonable adjustments for disabled people (Northern Ireland uses the Disability Discrimination Act 1995), and public sector bodies also fall under the 2018 accessibility regulations. UK government guidance says meeting WCAG 2.2 A and AA aligns you with those regulations.

Australia

The Disability Discrimination Act 1992 makes disability discrimination in providing goods and services unlawful, websites included. The Australian Human Rights Commission’s 2025 Guidelines on equal access to digital goods and services replace its 2014 advisory notes, which were based on WCAG 2.0, and recommend WCAG 2.2 AA. The guidelines are advisory, but the Commission can consider them when handling a complaint.

Trade-offs to weigh

  • Dynamic rendering and caching. On logged-in WooCommerce and LearnDash pages that bypass the page cache, keep render callbacks lean and cache expensive queries in transients.
  • Interactivity API or your own React. The API is lighter and server-rendered, but a full product configurator may still be easier as a dedicated bundle. Load it only where needed.
  • Scores or users. A Lighthouse accessibility score of 100 does not make a page accessible. Scores catch regressions; manual checks judge quality.

Fixing existing blocks usually fits into plugin development or maintenance work, not a rebuild.

Frequently asked questions

Are Gutenberg core blocks accessible?

Core blocks are a good starting point and receive accessibility fixes through WordPress releases. Most failures I find come from custom blocks, third-party block libraries, or themes that remove focus outlines and use low-contrast palettes. Test the finished page, because accessibility depends on how blocks are combined and styled.

Do custom Gutenberg blocks slow down a WordPress site?

Only if they ship assets the page does not need or run heavy JavaScript. A static or PHP-rendered block with no view script adds very little. Declare assets in block.json, make sure styles load on demand, and compare field Core Web Vitals before and after.

Should I use a static or a dynamic block?

Use static for simple content whose markup will rarely change. Use dynamic when output depends on live data or you expect to change the markup, since a PHP edit avoids deprecations. For client work I default to dynamic blocks behind page caching.

Does the Interactivity API help with INP?

Yes, in two ways: the shared runtime is small, and handlers run asynchronously by default so the browser responds to the click quickly. It cannot make slow code fast. For heavy work, use a generator action and yield splitTask() to break it up.

Is WCAG 2.2 legally required?

The US Title II rule and Section 508 name WCAG 2.1 AA and 2.0 AA, UK public sector guidance and Australia’s AHRC guidelines point to WCAG 2.2 AA, and the newest EN 301 549 aligns with WCAG 2.2. Building to WCAG 2.2 AA covers all of them, so that is my default.

Can axe-core or Lighthouse prove my blocks are accessible?

No. They catch issues such as missing labels, contrast failures and invalid ARIA, and axe flags some results for manual review. They cannot judge focus order, alt text quality or whether announcements make sense. Use them to stop regressions.

Does the European Accessibility Act apply to a US, UK or Australian WooCommerce store?

It can, if you sell to EU consumers and are not a microenterprise, because the Act covers e-commerce offered to EU consumers from outside the EU. Get legal advice, and technically start with checkout, account and product pages.


If you need custom Gutenberg blocks that are fast, keyboard and screen reader friendly, and built to WCAG 2.2 AA from the start, or an audit of the blocks you already have, see my Gutenberg block development service and tell me what your site needs.

Leave a Reply