+ `;
+
+ renderHook(() =>
+ useListNavigation('article.container', 'a.focusable', 'div.waterfall'),
+ );
+
+ const firstElement = document.querySelector('#focusable-1');
+ const secondElement = document.querySelector('#focusable-2');
+
+ firstElement.focus();
+
+ fireEvent.keyDown(document, { code: NAVIGATION_DOWN_KEY });
+
+ expect(secondElement).toHaveFocus();
+ });
+});
diff --git a/app/javascript/shared/components/useListNavigation.js b/app/javascript/shared/components/useListNavigation.js
new file mode 100644
index 000000000..ebc280c73
--- /dev/null
+++ b/app/javascript/shared/components/useListNavigation.js
@@ -0,0 +1,225 @@
+import PropTypes from 'prop-types';
+import { isInViewport } from '../../utilities/viewport';
+import { useKeyboardShortcuts } from './useKeyboardShortcuts';
+
+const NAVIGATION_UP_KEY = 'KeyK';
+const NAVIGATION_DOWN_KEY = 'KeyJ';
+
+const DIRECTIONS = {
+ UP: 'up',
+ DOWN: 'down',
+};
+
+/**
+ * Hook that registers a global key shortcut for 'j' and 'k' to navigate up and down in a list of items
+ *
+ * @example
+ * useListNavigation(
+ * ".crayons-story",
+ * "a[id^=article-link-]",
+ * "div.paged-stories,div.substories",
+ * )
+ *
+ * Note:
+ * To avoid conflicts, only one of these should be called per page.
+ *
+ * Note on waterfalls:
+ * In the next example, the waterfall container would be 'div.paged-stories':
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * @param {string} itemSelector - The selector for the highest level container of an item
+ * @param {string} focusableSelector - The selector for the element that should be focused on
+ * @param {string} waterfallItemContainerSelector - The selector for the waterfall item container if the list uses a waterfall structure at any point
+ */
+export function useListNavigation(
+ itemSelector,
+ focusableSelector,
+ waterfallItemContainerSelector,
+) {
+ function navigateInDirection(direction) {
+ navigate(
+ itemSelector,
+ focusableSelector,
+ waterfallItemContainerSelector,
+ direction,
+ );
+ }
+
+ useKeyboardShortcuts(
+ {
+ [NAVIGATION_UP_KEY]: () => navigateInDirection(DIRECTIONS.UP),
+ [NAVIGATION_DOWN_KEY]: () => navigateInDirection(DIRECTIONS.DOWN),
+ },
+ window,
+ { timeout: 0 },
+ );
+}
+
+/**
+ * Calls a hook that registers global key event listeners for 'j' and 'k' to navigate up and down in a list of items
+ *
+ * @example
+ *
+ *
+ * Note:
+ * To avoid conflicts, only one of these should be called per page.
+ *
+ * Note on waterfalls:
+ * In the next example, the waterfall container would be 'div.paged-stories':
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ * @param {string} itemSelector - The selector for the highest level container of an item
+ * @param {string} focusableSelector - The selector for the element that should be focused on
+ * @param {string} waterfallItemContainerSelector - The selector for the waterfall item container if the list uses a waterfall structure at any point
+ */
+export function ListNavigation({
+ itemSelector,
+ focusableSelector,
+ waterfallItemContainerSelector,
+}) {
+ useListNavigation(
+ itemSelector,
+ focusableSelector,
+ waterfallItemContainerSelector,
+ );
+
+ return null;
+}
+
+ListNavigation.propTypes = {
+ itemSelector: PropTypes.string.isRequired,
+ focusableSelector: PropTypes.string.isRequired,
+ waterfallItemContainerSelector: PropTypes.string,
+};
+
+/**
+ * Focuses on the next/previous element depending on the navigation direction
+ *
+ * @param {string} itemSelector - The selector for the highest level container of an item
+ * @param {string} focusableSelector - The selector for the element that should be focused on
+ * @param {string} waterfallItemContainerSelector - The selector for the waterfall item container if the list uses a waterfall structure at any point
+ * @param {string} direction - The navigation direction (up or down)
+ */
+function navigate(
+ itemSelector,
+ focusableSelector,
+ waterfallItemContainerSelector,
+ direction,
+) {
+ const closestContainer = document.activeElement?.closest(itemSelector);
+
+ let nextContainer;
+ if (!closestContainer) {
+ nextContainer = getFirstVisibleElement(itemSelector);
+ }
+ if (!nextContainer) {
+ const getElementCallback =
+ direction === DIRECTIONS.UP ? getPreviousElement : getNextElement;
+
+ nextContainer = getElementCallback(
+ closestContainer,
+ itemSelector,
+ waterfallItemContainerSelector,
+ );
+ }
+
+ const nextFocusable = nextContainer?.querySelector(focusableSelector);
+ if (nextFocusable) {
+ nextFocusable.focus();
+ if (!isInViewport(nextFocusable, 64)) {
+ window.scrollTo({ top: nextContainer.offsetTop - 64 });
+ }
+ }
+}
+
+/**
+ * Gets the next element of a list that matches a selector
+ *
+ * @param {object} element - The current element
+ * @param {string} itemSelector - The selector for the highest level container of an item
+ * @param {string} waterfallItemContainerSelector - The selector for the waterfall item container if the list uses a waterfall structure at any point
+ *
+ * @returns {object} The next element to focus on
+ */
+function getNextElement(element, itemSelector, waterfallItemContainerSelector) {
+ const sibling = element?.nextElementSibling;
+ if (
+ sibling &&
+ !sibling.matches(`${itemSelector},${waterfallItemContainerSelector}`)
+ ) {
+ return sibling.nextElementSibling;
+ }
+ return sibling;
+}
+
+/**
+ * Gets the previous element of a list that matches a selector
+ *
+ * @param {object} element - The current element
+ * @param {string} itemSelector - The selector for the highest level container of an item
+ * @param {string} waterfallItemContainerSelector - The selector for the waterfall item container if the list uses a waterfall structure at any point
+ *
+ * @returns {object} The previous element to focus on
+ */
+function getPreviousElement(
+ element,
+ itemSelector,
+ waterfallItemContainerSelector,
+) {
+ if (!element) {
+ return null;
+ }
+
+ let sibling = element.previousElementSibling;
+ if (!sibling && waterfallItemContainerSelector) {
+ // reached the top of a waterfall level
+ sibling = element.closest(waterfallItemContainerSelector)
+ ?.previousElementSibling;
+ }
+
+ if (sibling && !sibling.matches(itemSelector)) {
+ return sibling.previousElementSibling;
+ }
+
+ return sibling;
+}
+
+/**
+ * Gets the first visible element that matches a selector
+ *
+ * @param {string} selector - The CSS selector
+ *
+ * @returns {object} The first visible element
+ */
+function getFirstVisibleElement(selector) {
+ const elements = document.querySelectorAll(selector);
+ return Array.prototype.find.call(elements, (element) =>
+ isInViewport(element),
+ );
+}
diff --git a/app/javascript/utilities/viewport.js b/app/javascript/utilities/viewport.js
new file mode 100644
index 000000000..b9c2ec1d7
--- /dev/null
+++ b/app/javascript/utilities/viewport.js
@@ -0,0 +1,24 @@
+/**
+ * Checks if an element is visible in the viewport
+ *
+ * @example
+ * const element = document.querySelector('#element');
+ * isInViewport(element); // true or false
+ *
+ * @param {object} element - The HTML element to check
+ * @param {number} [offsetTop=0] - Part of the screen to ignore counting from the top
+ *
+ * @returns {boolean} isInViewport - true if the element is visible in the viewport
+ */
+export function isInViewport(element, offsetTop = 0) {
+ const boundingRect = element.getBoundingClientRect();
+ const clientHeight =
+ window.innerHeight || document.documentElement.clientHeight;
+ const clientWidth = window.innerWidth || document.documentElement.clientWidth;
+ return (
+ boundingRect.top >= offsetTop &&
+ boundingRect.left >= 0 &&
+ boundingRect.bottom <= clientHeight &&
+ boundingRect.right <= clientWidth
+ );
+}
diff --git a/app/views/articles/_single_story.html.erb b/app/views/articles/_single_story.html.erb
index 840832570..e5fd15332 100644
--- a/app/views/articles/_single_story.html.erb
+++ b/app/views/articles/_single_story.html.erb
@@ -1,4 +1,5 @@