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

The Same Tab Sequence, Two Baselines With an incomplete baseline the visitor tabs into a div that is focusable but inert, presses Enter and nothing happens, then tabs past a control that has no focusable element at all. With a complete baseline every stop is a real control that works immediately: links navigate, buttons submit forms, and activation later replaces the behaviour rather than providing it. Tabbing at 600 ms, before any island has activated incomplete baseline div with tabindex โ†’ Enter does nothing next stop skips the sort control entirely โ€” it is a span with a click handler the visitor cannot tell slow from broken complete baseline button in a form โ†’ Enter submits, server responds sort control is a link โ†’ Enter navigates with a query parameter activation later upgrades these, it does not create them

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

The Throttled Tab-Through Four steps: throttle, reload, tab immediately, and press Enter on each stop. Every stop must either act immediately or clearly indicate a pending state; no stop may silently do nothing. A stop that is focusable but inert is the specific failure this test exists to find. Five minutes, no tooling required 1 ยท slow 3G, CPU 6ร— โ€” the inert window becomes seconds long 2 ยท reload and start tabbing immediately, before anything settles 3 ยท press Enter at every stop and note what happens 4 ยท any stop that silently does nothing is a defect โ€” record it and fix the markup

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.

Buffer Almost Nothing Only activation keys on a control whose behaviour the island provides are worth buffering. Native submit buttons already work, and navigation, shortcut and modifier keys must never be intercepted. The buffer should be nearly empty buffer: Enter or Space on a control the island powers one action after activation, on the element originally focused never buffer: arrows, Escape, Tab, modifier combinations they move focus, scroll, or belong to the browser a native submit button needs no buffering โ€” it already works

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.

โ† Back to Event Delegation in Partially Hydrated Apps