Keyboard
Use the keys the canvas already responds to, reach a node's controls without a mouse, and add your own shortcuts without taking keys from the rest of the page.
The canvas is one stop in the page's tab order. While it has focus, it responds to the keys listed below. While anything else on the page has focus, it ignores them, so your app's own text fields and shortcuts keep working beside it.
Give the graph focus
Tab moves focus onto the canvas, and a press anywhere on the canvas focuses it too. A screen
reader announces the canvas by its ariaLabel, then reads a short instruction: arrow keys move
between nodes, Enter reaches the focused node's controls, and Escape comes back.
| Prop | Type | Default | Meaning |
|---|---|---|---|
ariaLabel | string | 'Flow graph' | The accessible name of the canvas. |
Give every graph a name that says what it holds, especially when a page has more than one.
<KookieFlow ariaLabel="Image pipeline" entities={entities} edges={edges} />The canvas is announced as an application, because its arrow keys move between nodes rather
than through text. Screen readers such as NVDA and JAWS leave browse mode inside it so that
those keys reach the canvas, and the same applies to any children you render inside
<KookieFlow>. Keep a panel that people should read as ordinary content outside the canvas.
The canvas responds to keys only while the canvas element itself has focus. When a button you render inside it, the editor of a text entity, or a field on a node has focus, the keys go to that element instead.
Keys the canvas responds to
The arrow keys move a keyboard cursor from entity to entity, and each move selects the entity the cursor lands on. The section on reaching a node's controls describes the cursor in full.
| Key | What it does |
|---|---|
| Down arrow or Right arrow | Moves the keyboard cursor to the next entity and selects it. |
| Up arrow or Left arrow | Moves the keyboard cursor to the previous entity and selects it. |
| Home or End | Moves the keyboard cursor to the first or last entity and selects it. |
| Enter | Moves focus into the controls on the keyboard cursor's node, when it has any. |
| Shift+arrow | Moves the selection one world unit, or one grid cell with snapToGrid on. With nothing selected, moves the keyboard cursor's entity. |
| Alt+Shift+arrow | Moves ten steps instead of one. |
| Alt+A, Alt+D, Alt+W or Alt+S | Lines the selection up on its left, right, top or bottom edge. Needs two or more entities. |
| Alt+H or Alt+V | Lines the selection's centres up on one vertical or one horizontal line. Needs two or more. |
| Alt+Shift+H or Alt+Shift+V | Spaces the selection evenly across or down. Needs three or more. |
| Cmd/Ctrl+A | Selects every entity. |
| Delete or Backspace | Deletes the selection, as the Selection chapter describes. |
| Escape | Stops what's in progress, or clears the selection. |
| Space, held | Pans while you drag with the primary button. |
| T | Adds an empty text entity in the middle of the view and starts editing it. |
| D | Picks up the pen, or puts it down. The Drawing chapter covers it. |
Escape handles the first of these that applies: it puts the pen down, stops editing a text entity, cancels a wire you're drawing, or cancels a selection box. When none of those is in progress, it clears the selection.
Every key that moves or adds something reports it, the same way a pointer does.
onEntitiesChange receives position changes for Shift+arrow and the alignment keys, and an
add change for T. The Layout and alignment chapter explains how aligning and
spacing choose where each entity goes.
The canvas matches letters by their position on the keyboard, so Alt+A works on macOS too,
where Option changes the character the key types. With history on, useGraph also binds Cmd/Ctrl+Z
to undo and Cmd/Ctrl+Shift+Z or Cmd/Ctrl+Y to redo while focus is inside the canvas, as the
Undo and redo chapter describes.
Reach a node's controls
The keyboard cursor is the one entity the keyboard is on. The arrow keys move it in reading order: top to bottom, then left to right. It wraps from the last entity to the first, and skips entities inside a collapsed frame. A press on an entity also moves the cursor there, so the pointer and the keyboard share one position.
Moving the cursor with a key selects that entity, because the selection outline is the only mark on the canvas that shows where the cursor is. When the entity isn't fully in view, the camera centres it. A screen reader announces the entity's label, or its id when it has no label, and how many controls it has: "Sampler, 3 controls".
Press Enter to move focus to the first control. Each widget on the node's unconnected inputs has a matching form control that is hidden from view but present for the keyboard and for screen readers. A checkbox widget becomes a checkbox input, a slider becomes a range input, and a select, number, colour, text, or text-area widget becomes the matching form element. Each control is labelled with its socket's name.
| Key, inside the controls | What it does |
|---|---|
| Down arrow or Up arrow | Moves to the next or previous control, wrapping at the ends. |
| Home or End | Moves to the first or last control. |
| Escape | Returns focus to the canvas. |
| Any other key | Goes to the control: Space toggles a checkbox, Left and Right move a slider, and typing edits a field. |
A change made this way calls onWidgetChange with the same kind of value a pointer sends: a
boolean for a checkbox, a number for a slider, and a string for a text field. The
Sockets and widgets chapter describes those values.
A connected input has no control, because its value arrives along the edge. With showWidgets
set to false there are no controls to reach. The mirror only contains the focused node’s controls.
Add your own shortcuts
useKeyboardShortcuts binds keys to functions. Import it from the plugins subpath.
import { useKeyboardShortcuts } from '@kushagradhawan/kookie-flow/plugins';| Option | Type | Default | Meaning |
|---|---|---|---|
bindings | Record<string, KeyHandler> | KeyBinding[] | required | The keys and what each one runs. |
target | HTMLElement | Document | null | document | The element that listens. null listens on document. |
enabled | boolean | true | Removes the listener while false. |
requireFocus | boolean | false | Runs bindings only while focus is inside containerRef. |
containerRef | React.RefObject<HTMLElement> | none | The element requireFocus checks. While its current is empty, bindings run without the check. |
A KeyHandler is (event: KeyboardEvent) => void. The object form of bindings maps a key
string to a handler and prevents the browser's default action, except for a
paste binding. The array form takes KeyBinding objects,
which let you keep the default.
KeyBinding field | Type | Default | Meaning |
|---|---|---|---|
key | string | required | The key string. |
handler | KeyHandler | required | What the key runs. |
preventDefault | boolean | true | Whether to cancel the browser's default action for the key. |
A key string joins modifiers and a key with +, such as mod+shift+k. The modifiers are
mod, which is Cmd on Apple devices and Ctrl elsewhere, and ctrl, meta, alt, and shift.
The modifiers must match exactly, so mod+k doesn't run for Cmd+Shift+K. After the modifiers:
- A letter matches the character the key types, in either case. On macOS, Option changes that
character, so an
alt+letter binding doesn't run there. deletematches Delete and Backspace, andbackspacematches Backspace only.escape,enter,space,arrowup,arrowdown,arrowleft, andarrowrightname those keys.- Any other key matches the lowercase value of
event.key, such as/or1.
The hook runs the first binding that matches and stops. It ignores keys typed into an input, a text area, or an editable element.
Scope shortcuts to the canvas
By default the hook listens on document, so a binding runs wherever focus is on the page. To
keep a shortcut to the graph, pass the element that wraps <KookieFlow> as target. Keys
pressed on the canvas reach that element on their way up, and keys pressed elsewhere don't.
editor.tsx'use client';import { useRef, useState } from 'react';import { KookieFlow, useGraph, type Entity, type KookieFlowInstance } from '@kushagradhawan/kookie-flow';import { useKeyboardShortcuts } from '@kushagradhawan/kookie-flow/plugins';export function Editor({ initialEntities }: { initialEntities: Entity[] }) { const { entities, edges, onEntitiesChange, onEdgesChange, onConnect } = useGraph({ initialEntities }); const flowRef = useRef<KookieFlowInstance>(null); // State rather than a ref, so the hook renders again once the element exists. const [board, setBoard] = useState<HTMLDivElement | null>(null); useKeyboardShortcuts({ target: board, enabled: board !== null, bindings: { f: () => flowRef.current?.fitView(), 'shift+f': () => { const ids = flowRef.current?.getSelectedEntities().map((entity) => entity.id) ?? []; flowRef.current?.fitView({ entities: ids }); }, }, }); return ( <div ref={setBoard} style={{ height: 480 }}> <KookieFlow ref={flowRef} entities={entities} edges={edges} onEntitiesChange={onEntitiesChange} onEdgesChange={onEdgesChange} onConnect={onConnect} /> </div> );}target takes the element, not a ref, which is why the example keeps it in state. A ref is
still empty on the first render, and the hook would listen on document until something else
made the component render again. requireFocus with containerRef checks focus against a ref
instead, but with React 19's types a useRef<HTMLDivElement>(null) doesn't satisfy
containerRef, so target is the simpler choice.
Pick keys the canvas doesn't already use. Your binding runs as well as the canvas's own
handling, not instead of it, and preventDefault doesn't stop the canvas. A delete binding
that calls the store's deleteSelected runs first, removes the selection without calling
onEntitiesChange, and leaves the canvas's own Delete with nothing to report. Leave Delete,
Backspace, Escape, Enter, Home, End, the arrows, Space, T, D, Cmd/Ctrl+A, and the Alt alignment
keys to the canvas.
Bind paste without blocking media
A paste binding, mod+v with no other modifier, works differently from the rest. The hook
doesn't cancel the key, because a cancelled Cmd/Ctrl+V stops the browser's paste event, and
that event is how a screenshot or an image link reaches the canvas. The hook waits for the
event instead:
- When the clipboard holds a file of any kind, or a link to a picture, a video, or a model, your handler doesn't run. The canvas turns a picture, a video, or a model into an entity, as the Dropping and pasting media chapter describes.
- When the clipboard holds anything else, your handler runs.
- When the browser fires no
pasteevent at all, your handler runs a moment later.
preventDefault has no effect on a paste binding. This pair copies the selected entities and
pastes them 40 units down and to the right, while a pasted screenshot still becomes an image.
It replaces the useKeyboardShortcuts call in the editor above.
const copied = useRef<Entity[]>([]);useKeyboardShortcuts({ target: board, enabled: board !== null, bindings: { 'mod+c': () => { copied.current = flowRef.current?.getSelectedEntities() ?? []; }, 'mod+v': () => { onEntitiesChange( copied.current.map((entity) => ({ type: 'add' as const, entity: { ...entity, id: crypto.randomUUID(), position: { x: entity.position.x + 40, y: entity.position.y + 40 }, }, })) ); }, },});The paste goes through onEntitiesChange from useGraph, so the new entities land in your own
state as one batch, and with history on they're one undo step. The
Clipboard and cloning chapter covers a clipboard that also copies the edges
between entities.