Handling Keyboard Navigation Before Hydration
The window between paint and activation is a keyboard problem before it is anything else, because keyboard users reach controls in seconds while a pointer user is still orienting. Everything needed to fix it is in the server markup and a few lines of the loader. This extends the capture-and-replay design in event delegation in partially hydrated apps.
Prerequisites
Implementation Steps
Step 1 โ Make the markup keyboard-complete before anything else
The rule is simple and unforgiving: if a control is not usable with a keyboard in the server-rendered HTML, deferring its island makes the page unusable rather than slow. Buttons inside forms, links with real hrefs, native selects and inputs. Anything built from a div and a click handler fails this test by construction.
Step 2 โ Treat focus as an activation trigger
// The single most valuable line for keyboard users in a deferred-island page.
el.addEventListener('focusin', () => activate(el), { once: true });
A viewport observer does not fire when focus moves into a region that is technically on screen but was never scrolled to, and it certainly does not fire for a region a screen reader reads without moving the viewport. Focus does.
Step 3 โ Buffer only what is safe
// Capture-phase listener on the island container, before activation.
container.addEventListener('keydown', (event) => {
if (container.dataset.islandState === 'ready') return; // already live
const isActivation = event.key === 'Enter' || event.key === ' ';
const target = event.target;
if (!isActivation || !target.matches('button, [role="button"]')) return;
// Native activation on a real button already works; buffering is only for
// controls whose behaviour is provided by the island.
if (target.closest('form') && target.type === 'submit') return;
event.preventDefault();
queue.push({ key: event.key, targetKey: target.dataset.focusKey });
activate(container);
}, true);
The two early returns matter more than the buffering. A submit button inside a form already does the right thing natively, and intercepting it converts working behaviour into deferred behaviour โ the opposite of the goal.
Step 4 โ Register shortcuts outside the island
A shortcut handler that lives inside a deferred island does not exist until the island activates. Put the key binding in the small always-present script, have it call activate() and then perform the action, so the shortcut works from the first paint.
Verification
Two automated checks complement the manual pass. A test that loads the page with scripting disabled and asserts a minimum number of focusable elements catches a regression where a link becomes a span. And a test that tabs through the page in a headless browser, recording the focused element at each stop, catches tab order drifting away from visual order after a layout change โ a regression that is invisible to anyone who navigates with a pointer.
For the buffered-activation path, assert that pressing Enter on a deferred control produces exactly one action after activation, not zero and not two. Two is the signature of buffering something that also fired natively, which is why the early returns in the capture handler are worth testing directly.
Why This Is the Highest-Leverage Accessibility Work in an Islands App
Most accessibility work on a component is about the component: names, roles, contrast, focus order within a widget. This one is about the architecture, which is why it is worth treating separately.
Deferred activation introduces a state that does not exist in a fully hydrated page โ visible but not yet functional โ and every keyboard user encounters it on every page load. No amount of correct labelling inside a component compensates for the component not working when it is reached. Conversely, a page with a complete keyboard baseline degrades gracefully under every failure mode at once: slow connection, blocked script, failed chunk, and the ordinary case of someone who simply tabs faster than the network delivers.
The work itself is mostly the absence of work. Use a button. Use a link with an href. Put controls in a form with an action. Let the server respond. Then add the island as an interception, exactly as in building a search form that works without JavaScript. Teams that start there rarely need the buffering machinery at all, because there is nothing to buffer โ the controls already worked.
Troubleshooting
Should I add tabindex to make regions focusable before activation?
Only to genuine controls, and usually not at all: a button, a link and a form control are focusable by default, and if the server markup uses them there is nothing to add. Adding tabindex to a div that becomes interactive later creates a focus target that does nothing when reached, which is worse than not being reachable. Use the right element and the problem disappears.
Is it safe to buffer and replay key events?
Enter and Space on a control that still exists, yes โ those are unambiguous activations and replaying them once is exactly what the visitor asked for. Everything else is risky: arrow keys move focus or scroll, Escape may close something that no longer exists, and modifier combinations may be browser shortcuts you must not intercept. A buffer that replays only activation keys on the originally focused element covers the real cases without surprises.
What about a keyboard shortcut that only works after activation?
Register it in the small always-loaded script rather than inside the island, and have it trigger activation before performing its action. A shortcut that silently does nothing for the first two seconds is indistinguishable from a shortcut that does not exist, and visitors who rely on shortcuts are precisely the ones who will notice.
Related
- Replaying Buffered Events After Island Hydration โ the queue and replay policy for pointer and keyboard events.
- Accessibility of Deferred Hydration โ the wider checklist this belongs to.
- Building a Search Form That Works Without JavaScript โ a worked baseline that needs no buffering.
โ Back to Event Delegation in Partially Hydrated Apps