diff --git a/README.md b/README.md
index 3030f28..abf61f4 100644
--- a/README.md
+++ b/README.md
@@ -243,3 +243,54 @@ Defines the sampling rate for the MobileWebMainMenuClickTracking schema.
* Type: `Number`
* Default: `0`
+
+### Components
+
+Components may be shared between server and client. Keeping all code for a single component only in
+one directory makes it easier to understand the complete domain of a component, all of its implicit
+dependencies, and also what it is independent of. The structure does not hint at ResourceLoader
+module bundling of resources and code. That is the domain of skin.json.
+
+New components are stored under components/. Potential older components are stored under includes/
+and resources/, and those directory structures imperfectly represent ResourceLoader module
+divisions.
+
+#### Mustache
+
+Mustache templates at the root components/ directory, like components/PageActionsMenu.mustache or
+components/ToggleList.mustache, are designed to be rendered as root templates not partials. E.g.:
+
+```lang=php
+// 🆗
+
+$templatesDir = __DIR__ . '/../../components';
+$invalidateTemplateCache = false;
+$templateParser = new TemplateParser( $templatesDir, $invalidateTemplateCache );
+
+// Render components/ToggleList.mustache not components/ToggleList/ToggleList.mustache.
+$html = $templateParser->processTemplate( 'ToggleList', $data );
+```
+
+Attempting to render a partial as a template root will fail because of components/ root path
+assumptions:
+
+```lang=php
+// 🚫
+
+$templatesDir = __DIR__ . '/../../components/ToggleList';
+$invalidateTemplateCache = false;
+$templateParser = new TemplateParser( $templatesDir, $invalidateTemplateCache );
+
+// Error: components/ToggleList/ToggleList.mustache references
+// components/ToggleList/ToggleList/ToggleListItem.mustache which does not exist.
+$html = $templateParser->processTemplate( 'ToggleList', $data );
+```
+
+Partials in components/ subdirectories, like components/PageActionsMenu/PageActionsMenu.mustache or
+components/ToggleList/ToggleList.mustache, are for in-template partial composition only as their
+paths assume the render root is components/. E.g.:
+
+```lang=mustache
+{{! Include components/ToggleList/ToggleList.mustache, not components/ToggleList.mustache. }}
+{{> ToggleList/ToggleList}}
+```
diff --git a/components/PageActionsMenu.mustache b/components/PageActionsMenu.mustache
new file mode 100644
index 0000000..dcd84d1
--- /dev/null
+++ b/components/PageActionsMenu.mustache
@@ -0,0 +1 @@
+{{> PageActionsMenu/PageActionsMenu}}
\ No newline at end of file
diff --git a/components/PageActionsMenu/PageActionsMenu.mustache b/components/PageActionsMenu/PageActionsMenu.mustache
new file mode 100644
index 0000000..626be61
--- /dev/null
+++ b/components/PageActionsMenu/PageActionsMenu.mustache
@@ -0,0 +1,18 @@
+
diff --git a/components/ToggleList.mustache b/components/ToggleList.mustache
new file mode 100644
index 0000000..d9f2c9c
--- /dev/null
+++ b/components/ToggleList.mustache
@@ -0,0 +1 @@
+{{> ToggleList/ToggleList}}
\ No newline at end of file
diff --git a/components/ToggleList/DropDownList.less b/components/ToggleList/DropDownList.less
new file mode 100644
index 0000000..f514e0b
--- /dev/null
+++ b/components/ToggleList/DropDownList.less
@@ -0,0 +1,22 @@
+// A DropDownList is a ToggleList that extends downward.
+
+@import '../../minerva.less/minerva.mixins';
+
+.toggle-list__list--drop-down {
+ transform: translateY( -8px );
+
+ // Animate menu visibility, opacity, and translation changes in and out. Visibility must be
+ // animated since it's a boolean and nothing can be seen in display hidden. Visibility itself
+ // cannot be animated as it causes a flash on page load in Chromium due to
+ // https://bugs.chromium.org/p/chromium/issues/detail?id=332189. The effect is that the menu is
+ // animated in but not animated out.
+ .transition( opacity .1s ease-in-out, -webkit-tap-highlight-color 0s ease-in-out, transform .1s ease-in-out; );
+
+ // When cursor is pointer and -webkit-tap-highlight-color is set, the color does not seem to
+ // transition. Clear it.
+ -webkit-tap-highlight-color: transparent;
+}
+
+.toggle-list__checkbox:checked ~ .toggle-list__list--drop-down {
+ transform: translateY( 0 );
+}
diff --git a/components/ToggleList/MenuListItem.less b/components/ToggleList/MenuListItem.less
new file mode 100644
index 0000000..7fe93c2
--- /dev/null
+++ b/components/ToggleList/MenuListItem.less
@@ -0,0 +1,32 @@
+// A MenuListItem is a ToggleList item for menus.
+
+@import '../../minerva.less/minerva.variables';
+@import '../../minerva.less/minerva.mixins';
+
+.toggle-list-item__anchor--menu {
+ font-size: @pageActionFontSize;
+ font-weight: bold;
+ // Fill the list item cell.
+ .box-sizing( border-box );
+ display: block;
+ width: 100%;
+ //
+ padding: 1em;
+ white-space: nowrap;
+ // Left-align text. Button elements are centered.
+ text-align: left;
+ //
+ color: @grayMediumDark;
+
+ &:visited, &:active {
+ // Visited and active links need extra specificity.
+ color: @grayMediumDark;
+ }
+ //
+ // Make the app feel like an app, not a JPEG. When hovering over a menu item, add a little
+ // interactivity.
+ &:hover {
+ text-decoration: none;
+ background: @grayLightest;
+ }
+}
diff --git a/components/ToggleList/ToggleList.js b/components/ToggleList/ToggleList.js
new file mode 100644
index 0000000..558799c
--- /dev/null
+++ b/components/ToggleList/ToggleList.js
@@ -0,0 +1,99 @@
+( function ( M ) {
+ var
+ /** The component selector. */
+ selector = '.toggle-list',
+ /** The visible label icon associated with the checkbox. */
+ toggleSelector = '.toggle-list__toggle',
+ /** The underlying hidden checkbox that controls list visibility. */
+ checkboxSelector = '.toggle-list__checkbox',
+ listSelector = '.toggle-list__list';
+
+ /**
+ * Automatically dismiss the list when clicking or focusing elsewhere and update the
+ * aria-expanded attribute based on list visibility.
+ * @param {Window} window
+ * @param {HTMLElement} component
+ * @param {OO.EventEmitter} eventBus
+ * @param {boolean} [resize] If true, resize the menu on scroll and window resize.
+ * @return {void}
+ */
+ function bind( window, component, eventBus, resize ) {
+ var
+ toggle = component.querySelector( toggleSelector ),
+ checkbox = /** @type {HTMLInputElement} */ (
+ component.querySelector( checkboxSelector )
+ ),
+ list = component.querySelector( listSelector );
+
+ window.addEventListener( 'click', function ( event ) {
+ if ( event.target !== toggle && event.target !== checkbox ) {
+ // Something besides the button or checkbox was tapped. Dismiss the list.
+ _dismiss( checkbox );
+ }
+ }, true );
+
+ // If focus is given to any element outside the list, dismiss the list. Setting a focusout
+ // listener on list would be preferable, but this interferes with the click listener.
+ window.addEventListener( 'focusin', function ( event ) {
+ if ( event.target instanceof Node && !component.contains( event.target ) ) {
+ // Something besides the button or checkbox was focused. Dismiss the list.
+ _dismiss( checkbox );
+ }
+ }, true );
+
+ checkbox.addEventListener( 'change', _updateAriaExpanded.bind( undefined, checkbox ) );
+
+ if ( resize ) {
+ eventBus.on( 'scroll:throttled', _resize.bind( undefined, list ) );
+ eventBus.on( 'resize:throttled', _resize.bind( undefined, list ) );
+ }
+ }
+
+ /**
+ * @param {HTMLElement} component
+ * @param {boolean} [resize] If true, resize the menu to fit within the window.
+ * @return {void}
+ */
+ function render( component, resize ) {
+ var list = /** @type {HTMLElement} */ ( component.querySelector( listSelector ) );
+ if ( resize ) {
+ _resize( list );
+ }
+ }
+
+ /**
+ * Hides the list.
+ * @param {HTMLInputElement} checkbox
+ * @return {void}
+ */
+ function _dismiss( checkbox ) {
+ checkbox.checked = false;
+ _updateAriaExpanded( checkbox );
+ }
+
+ /**
+ * @param {HTMLElement} list
+ * @return {void}
+ */
+ function _resize( list ) {
+ var rect = list.getClientRects()[ 0 ];
+ if ( rect ) {
+ list.style.maxHeight = window.document.documentElement.clientHeight - rect.top + 'px';
+ }
+ }
+
+ /**
+ * Revise the aria-expanded state to match the checked state.
+ * @param {HTMLInputElement} checkbox
+ * @return {void}
+ */
+ function _updateAriaExpanded( checkbox ) {
+ checkbox.setAttribute( 'aria-expanded', ( !!checkbox.checked ).toString() );
+ }
+
+ M.define( 'skins.minerva.scripts/ToggleList', Object.freeze( {
+ selector: selector,
+ render: render,
+ bind: bind
+ } ) );
+}( mw.mobileFrontend ) );
diff --git a/components/ToggleList/ToggleList.less b/components/ToggleList/ToggleList.less
new file mode 100644
index 0000000..839cbc0
--- /dev/null
+++ b/components/ToggleList/ToggleList.less
@@ -0,0 +1,51 @@
+@import '../../minerva.less/minerva.variables';
+
+.toggle-list__checkbox {
+ // Always occlude the checkbox. The checkbox display cannot be none since its focus state is used
+ // for other selectors.
+ position: absolute;
+ z-index: @z-indexOccluded;
+ opacity: 0;
+}
+
+.toggle-list__toggle {
+ // Use the hand icon for the toggle button which is actually a checkbox label.
+ cursor: pointer;
+}
+
+.toggle-list__checkbox:focus + .toggle-list__toggle {
+ // The toggle button / label itself cannot receive focus but the underlying checkbox can. Keep
+ // the button and checkbox focus presentation in sync. From
+ // resources/src/mediawiki.toc.styles/screen.less.
+ outline: dotted 1px; /* Firefox style for focus */
+ outline: auto @colorProgressiveHighlight; /* Webkit style for focus */
+}
+
+.touch-events .toggle-list__checkbox:focus + .toggle-list__toggle {
+ // Buttons have no focus outline on mobile.
+ outline: 0;
+}
+
+.toggle-list__list {
+ // The menu appears over the content and occupies no room within it.
+ position: absolute;
+ //
+ // If max-height is set and the height exceeds it, add a vertical scrollbar.
+ overflow-y: auto;
+ //
+ // The menu floats over content but below overlays.
+ z-index: @z-indexDrawer;
+ //
+ background: @skinContentBgColor;
+ box-shadow: 0 5px 17px 0 rgba( 0, 0, 0, 0.24 ), 0 0 1px @colorGray10;
+ border-radius: @borderRadius;
+ //
+ visibility: hidden;
+ opacity: 0;
+}
+
+.toggle-list__checkbox:checked ~ .toggle-list__list {
+ // Reveal the list when checked.
+ visibility: visible;
+ opacity: 1;
+}
diff --git a/components/ToggleList/ToggleList.mustache b/components/ToggleList/ToggleList.mustache
new file mode 100644
index 0000000..ba7d3af
--- /dev/null
+++ b/components/ToggleList/ToggleList.mustache
@@ -0,0 +1,27 @@
+{{!
+ A list with visibility toggled by a checkbox.
+ string|null class Optional CSS class for the root element.
+ string checkboxID CSS identifier unique to the page needed to connect label and input.
+ string|null toggleID Optional toggle button CSS identifier to connect label and toggle aria.
+ string|null toggleClass Optional toggle button CSS class.
+ string|null listClass Optional list CSS class.
+ string|null text Optional text and aria label for the toggle button.
+ array|null items Optional array of drop down list items for the unordered list.
+}}
+