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.jsonso 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:
| Metric | Good threshold | Typical block cause | Fix |
|---|---|---|---|
| LCP | 2.5 s or less | Hero or slider block lazy-loads the first image or waits for JavaScript | Render the image in server markup, no loading="lazy" on it |
| INP | 200 ms or less | Heavy click handlers, large front-end bundles | Ship less JavaScript, use Interactivity API actions, splitTask() |
| CLS | 0.1 or less | Images without dimensions, content injected after load | Output 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 type | Front-end JS | Server cost | Changing markup later | Good for |
|---|---|---|---|---|
Static (save()) | None by default | None | Needs a deprecation | Callouts, layout wrappers |
Dynamic (render.php) | None by default | Small, cacheable | Edit the PHP | Product and course listings |
| Interactive (Interactivity API) | Shared runtime plus your module | As above | Edit PHP and store | Tabs, disclosures, filters |
| Interactive (custom bundle) | Whatever you ship | As above | Edit everything | Complex 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-modulesadd_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:
| Criterion | Level | Where custom blocks fail |
|---|---|---|
| 2.4.11 Focus Not Obscured (Minimum) | AA | Sticky header or cookie bar covers the focused control |
| 2.5.7 Dragging Movements | AA | Sliders or sortable lists that only work by dragging |
| 2.5.8 Target Size (Minimum) | AA | Icon buttons and carousel dots under 24 by 24 CSS pixels without spacing |
| 3.2.6 Consistent Help | A | Help links that move between templates |
| 3.3.7 Redundant Entry | A | Multi-step forms asking for the same data twice |
| 3.3.8 Accessible Authentication (Minimum) | AA | Login 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 autorunManual checks before release
- Put the mouse away. Tab through every block, operate controls with Enter, Space, Escape and arrows, and confirm focus is always visible.
- Zoom to 200% and 400% and check nothing overlaps or is cut off.
- Run the main journey with NVDA on Windows (free) or VoiceOver on macOS and iOS.
- Check contrast for every palette pairing your
theme.jsonallows. - 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.
| Region | Main law or rule | Technical reference | Who it reaches |
|---|---|---|---|
| United States | ADA Title II rule; ADA Title III; Section 508 | WCAG 2.1 AA (Title II); WCAG 2.0 AA (508) | Public bodies directly; businesses through complaints and lawsuits |
| European Union | European Accessibility Act, from 28 June 2025 | EN 301 549 (V4.1.1 aligns with WCAG 2.2 AA) | E-commerce, banking, e-books and more, sold to EU consumers |
| United Kingdom | Equality Act 2010; 2018 public sector regulations | WCAG 2.2 AA in public sector guidance | All service providers; public sector sites specifically |
| Australia | Disability Discrimination Act 1992 | WCAG 2.2 AA recommended by the AHRC | Providers 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.

