diff --git a/core/composer.json b/core/composer.json
index 550c781..1c7fc8a 100644
--- a/core/composer.json
+++ b/core/composer.json
@@ -109,6 +109,7 @@
         "drupal/migrate_drupal_ui": "self.version",
         "drupal/node": "self.version",
         "drupal/options": "self.version",
+        "drupal/outside_in": "self.version",
         "drupal/page_cache": "self.version",
         "drupal/path": "self.version",
         "drupal/quickedit": "self.version",
diff --git a/core/modules/outside_in/css/offcanvas.css b/core/modules/outside_in/css/offcanvas.css
new file mode 100644
index 0000000..c4d387c
--- /dev/null
+++ b/core/modules/outside_in/css/offcanvas.css
@@ -0,0 +1,43 @@
+#offcanvas {
+  background: #3c3c3c;
+  color: #fff;
+
+  box-sizing: border-box;
+  z-index: 1000;
+
+  position: fixed;
+  height: 100%;
+  overflow-y: auto;
+
+  border-left: 1px solid #000;
+  box-shadow: -2px 3px 1px 1px rgba(0, 0, 0, 0.3333);
+
+  display: inline-block;
+
+  width: 25%;
+}
+
+#offcanvas span.offcanvasClose {
+  float: right;
+  margin: 15px 15px 0 0;
+}
+
+#offcanvas h1 {
+  margin: 0 0 15px 0;
+  padding: 15px;
+  color: #fff;
+  border-bottom: 1px solid #fff;
+}
+
+#offcanvas .content {
+  padding: 15px;
+}
+
+#canvas-tray.offCanvasDisplayInProgress {
+  position: fixed;
+  display: inline-block;
+}
+
+#canvas-tray.offCanvasDisplayed {
+  display: inline-block;
+}
diff --git a/core/modules/outside_in/js/offcanvas.js b/core/modules/outside_in/js/offcanvas.js
new file mode 100644
index 0000000..d72144a
--- /dev/null
+++ b/core/modules/outside_in/js/offcanvas.js
@@ -0,0 +1,157 @@
+/**
+ * @file
+ * Drupal's off canvas library.
+ */
+
+(function ($, Drupal) {
+  'use strict';
+
+  /**
+   * Create a wrapper container for the off canvas element.
+   * @param  {number} pageWidth
+   *   The width of #page-wrapper.
+   * @return {object}
+   *   jQuery object that is the off canvas wrapper element.
+   */
+  var createOffCanvasWrapper = function (pageWidth) {
+    return $('<div />', {
+      id: 'offcanvas',
+      'role': 'region',
+      'aria-labelledby': 'offcanvas-header',
+      css: {
+        right: '-25%'
+      }
+    });
+  };
+
+  /**
+   * Create the title element for the off canvas element.
+   * @param  {string} title
+   *   The title string.
+   * @return {object}
+   *   jQuery object that is the off canvas title element.
+   */
+  var createTitle = function (title) {
+    return $('<h1 />', {text: title, id: 'offcanvas-header'});
+  };
+
+  /**
+   * Create the actual off canvas content.
+   * @param  {string} data
+   *   This is fully rendered html from Drupal.
+   * @return {object}
+   *   jQuery object that is the off canvas content element.
+   */
+  var createOffCanvasContent = function (data) {
+    return $('<div />', {class: 'content', html: data});
+  };
+
+  /**
+   * Create the off canvas close element.
+   * @param  {object} offCanvasWrapper
+   *   The jQuery off canvas wrapper element
+   * @param  {object} page
+   *   The #page element.
+   * @param  {number} pageWidth
+   *   The width of #page-wrapper
+   * @param  {number} animationDuration
+   *   The duration of the animation.
+   * @return {object}
+   *   jQuery object that is the off canvas close element.
+   */
+  var createOffCanvasClose = function (offCanvasWrapper, page, pageWidth, animationDuration) {
+    return $('<button />', {class: 'offcanvasClose', 'aria-label': Drupal.t('Close configuration tray.'), text: 'x'}).click(function () {
+      offCanvasWrapper.animate({right: -(pageWidth * .2)}, {duration: animationDuration, queue: false});
+      page
+        .animate({width: '100%'}, {duration: animationDuration, queue: false, complete: function () {
+          // Remove some leftovers on $page.
+          page
+            .removeClass('offCanvasDisplayed')
+            .removeAttr('style');
+
+          // Remove off canvas element, and set display state variable.
+          Drupal.offCanvas.visible = false;
+          offCanvasWrapper.remove();
+          Drupal.announce(Drupal.t('Configuration tray closed.'));
+        }});
+    });
+  };
+
+
+  /**
+   * Command to open an off canvas element.
+   *
+   * @param {Drupal.Ajax} ajax
+   *   The Drupal Ajax object.
+   * @param {object} response
+   *   Object holding the server response.
+   * @param {number} [status]
+   *   The HTTP status code.
+   *
+   * @return {bool}
+   *   Returns false.
+   */
+  Drupal.AjaxCommands.prototype.openOffCanvas = function (ajax, response, status) {
+    // Set animation duration and get #page-wrapper width.
+    var animationDuration = 600;
+    var $pageWrapper = $('#canvas-tray-wrapper');
+    var pageWidth = $pageWrapper.width();
+
+    var $page = $('#canvas-tray');
+
+    // Set the initial state of the off canvas element.
+    // If the state has been set previously, use it.
+    Drupal.offCanvas = {
+      visible: Drupal.offCanvas ? Drupal.offCanvas.visible : false
+    };
+
+    // Construct off canvas wrapper
+    var $offcanvasWrapper = createOffCanvasWrapper(pageWidth);
+
+    // Construct off canvas internal elements.
+    var $offcanvasClose = createOffCanvasClose($offcanvasWrapper, $page, pageWidth, animationDuration);
+    var $title = createTitle(response.dialogOptions.title);
+    var $offcanvasContent = createOffCanvasContent(response.data);
+
+    // Put everything together.
+    $offcanvasWrapper.append([$offcanvasClose, $title, $offcanvasContent]);
+
+    // Only add off canvas elements if we have none visible.
+    if (!Drupal.offCanvas.visible) {
+      // Append off canvas wrapper to the 'page'
+      $pageWrapper.append($offcanvasWrapper);
+
+      // Animate $page and $offcanvasWrapper to simulate a slide in effect
+      $page
+        .animate({
+          width: '75%'
+        }, {
+          duration: animationDuration,
+          queue: false,
+          start: function () {
+            $page.addClass('offCanvasDisplayInProgress');
+          },
+          complete: function () {
+            $page
+              .removeClass('offCanvasDisplayInProgress')
+              .addClass('offCanvasDisplayed');
+          }
+        });
+      $offcanvasWrapper
+        .animate({right: '0%'}, {
+          duration: animationDuration,
+          queue: false,
+          start: function () {
+            // Set the offCanvas visible state.
+            Drupal.offCanvas.visible = true;
+          },
+          complete: function() {
+            Drupal.announce(Drupal.t('Configuration tray opened.'));
+          }
+        });
+    }
+
+    return false;
+  };
+
+})(jQuery, Drupal);
diff --git a/core/modules/outside_in/outside_in.info.yml b/core/modules/outside_in/outside_in.info.yml
new file mode 100644
index 0000000..ecf9c3d
--- /dev/null
+++ b/core/modules/outside_in/outside_in.info.yml
@@ -0,0 +1,9 @@
+name: 'Outside In'
+type: module
+description: 'Provides the ability to access useful configuration from the Drupal front-end.'
+package: Core (Experimental)
+version: VERSION
+core: 8.x
+dependencies:
+  - block
+  - toolbar
diff --git a/core/modules/outside_in/outside_in.libraries.yml b/core/modules/outside_in/outside_in.libraries.yml
new file mode 100644
index 0000000..a4265eb
--- /dev/null
+++ b/core/modules/outside_in/outside_in.libraries.yml
@@ -0,0 +1,14 @@
+drupal.off_canvas:
+  version: VERSION
+  js:
+    js/offcanvas.js: {}
+  css:
+    component:
+      css/offcanvas.css: {}
+  dependencies:
+    - core/jquery
+    - core/drupal
+    - core/drupal.ajax
+    - core/drupal.announce
+    - core/drupal.dialog
+    - core/drupal.dialog.ajax
diff --git a/core/modules/outside_in/outside_in.links.contextual.yml b/core/modules/outside_in/outside_in.links.contextual.yml
new file mode 100644
index 0000000..ee83136
--- /dev/null
+++ b/core/modules/outside_in/outside_in.links.contextual.yml
@@ -0,0 +1,4 @@
+outside_in.block_configure:
+  title: 'Quick Edit'
+  route_name: 'entity.block.edit_form'
+  group: 'block'
diff --git a/core/modules/outside_in/outside_in.module b/core/modules/outside_in/outside_in.module
new file mode 100644
index 0000000..adf13c1
--- /dev/null
+++ b/core/modules/outside_in/outside_in.module
@@ -0,0 +1,68 @@
+<?php
+
+/**
+ * @file
+ * Allows configuring blocks and other configuration from the front-end of the site.
+ */
+
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\Component\Utility\Html;
+use Drupal\Component\Utility\Xss;
+/**
+ * Implements hook_help().
+ */
+function outside_in_help($route_name, RouteMatchInterface $route_match) {
+  switch ($route_name) {
+    case 'help.page.outside_in':
+      $output = '<h3>' . t('About') . '</h3>';
+      // @todo Update help text.
+      $output .= '<p>' . t('The Outside In module is something that we should have help for. For more information, see the <a href=":outside-in-documentation">online documentation for the Outside In module</a>.', [':outside-in-documentation' => 'https://www.drupal.org/documentation/modules/outside_in']) . '</p>';
+      return $output;
+  }
+}
+
+/**
+ * Implements hook_contextual_links_view_alter().
+ *
+ * Change Configure Blocks into offcanvas links.
+ */
+function outside_in_contextual_links_view_alter(&$element, $items) {
+  if (isset($element['#links']['outside-inblock-configure'])) {
+    $element['#links']['outside-inblock-configure']['attributes'] = [
+      'class' => ['use-ajax'],
+      'data-dialog-type' => 'offcanvas',
+    ];
+
+    $element['#attached'] = [
+      'library' => [
+        'outside_in/drupal.off_canvas',
+      ],
+    ];
+  }
+}
+
+/**
+ * Implements hook_page_top().
+ *
+ * Opens tag consistant wrapping to html.html.twig for all themes.
+ */
+function outside_in_page_top(array &$page_top) {
+  // TODO:  rename 'outside_in' longterm.
+  $page_top['outside_in'] = [
+    '#markup' => '<div id="canvas-tray-wrapper"><div id="canvas-tray">',
+    '#weight' => 1000
+    ];
+}
+
+/**
+ * Implements hook_page_bottom().
+ *
+ * Closes tag consistant wrapping to html.html.twig for all themes.
+ */
+function outside_in_page_bottom(array &$page_top) {
+  // TODO:  rename 'outside_in' longterm.
+  $page_top['outside_in'] = [
+    '#markup' => '</div></div>',
+    '#weight' => -1000
+  ];
+}
diff --git a/core/modules/outside_in/outside_in.services.yml b/core/modules/outside_in/outside_in.services.yml
new file mode 100644
index 0000000..00b78cf
--- /dev/null
+++ b/core/modules/outside_in/outside_in.services.yml
@@ -0,0 +1,6 @@
+services:
+  main_content_renderer.off_canvas:
+      class: Drupal\outside_in\Render\MainContent\OffCanvasRender
+      arguments: ['@title_resolver', '@renderer']
+      tags:
+        - { name: render.main_content_renderer, format: drupal_offcanvas }
diff --git a/core/modules/outside_in/src/Ajax/OpenOffCanvasDialogCommand.php b/core/modules/outside_in/src/Ajax/OpenOffCanvasDialogCommand.php
new file mode 100644
index 0000000..d61ed4e
--- /dev/null
+++ b/core/modules/outside_in/src/Ajax/OpenOffCanvasDialogCommand.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Drupal\outside_in\Ajax;
+
+use Drupal\Core\Ajax\OpenDialogCommand;
+
+/**
+ * Defines an AJAX command to open content in a dialog in a off canvas tray.
+ *
+ * @ingroup ajax
+ */
+class OpenOffCanvasDialogCommand extends OpenDialogCommand {
+
+  /**
+   * Constructs an OpenOffCanvasDialogCommand object.
+   *
+   * Drupal provides a built-in offcanvas tray for
+   * this purpose, so no selector needs to be provided.
+   *
+   * @todo Do we need a selector? Or act the same as modal?
+   *
+   * @param string $title
+   *   The title of the dialog.
+   * @param string|array $content
+   *   The content that will be placed in the dialog, either a render array
+   *   or an HTML string.
+   * @param array $dialog_options
+   *   (optional) Settings to be passed to the dialog implementation. Any
+   *   jQuery UI option can be used. See http://api.jqueryui.com/dialog.
+   * @param array|null $settings
+   *   (optional) Custom settings that will be passed to the Drupal behaviors
+   *   on the content of the dialog. If left empty, the settings will be
+   *   populated automatically from the current request.
+   */
+  public function __construct($title, $content, array $dialog_options = array(), $settings = NULL) {
+    $dialog_options['modal'] = FALSE;
+    parent::__construct('#drupal-offcanvas', $title, $content, $dialog_options, $settings);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function render() {
+    $this->dialogOptions['modal'] = FALSE;
+    return array(
+      'command' => 'openOffCanvas',
+      'selector' => $this->selector,
+      'settings' => $this->settings,
+      'data' => $this->getRenderedContent(),
+      'dialogOptions' => $this->dialogOptions,
+    );
+  }
+
+}
diff --git a/core/modules/outside_in/src/Render/MainContent/OffCanvasRender.php b/core/modules/outside_in/src/Render/MainContent/OffCanvasRender.php
new file mode 100644
index 0000000..5f46c76
--- /dev/null
+++ b/core/modules/outside_in/src/Render/MainContent/OffCanvasRender.php
@@ -0,0 +1,63 @@
+<?php
+
+namespace Drupal\outside_in\Render\MainContent;
+
+use Drupal\Core\Ajax\AjaxResponse;
+use Drupal\Core\Controller\TitleResolverInterface;
+use Drupal\Core\Render\MainContent\DialogRenderer;
+use Drupal\Core\Render\RendererInterface;
+use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\outside_in\Ajax\OpenOffCanvasDialogCommand;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Default main content renderer for offcanvas dialog requests.
+ */
+class OffCanvasRender extends DialogRenderer {
+
+  /**
+   * The renderer.
+   *
+   * @var \Drupal\Core\Render\RendererInterface
+   */
+  protected $renderer;
+
+  /**
+   * Constructs a new DialogRenderer.
+   *
+   * @param \Drupal\Core\Controller\TitleResolverInterface $title_resolver
+   *   The title resolver.
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   *   The renderer.
+   */
+  public function __construct(TitleResolverInterface $title_resolver, RendererInterface $renderer) {
+    parent::__construct($title_resolver);
+    $this->renderer = $renderer;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function renderResponse(array $main_content, Request $request, RouteMatchInterface $route_match) {
+    $response = new AjaxResponse();
+
+    // First render the main content, because it might provide a title.
+    $content = $this->renderer->renderRoot($main_content);
+
+    // Attach the library necessary for using the OpenModalDialogCommand and set
+    // the attachments for this Ajax response.
+    $main_content['#attached']['library'][] = 'outside_in/drupal.off_canvas';
+    $response->setAttachments($main_content['#attached']);
+
+    // If the main content doesn't provide a title, use the title resolver.
+    $title = isset($main_content['#title']) ? $main_content['#title'] : $this->titleResolver->getTitle($request, $route_match->getRouteObject());
+
+    // Determine the title: use the title provided by the main content if any,
+    // otherwise get it from the routing information.
+    $options = $request->request->get('dialogOptions', array());
+
+    $response->addCommand(new OpenOffCanvasDialogCommand($title, $content, $options));
+    return $response;
+  }
+
+}
diff --git a/core/modules/outside_in/src/Tests/Ajax/OffCanvasDialogTest.php b/core/modules/outside_in/src/Tests/Ajax/OffCanvasDialogTest.php
new file mode 100644
index 0000000..3ad22a8
--- /dev/null
+++ b/core/modules/outside_in/src/Tests/Ajax/OffCanvasDialogTest.php
@@ -0,0 +1,51 @@
+<?php
+
+namespace Drupal\outside_in\Tests\Ajax;
+
+use Drupal\ajax_test\Controller\AjaxTestController;
+use Drupal\Core\EventSubscriber\MainContentViewSubscriber;
+use Drupal\system\Tests\Ajax\AjaxTestBase;
+
+/**
+ * Performs tests on opening and manipulating dialogs via AJAX commands.
+ *
+ * @group Outside In
+ */
+class OffCanvasDialogTest extends AjaxTestBase {
+
+  /**
+   * Modules to enable.
+   *
+   * @var array
+   */
+  public static $modules = array('outside_in');
+
+  /**
+   * Test sending AJAX requests to open and manipulate offcanvas dialog.
+   */
+  public function testDialog() {
+    $this->drupalLogin($this->drupalCreateUser(array('administer contact forms')));
+    // Ensure the elements render without notices or exceptions.
+    $this->drupalGet('ajax-test/dialog');
+
+    // Set up variables for this test.
+    $dialog_renderable = AjaxTestController::dialogContents();
+    $dialog_contents = \Drupal::service('renderer')->renderRoot($dialog_renderable);
+
+    $offcanvas_expected_response = array(
+      'command' => 'openOffCanvas',
+      'selector' => '#drupal-offcanvas',
+      'settings' => NULL,
+      'data' => $dialog_contents,
+      'dialogOptions' => array(
+        'modal' => FALSE,
+        'title' => 'AJAX Dialog contents',
+      ),
+    );
+
+    // Emulate going to the JS version of the page and check the JSON response.
+    $ajax_result = $this->drupalGetAjax('ajax-test/dialog-contents', array('query' => array(MainContentViewSubscriber::WRAPPER_FORMAT => 'drupal_offcanvas')));
+    $this->assertEqual($offcanvas_expected_response, $ajax_result[3], 'Offcanvas dialog JSON response matches.');
+  }
+
+}
diff --git a/core/modules/outside_in/tests/modules/offcanvas_test/offcanvas_test.info.yml b/core/modules/outside_in/tests/modules/offcanvas_test/offcanvas_test.info.yml
new file mode 100644
index 0000000..e21810e
--- /dev/null
+++ b/core/modules/outside_in/tests/modules/offcanvas_test/offcanvas_test.info.yml
@@ -0,0 +1,9 @@
+name: 'Offcanvas tests'
+type: module
+description: 'Provides offcanvas test links.'
+package: Testing
+version: VERSION
+core: 8.x
+dependencies:
+  - block
+  - outside_in
diff --git a/core/modules/outside_in/tests/modules/offcanvas_test/offcanvas_test.routing.yml b/core/modules/outside_in/tests/modules/offcanvas_test/offcanvas_test.routing.yml
new file mode 100644
index 0000000..7bfd52b
--- /dev/null
+++ b/core/modules/outside_in/tests/modules/offcanvas_test/offcanvas_test.routing.yml
@@ -0,0 +1,23 @@
+offcanvas_test.links:
+  path: '/offcanvas-test-links'
+  defaults:
+    _controller: '\Drupal\offcanvas_test\Controller\TestController::linksDisplay'
+    _title: 'Links'
+  requirements:
+    _access: 'TRUE'
+
+offcanvas_test.thing1:
+  path: '/offcanvas-thing1'
+  defaults:
+    _controller: '\Drupal\offcanvas_test\Controller\TestController::thing1'
+    _title: 'Thing 1'
+  requirements:
+    _access: 'TRUE'
+
+offcanvas_test.thing2:
+  path: '/offcanvas-thing2'
+  defaults:
+    _controller: '\Drupal\offcanvas_test\Controller\TestController::thing2'
+    _title: 'Thing 2'
+  requirements:
+    _access: 'TRUE'
diff --git a/core/modules/outside_in/tests/modules/offcanvas_test/src/Controller/TestController.php b/core/modules/outside_in/tests/modules/offcanvas_test/src/Controller/TestController.php
new file mode 100644
index 0000000..8bf28ad
--- /dev/null
+++ b/core/modules/outside_in/tests/modules/offcanvas_test/src/Controller/TestController.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace Drupal\offcanvas_test\Controller;
+use Drupal\Core\Url;
+
+/**
+ * Test controller for 2 different responses.
+ */
+class TestController {
+
+  /**
+   * Thing1.
+   *
+   * @return string
+   *   Return Hello string.
+   */
+  public function thing1() {
+    return [
+      '#type' => 'markup',
+      '#markup' => 'Thing 1 says hello',
+    ];
+  }
+
+  /**
+   * Thing2.
+   *
+   * @return string
+   *   Return Hello string.
+   */
+  public function thing2() {
+    return [
+      '#type' => 'markup',
+      '#markup' => 'Thing 2 says hello',
+    ];
+  }
+
+  /**
+   * Display test links that will open in offcanvas tray.
+   *
+   * @return array
+   *   Render array with links.
+   */
+  public function linksDisplay() {
+    return [
+      'offcanvas_link_1' => [
+        '#title' => 'Click Me 1!',
+        '#type' => 'link',
+        '#url' => Url::fromRoute('offcanvas_test.thing1'),
+        '#attributes' => [
+          'class' => ['use-ajax'],
+          'data-dialog-type' => 'offcanvas',
+        ],
+        '#attached' => [
+          'library' => [
+            'outside_in/drupal.off_canvas',
+          ],
+        ],
+      ],
+      'offcanvas_link_2' => [
+        '#title' => 'Click Me 2!',
+        '#type' => 'link',
+        '#url' => Url::fromRoute('offcanvas_test.thing2'),
+        '#attributes' => [
+          'class' => ['use-ajax'],
+          'data-dialog-type' => 'offcanvas',
+        ],
+        '#attached' => [
+          'library' => [
+            'outside_in/drupal.off_canvas',
+          ],
+        ],
+      ],
+
+    ];
+  }
+
+}
diff --git a/core/modules/outside_in/tests/modules/offcanvas_test/src/Plugin/Block/TestBlock.php b/core/modules/outside_in/tests/modules/offcanvas_test/src/Plugin/Block/TestBlock.php
new file mode 100644
index 0000000..a271cd8
--- /dev/null
+++ b/core/modules/outside_in/tests/modules/offcanvas_test/src/Plugin/Block/TestBlock.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Drupal\offcanvas_test\Plugin\Block;
+
+use Drupal\Core\Block\BlockBase;
+use Drupal\Core\Url;
+
+/**
+ * Provides a 'Powered by Drupal' block.
+ *
+ * @Block(
+ *   id = "offcanvas_links_block",
+ *   admin_label = @Translation("Offcanvas test block")
+ * )
+ */
+class TestBlock extends BlockBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function build() {
+    return [
+      'offcanvas_link_1' => [
+        '#title' => $this->t('Click Me 1!'),
+        '#type' => 'link',
+        '#url' => Url::fromRoute('offcanvas_test.thing1'),
+        '#attributes' => [
+          'class' => ['use-ajax'],
+          'data-dialog-type' => 'offcanvas',
+        ],
+      ],
+      'offcanvas_link_2' => [
+        '#title' => $this->t('Click Me 2!'),
+        '#type' => 'link',
+        '#url' => Url::fromRoute('offcanvas_test.thing2'),
+        '#attributes' => [
+          'class' => ['use-ajax'],
+          'data-dialog-type' => 'offcanvas',
+        ],
+      ],
+      '#attached' => [
+        'library' => [
+          'outside_in/drupal.off_canvas',
+        ],
+      ],
+    ];
+  }
+
+}
diff --git a/core/modules/outside_in/tests/src/Unit/Ajax/AjaxCommandsTest.php b/core/modules/outside_in/tests/src/Unit/Ajax/AjaxCommandsTest.php
new file mode 100644
index 0000000..d907b6c
--- /dev/null
+++ b/core/modules/outside_in/tests/src/Unit/Ajax/AjaxCommandsTest.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Drupal\Tests\outside_in\Unit\Ajax;
+
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * Test coverage for various classes in the \Drupal\Core\Ajax namespace.
+ *
+ * @group Ajax
+ */
+class AjaxCommandsTest extends UnitTestCase {
+
+  /**
+   * @covers \Drupal\outside_in\Ajax\OpenOffCanvasDialogCommand
+   */
+  public function testOpenOffCanvasDialogCommand() {
+    $command = $this->getMockBuilder('Drupal\outside_in\Ajax\OpenOffCanvasDialogCommand')
+      ->setConstructorArgs(array(
+        'Title', '<p>Text!</p>', array(
+          'url' => 'example',
+        ),
+      ))
+      ->setMethods(array('getRenderedContent'))
+      ->getMock();
+
+    // This method calls the render service, which isn't available. We want it
+    // to do nothing so we mock it to return a known value.
+    $command->expects($this->once())
+      ->method('getRenderedContent')
+      ->willReturn('rendered content');
+
+    $expected = array(
+      'command' => 'openOffCanvas',
+      'selector' => '#drupal-offcanvas',
+      'settings' => NULL,
+      'data' => 'rendered content',
+      'dialogOptions' => array(
+        'url' => 'example',
+        'title' => 'Title',
+        'modal' => FALSE,
+      ),
+    );
+    $this->assertEquals($expected, $command->render());
+  }
+
+}
