Keyboard input and focus for games in portal iframes
Get key events into a game inside a portal iframe: canvas focus, no page scrolling on arrows or Space, code vs key, pause keys per portal, and pausing on blur.
On a portal, your game is an iframe inside a longer page. Key events only reach it when its document has focus. Arrow keys and Space scroll the outer page unless you stop them. Escape belongs to the browser and, on some portals, to the portal too. This guide covers each of these with a small input module and its tests, and lists what Poki, CrazyGames and YouTube Playables publish about keys as of 2026-10-01.
Who receives key events
Per MDN’s keydown page, the target of a key event is the focused element. If nothing suitable has focus, the target is <body> or the root element, and the event bubbles up to Document and Window. Inside an iframe, that only happens once the iframe’s document has focus. Until the player clicks into the game, their key presses go to the portal page.
Two portal and engine notes change this picture:
- The YouTube Playables certification FAQ says a Playable may be given focus automatically. Do not build a flow that depends on a first click arriving before any key press.
- Unity’s Input in Web page (Unity 6.3 LTS) says Unity processes all keyboard input the page receives by default, whether or not the canvas has focus. If you add HTML text fields to your page, set
WebGLInput.captureAllKeyboardInputtofalse. Unity then only reads keys while the canvas has focus, and the fields can receive input.
Make the canvas focusable
A <canvas> is not focusable by default. Give it tabindex="0". MDN’s tabindex page recommends using only 0 and -1. A value of 0 puts the canvas in the normal Tab order, so keyboard users can reach it as well.
Then focus it when the player presses on it. focus() scrolls the element into view by default. Pass { preventScroll: true } so the portal page does not jump.
<canvas id="game" width="960" height="540" tabindex="0"></canvas>
Stop arrows and Space from scrolling the page
Poki’s HTML5 guide describes the problem. Space and the arrow keys scroll by default, which goes unnoticed while the game fills the window during development, but on Poki the game sits inside a page that can scroll. Its fix cancels ArrowUp, ArrowDown and Space on keydown, and the mouse wheel with a non-passive listener. Poki’s requirements list this under page integration: game scrolling must not affect the parent page.
Canceling every key is the wrong fix. It breaks browser shortcuts, and it can break rules on other portals. The module below cancels a key only when all of these hold:
- it is one of the keys that scroll (arrows, Space, Page Up/Down),
- it is bound to a game action,
- no Ctrl, Meta or Alt modifier is held, and IME composition is not in progress.
It never cancels Escape or Tab. The YouTube Playables design requirements say a game MUST NOT call preventDefault() on Escape. Tab is how keyboard users leave the game, which the next sections come back to.
If your game uses the mouse wheel, add Poki’s wheel listener as well. The module leaves the wheel alone.
The input module
// file: examples/snippets/keyboard-and-focus-in-portal-iframes/keyboard-input.js
// keyboard-input.js: keyboard handling for a game running inside a portal iframe.
// Bindings use KeyboardEvent.code (physical key), so WASD stays in the same place on AZERTY.
/** @typedef {Record<string, string[]>} Bindings action name -> KeyboardEvent.code values */
/** @type {Bindings} */
export const DEFAULT_BINDINGS = {
left: ['ArrowLeft', 'KeyA'],
right: ['ArrowRight', 'KeyD'],
up: ['ArrowUp', 'KeyW'],
down: ['ArrowDown', 'KeyS'],
jump: ['Space'],
pause: ['Escape', 'KeyP'],
};
// Keys whose default action scrolls the page that contains the iframe.
const SCROLL_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space', 'PageUp', 'PageDown']);
// Never cancel these: Tab moves focus out of the game, Escape belongs to the browser and the portal.
const NEVER_PREVENT = new Set(['Tab', 'Escape']);
/**
* Pure: physical key code for an event; falls back to `key` when `code` is empty or unidentified. */
export function codeOf(event) {
if (event.code && event.code !== 'Unidentified') return event.code;
const key = event.key ?? '';
if (key === ' ' || key === 'Spacebar') return 'Space';
if (key === 'Esc') return 'Escape';
if (/^[a-z]$/i.test(key)) return `Key${key.toUpperCase()}`;
return key;
}
/**
* Pure: the action bound to this event, or null.
* @param {Bindings} [bindings]
*/
export function actionFor(event, bindings = DEFAULT_BINDINGS) {
const code = codeOf(event);
for (const [action, codes] of Object.entries(bindings)) if (codes.includes(code)) return action;
return null;
}
/**
* Pure: cancel the default only for game keys that would scroll the portal page.
* @param {Bindings} [bindings]
*/
export function shouldPreventDefault(event, bindings = DEFAULT_BINDINGS) {
const code = codeOf(event);
if (NEVER_PREVENT.has(code)) return false;
if (event.ctrlKey || event.metaKey || event.altKey) return false; // leave browser shortcuts alone
if (event.isComposing) return false; // IME input in progress
return SCROLL_KEYS.has(code) && actionFor(event, bindings) !== null;
}
/**
* Pure state: which actions are held. clear() on blur so no key stays stuck down.
* @param {Bindings} [bindings]
*/
export function createKeyState(bindings = DEFAULT_BINDINGS) {
const held = new Set();
return {
keydown(event) {
const action = actionFor(event, bindings);
if (action) held.add(action);
return action;
},
keyup(event) {
const action = actionFor(event, bindings);
if (action) held.delete(action);
return action;
},
isDown: (action) => held.has(action),
clear: () => held.clear(),
};
}
/**
* Browser wiring. `win` receives key events (they bubble up to Window), `canvas` gets focus on
* pointerdown, `doc` reports visibility. onPauseKey runs on a fresh press of a pause key;
* onFocusLost runs when the window blurs or the page is hidden.
*/
export function attachKeyboard({ win, doc, canvas, bindings = DEFAULT_BINDINGS, onPauseKey, onFocusLost }) {
const keys = createKeyState(bindings);
if (canvas.getAttribute('tabindex') === null) canvas.setAttribute('tabindex', '0');
const listeners = [
[canvas, 'pointerdown', () => canvas.focus({ preventScroll: true })],
[win, 'keydown', (e) => {
if (shouldPreventDefault(e, bindings)) e.preventDefault();
const action = keys.keydown(e);
if (action === 'pause' && !e.repeat) onPauseKey();
}],
[win, 'keyup', (e) => keys.keyup(e)],
[win, 'blur', () => { keys.clear(); onFocusLost('blur'); }],
[doc, 'visibilitychange', () => {
if (doc.visibilityState === 'hidden') { keys.clear(); onFocusLost('hidden'); }
}],
];
for (const [target, type, fn] of listeners) target.addEventListener(type, fn);
return {
keys,
detach() {
for (const [target, type, fn] of listeners) target.removeEventListener(type, fn);
},
};
}
codeOf, actionFor, shouldPreventDefault and createKeyState take plain objects, so the unit tests feed them hand-made events: an AZERTY key press, a missing code, Ctrl+Arrow, and so on. attachKeyboard is tested with Node’s EventTarget, which is enough to check that Space is canceled, Escape is not, and a held key is released on blur.
Wiring it into a game loop:
import { attachKeyboard } from './keyboard-input.js';
const input = attachKeyboard({
win: window,
doc: document,
canvas: document.getElementById('game'),
onPauseKey: () => (game.paused ? game.resume() : game.pause()),
onFocusLost: () => game.pause(),
});
// In update(dt):
if (input.keys.isDown('left')) player.x -= player.speed * dt;
Use code for positions, key for characters
KeyboardEvent.code names the physical key and ignores layout and modifiers. MDN’s example: the key that produces Q on QWERTY reports KeyQ, and so does the key in the same place on AZERTY, where it produces A. That is what movement keys need. KeyboardEvent.key is the character after layout and modifiers are applied, which is what text entry needs.
CrazyGames’ quality requirements ask for exactly this. They prefer key bindings that adapt to the player’s layout and point out that WASD sits where ZQSD is printed on French AZERTY keyboards. Binding KeyW/KeyA/KeyS/KeyD handles that without a settings screen.
Two caveats:
- MDN marks
codeas not Baseline (as of 2026-10-01), which means it does not work in some widely used browsers.codeOf()falls back tokeywhencodeis empty orUnidentified. codecannot tell you what is printed on the key. MDN notes that on an unexpected layout you cannot derive the key’s name for the player from it. If your controls overlay says “WASD”, AZERTY players will see the wrong letters. Label the keys by position, or show arrows.
Hold-to-repeat sends a stream of keydown events. repeat is true for those, so the pause toggle ignores them. MDN lists repeat as Baseline 2025, so in older browsers the field may be missing. The module then treats the press as fresh.
Pause keys differ by portal
| Source | What it says about pause and Escape |
|---|---|
| Poki requirements | Keyboard games implement Escape or Space for pause and resume, and fire the matching SDK events. |
| CrazyGames quality requirements | Avoid keys with other behaviour on the web: Escape closes fullscreen; Ctrl/Cmd+W closes the tab, which you can disable in fullscreen. |
| YouTube Playables design requirements | Games SHOULD let Escape close modals and dialogs, MUST NOT call preventDefault() on Escape, and SHOULD support keyboard input for directional or text input. |
| Unity 6.3 Input in Web | Escape exits fullscreen, and depending on the browser, Escape may not reach the application at all. |
DEFAULT_BINDINGS pauses on Escape and P and never cancels Escape. That meets Poki’s requirement and does not fight the browser or YouTube. On CrazyGames, a player pressing Escape in fullscreen both leaves fullscreen and pauses. Because Unity says Escape may not reach the game in every browser, keep a second pause key such as P and a visible pause button. If Space is not your jump key, you can add it to pause for Poki.
When the pause comes from a key, fire the portal’s gameplay events as you would for a pause button. See the Poki SDK guide and the CrazyGames SDK guide.
Pause when focus leaves
The window’s blur event fires when the window loses focus, for example to the address bar. visibilitychange fires when the player switches tabs, minimizes the browser or, on mobile, switches apps.
Both matter for input. A key that is held when focus leaves never sends its keyup to your document, so the character keeps walking. attachKeyboard clears all held keys on either event, then calls onFocusLost. Pause there, and show the pause screen, so the player sees why the game stopped when they come back. Also test a click on the portal page outside the iframe: the game should pause then as well.
Do not trap keyboard users
WCAG success criterion 2.1.2 requires that if keyboard focus can move into a component, it can also move out using only the keyboard. If leaving needs more than unmodified arrow keys, Tab or another standard exit method, users must be told how. A canvas that cancels Tab is exactly such a trap, which is why the module never cancels it. If your game really needs Tab (an inventory, say), bind it only while a menu is open, and tell the player how to leave the game with the keyboard.
What to check before you submit
- The canvas has
tabindex="0", and a click on it gives it focus without the page jumping. - In a test page with tall content around an iframe, arrows and Space move the character and not the page.
- Escape is never canceled, and Tab still moves focus out of the game.
- Movement uses
code, and a key labelled differently on AZERTY still lands in the WASD position. - Pause works from Escape and from a second key or button, and fires the portal’s gameplay events.
- Switching tabs or clicking outside the iframe pauses the game and releases held keys.
- Unity builds with HTML text fields set
WebGLInput.captureAllKeyboardInputtofalse.
Limits of this guide
The module was tested with plain objects and Node’s EventTarget, not inside real portal iframes or real browsers. How browsers handle a programmatic focus() inside a cross-origin iframe before the player interacts was not checked, so the module only focuses on pointerdown. Gamepad input and on-screen controls are not covered. For touch, see mobile and touch readiness. Godot’s keyboard handling on the web is not covered.
Questions
- Why do the arrow keys scroll the portal page instead of moving my character?
- Your game runs in an iframe inside a longer page. Poki's HTML5 guide explains that Space and the arrow keys scroll by default, and its fix is to call preventDefault() on those keydown events inside the game.
- Should Escape pause my game?
- Poki asks keyboard games to pause and resume on Escape or Space. YouTube Playables forbids preventDefault() on Escape, and CrazyGames notes that Escape exits fullscreen. Pausing on Escape without canceling the event is consistent with all three as published on 2026-10-01.
- Should I read KeyboardEvent.code or KeyboardEvent.key?
- Use code for movement and action keys, because it names the physical key regardless of layout. Use key when you need the character the player typed.
Sources
- PokiSDK: HTML5, prevent page jump (Poki for Developers)
- Requirements (Poki for Developers)
- Quality requirements: restricted keys (CrazyGames developer docs)
- Design requirements: interaction methods (YouTube Playables)
- Certification FAQ (YouTube Playables)
- Element: keydown event (MDN)
- KeyboardEvent: code property (MDN)
- KeyboardEvent: key property (MDN)
- KeyboardEvent: repeat property (MDN)
- tabindex global attribute (MDN)
- HTMLElement: focus() method (MDN)
- Window: blur event (MDN)
- Document: visibilitychange event (MDN)
- Understanding Success Criterion 2.1.2: No Keyboard Trap (W3C WAI, WCAG 2.2)
- Input in Web (Unity 6.3 LTS manual)