diff --git a/resources/js/components/index.js b/resources/js/components/index.js
index 5b84edba0..6203d0b74 100644
--- a/resources/js/components/index.js
+++ b/resources/js/components/index.js
@@ -43,6 +43,7 @@ import popup from "./popup.js"
 import settingAppColorPicker from "./setting-app-color-picker.js"
 import settingColorPicker from "./setting-color-picker.js"
 import shelfSort from "./shelf-sort.js"
+import shortcuts from "./shortcuts";
 import sidebar from "./sidebar.js"
 import sortableList from "./sortable-list.js"
 import submitOnChange from "./submit-on-change.js"
@@ -101,6 +102,7 @@ const componentMapping = {
     "setting-app-color-picker": settingAppColorPicker,
     "setting-color-picker": settingColorPicker,
     "shelf-sort": shelfSort,
+    "shortcuts": shortcuts,
     "sidebar": sidebar,
     "sortable-list": sortableList,
     "submit-on-change": submitOnChange,
diff --git a/resources/js/components/shortcuts.js b/resources/js/components/shortcuts.js
new file mode 100644
index 000000000..799f0e629
--- /dev/null
+++ b/resources/js/components/shortcuts.js
@@ -0,0 +1,119 @@
+/**
+ * The default mapping of unique id to shortcut key.
+ * @type {Object<string, string>}
+ */
+const defaultMap = {
+    "edit": "e",
+    "global_search": "/",
+};
+
+function reverseMap(map) {
+    const reversed = {};
+    for (const [key, value] of Object.entries(map)) {
+        reversed[value] = key;
+    }
+    return reversed;
+}
+
+/**
+ * @extends {Component}
+ */
+class Shortcuts {
+
+    setup() {
+        this.container = this.$el;
+        this.mapById = defaultMap;
+        this.mapByShortcut = reverseMap(this.mapById);
+
+        this.hintsShowing = false;
+        // TODO - Allow custom key maps
+        // TODO - Allow turning off shortcuts
+        // TODO - Roll out to interface elements
+        // TODO - Hide hints on focus, scroll, click
+
+        this.setupListeners();
+    }
+
+    setupListeners() {
+        window.addEventListener('keydown', event => {
+
+            if (event.target.closest('input, select, textarea')) {
+                return;
+            }
+
+            const shortcutId = this.mapByShortcut[event.key];
+            if (shortcutId) {
+                const wasHandled = this.runShortcut(shortcutId);
+                if (wasHandled) {
+                    event.preventDefault();
+                }
+            }
+        });
+
+        window.addEventListener('keydown', event => {
+            if (event.key === '?') {
+                this.hintsShowing ? this.hideHints() : this.showHints();
+                this.hintsShowing = !this.hintsShowing;
+            }
+        });
+    }
+
+    /**
+     * Run the given shortcut, and return a boolean to indicate if the event
+     * was successfully handled by a shortcut action.
+     * @param {String} id
+     * @return {boolean}
+     */
+    runShortcut(id) {
+        const el = this.container.querySelector(`[data-shortcut="${id}"]`);
+        console.info('Shortcut run', el);
+        if (!el) {
+            return false;
+        }
+
+        if (el.matches('input, textarea, select')) {
+            el.focus();
+            return true;
+        }
+
+        if (el.matches('a, button')) {
+            el.click();
+            return true;
+        }
+
+        console.error(`Shortcut attempted to be ran for element type that does not have handling setup`, el);
+
+        return false;
+    }
+
+    showHints() {
+        const shortcutEls = this.container.querySelectorAll('[data-shortcut]');
+        for (const shortcutEl of shortcutEls) {
+            const id = shortcutEl.getAttribute('data-shortcut');
+            const key = this.mapById[id];
+            this.showHintLabel(shortcutEl, key);
+        }
+    }
+
+    showHintLabel(targetEl, key) {
+        const targetBounds = targetEl.getBoundingClientRect();
+        const label = document.createElement('div');
+        label.classList.add('shortcut-hint');
+        label.textContent = key;
+        this.container.append(label);
+
+        const labelBounds = label.getBoundingClientRect();
+
+        label.style.insetInlineStart = `${((targetBounds.x + targetBounds.width) - (labelBounds.width + 12))}px`;
+        label.style.insetBlockStart = `${(targetBounds.y + (targetBounds.height - labelBounds.height) / 2)}px`;
+    }
+
+    hideHints() {
+        const hints = this.container.querySelectorAll('.shortcut-hint');
+        for (const hint of hints) {
+            hint.remove();
+        }
+    }
+}
+
+export default Shortcuts;
\ No newline at end of file
diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss
index acb45100f..661fce758 100644
--- a/resources/sass/_components.scss
+++ b/resources/sass/_components.scss
@@ -982,4 +982,18 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
 }
 .status-indicator-inactive {
   background-color: $negative;
+}
+
+.shortcut-hint {
+  position: fixed;
+  padding: $-xxs $-xxs;
+  font-size: .85rem;
+  font-weight: 700;
+  line-height: 1;
+  z-index: 99;
+  background-color: #eee;
+  border-radius: 3px;
+  border: 1px solid #b4b4b4;
+  box-shadow: 0 1px 1px rgba(0, 0, 0, .2), 0 2px 0 0 rgba(255, 255, 255, .7) inset;
+  color: #333;
 }
\ No newline at end of file
diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php
index b95b69d1b..dbd2cbb35 100644
--- a/resources/views/books/show.blade.php
+++ b/resources/views/books/show.blade.php
@@ -109,7 +109,7 @@
             <hr class="primary-background">
 
             @if(userCan('book-update', $book))
-                <a href="{{ $book->getUrl('/edit') }}" class="icon-list-item">
+                <a href="{{ $book->getUrl('/edit') }}" data-shortcut="edit" class="icon-list-item">
                     <span>@icon('edit')</span>
                     <span>{{ trans('common.edit') }}</span>
                 </a>
diff --git a/resources/views/common/header.blade.php b/resources/views/common/header.blade.php
index 197b80c27..1b0e64ac4 100644
--- a/resources/views/common/header.blade.php
+++ b/resources/views/common/header.blade.php
@@ -22,6 +22,7 @@
             <form action="{{ url('/search') }}" method="GET" class="search-box" role="search">
                 <button id="header-search-box-button" type="submit" aria-label="{{ trans('common.search') }}" tabindex="-1">@icon('search') </button>
                 <input id="header-search-box-input" type="text" name="term"
+                       data-shortcut="global_search"
                        aria-label="{{ trans('common.search') }}" placeholder="{{ trans('common.search') }}"
                        value="{{ isset($searchTerm) ? $searchTerm : '' }}">
             </form>
diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php
index 9f6e9f89a..928eb17a0 100644
--- a/resources/views/layouts/base.blade.php
+++ b/resources/views/layouts/base.blade.php
@@ -31,7 +31,7 @@
     <!-- Translations for JS -->
     @stack('translations')
 </head>
-<body class="@stack('body-class')">
+<body component="shortcuts" class="@stack('body-class')">
 
     @include('layouts.parts.base-body-start')
     @include('common.skip-to-content')