Summary#
A surface that responds to the keyboard declares which keys it owns, and then reads them:
component Board() {
int x = 0;
action Move(string key) { x = key == "Left" ? x - 1 : x + 1; }
render {
Box(keys: [Left, Right], onKeyDown: Move) {
Text($"position {x}");
}
}
}Declaring keys: does three things at once, and they are not separable — a surface that could not be focused would
hear nothing, and one that heard arrows without claiming them would scroll the page while it moved:
- the element becomes focusable, so clicking it or tabbing to it gives it the keyboard;
- it owns exactly the listed keys — they no longer scroll, and every other key is left alone;
Keyboard.Down(...)andonKeyDown/onKeyUpanswer for those keys while it has focus.
Signature#
// on any element — declares the surface
keys: [<Key>, <Key>, …]
// the discrete events (both optional; each hands the action the key NAME)
onKeyDown: <action> // action Move(string key)
onKeyUp: <action>
// the held-state read — legal in `render` AND in a client action body
Keyboard.Down(<Key>) // bool: is that key down right now?Description#
Held STATE is the primitive; the events are derived#
Two different surfaces ask two different questions about the keyboard, and only one ordering answers both:
| the surface | the question | what answers it |
|---|---|---|
| a grid, a list, a menu | was an arrow pressed? | onKeyDown |
| anything that moves | is Left down right now? | Keyboard.Down(Left) |
Holding an arrow slides a piece; holding W walks. No vocabulary of fired events can express that — you would end up
tracking "which keys are currently down" in your own state, updating it from two handlers, and getting the edge cases
wrong. So the held set is the primitive, and onKeyDown is the convenient discrete half over it.
This matters most for the case that looks like it works and does not: a held key auto-repeats, firing keydown
roughly thirty times a second. onKeyDown runs once per press, on the real transition — so an app that moves one
step per press gets one step. An app that wants continuous motion reads Keyboard.Down on each tick instead.
Simultaneous keys just work#
Two reads are two independent reads:
if (Keyboard.Down(W)) { walk(); }
if (Keyboard.Down(A)) { strafe(); }Both are true while both keys are down. There is no "current key" and no arbitration to lose.
Ownership is declared, never inferred#
Arrows and Space scroll the page. Naming a key in keys: is what makes the platform claim it — and claim only it.
Every key you did not list passes through untouched, so <kbd>Tab</kbd> still moves focus and the browser's own
shortcuts still work. That is the difference between a key surface and a page that has taken the keyboard hostage.
It is focus-scoped#
A key surface hears keys while it has focus. Two boards on one page therefore never both move, and a keystroke meant for a text field is not swallowed by a board elsewhere on the page.
This is the opposite scoping from onEscape, deliberately: Escape dismisses the thing that is open, which is almost never the thing that is focused, so it listens page-wide. A key surface is the thing you are interacting with. (When focus leaves mid-hold — a <kbd>Cmd</kbd>+<kbd>Tab</kbd> while holding an arrow — every held key is released, because the browser delivers that key-up to whatever has focus now, which is not you.)
Keys are NAMES, checked when you compile#
Left, Space, W, Digit1, Shift — written bare, from a fixed vocabulary. A misspelling is a compile error
that suggests the nearest real key, rather than a surface that renders, takes focus, and silently never responds.
Available: the arrows Left Right Up Down · Space Enter Escape Tab Backspace Delete ·
Home End PageUp PageDown · the letters A–Z · the digits Digit0–Digit9 · the modifiers Shift
Control Alt Meta.
A modifier names either physical key — Keyboard.Down(Shift) is true for the left or the right one, and
onKeyDown still hands you Shift. You are never asked to care that there are two.
Keys are PHYSICAL POSITIONS, not the character produced#
Keyboard.Down(W) means the key where W sits, not the key that types "w". This is what makes a WASD movement
cluster keep its shape when someone holds <kbd>Shift</kbd> to run, and on a keyboard layout that is not QWERTY.
The cost is worth stating plainly: on an AZERTY keyboard, W is the key physically where W is on QWERTY, whatever is
printed on the cap. That is what movement keys mean and what players expect — but it is why this is a gesture
vocabulary, not a text one. To read what somebody typed, bind an Input and use onInput, where keyboard layout
and IME are handled properly and this question never comes up.
Where Keyboard.Down may be read#
In a render block, where the read is reactive — a surface bound to a held key repaints on press and on release
with nothing wired by you:
Box(keys: [Space], bg: Keyboard.Down(Space) ? Accent : Surface) { Text("hold me"); }…and in a client action body, where it is a point-in-time read — which is what a tick handler wants.
It is not available on the server: no keyboard is attached to one. A server-rendered page paints as though nothing is held (because nothing is), and the first real keystroke corrects it. A server function that reads it is a compile error naming the conflict.
Making a key surface focusable — autoFocus#
A key surface can take keys only while it has focus — keys: makes it focusable (tabindex), and clicking it
focuses it. autoFocus: true says it should start out holding focus, so the first keystroke works without a click
first.
Box(keys: [Left, Right, Shift], autoFocus: true) { … }⚠ Without it, a modifier-gated click does the UNMODIFIED thing on a freshly loaded page — and silently. If a
page reads Keyboard.Down(Shift) inside a click action to mean "flag rather than reveal", the very first
shift-click reveals instead, then works correctly ever after. That reads as a flake rather than as a missing
declaration, which is why the prop exists.
Focusing does not scroll: an autoFocus surface below the fold will not jump the page past the heading that
explains it. And it fires on the edge — when the value becomes true — never re-asserting on an unrelated re-render,
so it cannot yank focus back from wherever the reader has tabbed to.
Examples#
component Board() {
int x = 0;
int y = 0;
action Move(string key) {
if (key == "Left") { x = x - 1; }
if (key == "Right") { x = x + 1; }
if (key == "Up") { y = y - 1; }
if (key == "Down") { y = y + 1; }
}
render {
Box(keys: [Left, Right, Up, Down], onKeyDown: Move, p: 4) {
Text($"({x}, {y})");
}
}
}component Thruster() {
render {
Box(keys: [Space], p: 4) {
Text(Keyboard.Down(Space) ? "burning" : "idle");
}
}
}component Strafe() {
render {
Box(keys: [W, A, S, D], p: 4) {
Text(Keyboard.Down(W) && Keyboard.Down(A) ? "forward-left" : "idle");
}
}
}See also#
- onEnter — Enter as the keyboard peer of a click, on a focused field
- onEscape — Escape as dismissal, listening page-wide rather than on a focused element
- component — state, actions, and the render block these examples are written in
- layout primitives —
Layout.AtLeast, the other primitive that answers a question about the live page