diff --git a/src/lib/vm-listener-hoc.jsx b/src/lib/vm-listener-hoc.jsx
index 79a21210e77efe62886ac3dfa32d8b063d29d28f..b6207a5b7dd1a693df06bb3b2015880799da1f86 100644
--- a/src/lib/vm-listener-hoc.jsx
+++ b/src/lib/vm-listener-hoc.jsx
@@ -87,9 +87,9 @@ const vmListenerHOC = function (WrappedComponent) {
             // Don't capture keys intended for Blockly inputs.
             if (e.target !== document && e.target !== document.body) return;
 
+            const key = (!e.key || e.key === 'Dead') ? e.keyCode : e.key;
             this.props.vm.postIOData('keyboard', {
-                keyCode: e.keyCode,
-                key: e.key,
+                key: key,
                 isDown: true
             });
 
@@ -102,9 +102,9 @@ const vmListenerHOC = function (WrappedComponent) {
         handleKeyUp (e) {
             // Always capture up events,
             // even those that have switched to other targets.
+            const key = (!e.key || e.key === 'Dead') ? e.keyCode : e.key;
             this.props.vm.postIOData('keyboard', {
-                keyCode: e.keyCode,
-                key: e.key,
+                key: key,
                 isDown: false
             });
 
diff --git a/test/unit/util/vm-listener-hoc.test.jsx b/test/unit/util/vm-listener-hoc.test.jsx
index 78993f0ce4a61f6a56dd7f55e0d78b7df32babee..011f66135f9e647218b24c74f4b85733ce9eddcf 100644
--- a/test/unit/util/vm-listener-hoc.test.jsx
+++ b/test/unit/util/vm-listener-hoc.test.jsx
@@ -133,4 +133,49 @@ describe('VMListenerHOC', () => {
         const actions = store.getActions();
         expect(actions.length).toEqual(0);
     });
+
+    test('keypresses go to the vm', () => {
+        const Component = () => (<div />);
+        const WrappedComponent = vmListenerHOC(Component);
+
+        // Mock document.addEventListener so we can trigger keypresses manually
+        // Cannot use the enzyme simulate method because that only works on synthetic events
+        const eventTriggers = {};
+        document.addEventListener = jest.fn((event, cb) => {
+            eventTriggers[event] = cb;
+        });
+
+        vm.postIOData = jest.fn();
+
+        store = mockStore({
+            scratchGui: {
+                mode: {isFullScreen: true},
+                modals: {soundRecorder: true},
+                vm: vm
+            }
+        });
+        mount(
+            <WrappedComponent
+                attachKeyboardEvents
+                store={store}
+                vm={vm}
+            />
+        );
+
+        // keyboard events that do not target the document or body are ignored
+        eventTriggers.keydown({key: 'A', target: null});
+        expect(vm.postIOData).not.toHaveBeenLastCalledWith('keyboard', {key: 'A', isDown: true});
+
+        // keydown/up with target as the document are sent to the vm via postIOData
+        eventTriggers.keydown({key: 'A', target: document});
+        expect(vm.postIOData).toHaveBeenLastCalledWith('keyboard', {key: 'A', isDown: true});
+
+        eventTriggers.keyup({key: 'A', target: document});
+        expect(vm.postIOData).toHaveBeenLastCalledWith('keyboard', {key: 'A', isDown: false});
+
+        // When key is 'Dead' e.g. bluetooth keyboards on iOS, it sends keyCode instead
+        // because the VM can process both named keys or keyCodes as the `key` property
+        eventTriggers.keyup({key: 'Dead', keyCode: 10, target: document});
+        expect(vm.postIOData).toHaveBeenLastCalledWith('keyboard', {key: 10, isDown: false});
+    });
 });