+ );
+};
+
+Default.story = {
+ name: 'MobileDrawer',
+};
diff --git a/app/javascript/crayons/MobileDrawer/__stories__/drawers.md b/app/javascript/crayons/MobileDrawer/__stories__/drawers.md
new file mode 100644
index 000000000..6387c5feb
--- /dev/null
+++ b/app/javascript/crayons/MobileDrawer/__stories__/drawers.md
@@ -0,0 +1,25 @@
+## MobileDrawers
+
+MobileDrawers are intended to be used in small screen sizes only (i.e. for
+mobile UI variants), and always appear from the bottom of the viewport. The
+button which triggers the MobileDrawer may be located anywhere on the page.
+
+MobileDrawer content should always include at least one interactive item (e.g.
+button, link) to make sure focus may be transferred to the new content.
+
+### MobileDrawer accessibility
+
+The MobileDrawer is essentially a
+[modal dialog](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/dialog_role).
+A `title` should be passed in props to properly label the dialog, and at least
+one interactive item should be present in the inner content.
+
+The MobileDrawer component utilises the `focus-trap` library to ensure that:
+
+- When the drawer is opened, focus is transferred to the first interactive item
+ in that drawer
+- When the drawer is closed, focus is transferred back to the button that
+ activated the drawer
+- While the drawer is open, focus is trapped inside so that when a user presses
+ the Tab key, interactive items behind the drawer are not focused
+- When the drawer is open, pressing the Escape key will close it
diff --git a/app/javascript/crayons/MobileDrawer/__tests__/MobileDrawer.test.jsx b/app/javascript/crayons/MobileDrawer/__tests__/MobileDrawer.test.jsx
new file mode 100644
index 000000000..a54e70886
--- /dev/null
+++ b/app/javascript/crayons/MobileDrawer/__tests__/MobileDrawer.test.jsx
@@ -0,0 +1,82 @@
+import { h } from 'preact';
+import { axe } from 'jest-axe';
+import '@testing-library/jest-dom';
+import { render, waitFor } from '@testing-library/preact';
+import userEvent from '@testing-library/user-event';
+import { MobileDrawer } from '../MobileDrawer';
+
+describe('', () => {
+ it('should have no a11y violations', async () => {
+ const { container } = render(
+
+
+ ,
+ );
+ const results = await axe(container);
+
+ expect(results).toHaveNoViolations();
+ });
+
+ it('should render correctly', () => {
+ const { container } = render(
+
+
+ ,
+ );
+
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it('should trap focus inside the drawer by default', async () => {
+ const { getByRole } = render(
+
+ );
+};
+
+Default.story = {
+ name: 'MobileDrawerNavigation',
+};
diff --git a/app/javascript/crayons/navigation/MobileDrawerNavigation/__stories__/mobileDrawerNavigation.md b/app/javascript/crayons/navigation/MobileDrawerNavigation/__stories__/mobileDrawerNavigation.md
new file mode 100644
index 000000000..f9e081c2a
--- /dev/null
+++ b/app/javascript/crayons/navigation/MobileDrawerNavigation/__stories__/mobileDrawerNavigation.md
@@ -0,0 +1,29 @@
+## MobileDrawerNavigation
+
+The MobileDrawerNavigation component is intended to be used on small
+(mobile-sized) screens only. It can be used as an alternative to the larger
+navigation tab component.
+
+The component is responsible for showing:
+
+- The heading of the currently selected page
+- A navigation dialog with the given links
+
+This component is best placed at the top of a page. The dialog utilizes
+``, and will appear from the bottom of the screen.
+
+### MobileDrawerNavigation Accessibility
+
+The MobileDrawerNavigation component requires a `navigationTitle` prop which
+will be used for:
+
+- The activating button for the navigation dialog
+- The label of the navigation dialog
+- The label of the navigation element containing the links
+
+This helps ensure accessible element names are surfaced to all users.
+
+The component also requires a `headingLevel` prop which is used to determine the
+HTML element used for the displayed current page title (e.g. `1` will render an
+`h1`). Appropriate consideration should be given to which heading level is
+semantically correct for the location the component is rendered.
diff --git a/app/javascript/crayons/navigation/MobileDrawerNavigation/__tests__/MobileDrawerNavigation.spec.js b/app/javascript/crayons/navigation/MobileDrawerNavigation/__tests__/MobileDrawerNavigation.spec.js
new file mode 100644
index 000000000..fab8b94e6
--- /dev/null
+++ b/app/javascript/crayons/navigation/MobileDrawerNavigation/__tests__/MobileDrawerNavigation.spec.js
@@ -0,0 +1,85 @@
+import { h } from 'preact';
+import { axe } from 'jest-axe';
+import '@testing-library/jest-dom';
+import { render, waitFor } from '@testing-library/preact';
+import { MobileDrawerNavigation } from '../MobileDrawerNavigation';
+
+describe('', () => {
+ const testLinks = [
+ { url: '/#1', displayName: 'Link 1', isCurrentPage: true },
+ { url: '/#2', displayName: 'Link 2', isCurrentPage: false },
+ { url: '/#3', displayName: 'Link 3', isCurrentPage: false },
+ ];
+
+ it('should have no a11y violations when closed', async () => {
+ const { container } = render(
+ ,
+ );
+
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ it('should have no a11y violations when open', async () => {
+ const { container, getByRole } = render(
+ ,
+ );
+
+ getByRole('button', { name: 'Test navigation' }).click();
+ await waitFor(() => getByRole('navigation', { name: 'Test navigation' }));
+
+ const results = await axe(container);
+ expect(results).toHaveNoViolations();
+ });
+
+ it('should render a heading with a button when closed', () => {
+ const { container } = render(
+ ,
+ );
+
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it('should render a navigation with a checkmark for current page when open', async () => {
+ const { container, getByRole } = render(
+ ,
+ );
+
+ getByRole('button', { name: 'Test navigation' }).click();
+ await waitFor(() => getByRole('navigation', { name: 'Test navigation' }));
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it('should render all links', async () => {
+ const { getByRole } = render(
+ ,
+ );
+
+ getByRole('button', { name: 'Test navigation' }).click();
+ await waitFor(() => getByRole('navigation', { name: 'Test navigation' }));
+
+ expect(getByRole('link', { name: 'Link 1' })).toBeInTheDocument();
+ expect(getByRole('link', { name: 'Link 2' })).toBeInTheDocument();
+ expect(getByRole('link', { name: 'Link 3' })).toBeInTheDocument();
+ });
+});
diff --git a/app/javascript/crayons/navigation/MobileDrawerNavigation/__tests__/__snapshots__/MobileDrawerNavigation.spec.js.snap b/app/javascript/crayons/navigation/MobileDrawerNavigation/__tests__/__snapshots__/MobileDrawerNavigation.spec.js.snap
new file mode 100644
index 000000000..4522fa1f2
--- /dev/null
+++ b/app/javascript/crayons/navigation/MobileDrawerNavigation/__tests__/__snapshots__/MobileDrawerNavigation.spec.js.snap
@@ -0,0 +1,5 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[` should render a heading with a button when closed 1`] = `"
Link 1
"`;
+
+exports[` should render a navigation with a checkmark for current page when open 1`] = `"
Link 1
"`;
diff --git a/app/javascript/crayons/navigation/index.js b/app/javascript/crayons/navigation/index.js
new file mode 100644
index 000000000..f002afbeb
--- /dev/null
+++ b/app/javascript/crayons/navigation/index.js
@@ -0,0 +1 @@
+export * from './MobileDrawerNavigation/MobileDrawerNavigation';