diff --git a/src/Form/AjaxFormTrait.php b/src/Form/AjaxFormTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..0bc4bc7e15a128a579b2220c35de509446bb875a
--- /dev/null
+++ b/src/Form/AjaxFormTrait.php
@@ -0,0 +1,48 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\ctools\Form\AjaxFormTrait.
+ */
+
+namespace Drupal\ctools\Form;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\NestedArray;
+
+/**
+ * Provides helper methods for using an AJAX modal.
+ */
+trait AjaxFormTrait {
+
+  /**
+   * Gets attributes for use with an AJAX modal.
+   *
+   * @return array
+   */
+   public static function getAjaxAttributes() {
+    return [
+      'class' => ['use-ajax'],
+      'data-dialog-type' => 'modal',
+      'data-dialog-options' => Json::encode([
+        'width' => 'auto',
+      ]),
+    ];
+  }
+
+  /**
+   * Gets attributes for use with an add button AJAX modal.
+   *
+   * @return array
+   */
+   public static function getAjaxButtonAttributes() {
+    return NestedArray::mergeDeep(AjaxFormTrait::getAjaxAttributes(), [
+      'class' => [
+        'button',
+        'button--small',
+        'button-action',
+      ],
+    ]);
+  }
+
+}
diff --git a/src/Plugin/BlockPluginCollection.php b/src/Plugin/BlockPluginCollection.php
new file mode 100644
index 0000000000000000000000000000000000000000..1d3dd0f6f672cb55fa077d27de388093d79b70e3
--- /dev/null
+++ b/src/Plugin/BlockPluginCollection.php
@@ -0,0 +1,58 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\ctools\Plugin\BlockPluginCollection.
+ */
+
+namespace Drupal\ctools\Plugin;
+
+use Drupal\Core\Block\BlockPluginInterface;
+use Drupal\Core\Plugin\DefaultLazyPluginCollection;
+
+/**
+ * Provides a collection of block plugins.
+ */
+class BlockPluginCollection extends DefaultLazyPluginCollection {
+
+  /**
+   * {@inheritdoc}
+   *
+   * @return \Drupal\Core\Block\BlockPluginInterface
+   */
+  public function &get($instance_id) {
+    return parent::get($instance_id);
+  }
+
+  /**
+   * Returns all blocks keyed by their region.
+   *
+   * @return array
+   *   An associative array keyed by region, containing an associative array of
+   *   block plugins.
+   */
+  public function getAllByRegion() {
+    $region_assignments = [];
+    foreach ($this as $block_id => $block) {
+      $configuration = $block->getConfiguration();
+      $region = isset($configuration['region']) ? $configuration['region'] : NULL;
+      $region_assignments[$region][$block_id] = $block;
+    }
+    foreach ($region_assignments as $region => $region_assignment) {
+      // @todo Determine the reason this needs error suppression.
+      @uasort($region_assignment, function (BlockPluginInterface $a, BlockPluginInterface $b) {
+        $a_config = $a->getConfiguration();
+        $a_weight = isset($a_config['weight']) ? $a_config['weight'] : 0;
+        $b_config = $b->getConfiguration();
+        $b_weight = isset($b_config['weight']) ? $b_config['weight'] : 0;
+        if ($a_weight == $b_weight) {
+          return strcmp($a->label(), $b->label());
+        }
+        return $a_weight > $b_weight ? 1 : -1;
+      });
+      $region_assignments[$region] = $region_assignment;
+    }
+    return $region_assignments;
+  }
+
+}
diff --git a/src/Plugin/BlockVariantInterface.php b/src/Plugin/BlockVariantInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..bb745ee32b5e3dbbc01bf0cd52a324abb806d9f5
--- /dev/null
+++ b/src/Plugin/BlockVariantInterface.php
@@ -0,0 +1,101 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\ctools\Plugin\BlockVariantInterface.
+ */
+
+namespace Drupal\ctools\Plugin;
+
+use Drupal\Core\Display\VariantInterface;
+
+/**
+ * Provides an interface for variant plugins that use block plugins.
+ */
+interface BlockVariantInterface extends VariantInterface {
+
+  /**
+   * Returns the human-readable list of regions keyed by machine name.
+   *
+   * @return array
+   *   An array of human-readable region names keyed by machine name.
+   */
+  public function getRegionNames();
+
+  /**
+   * Returns the human-readable name of a specific region.
+   *
+   * @param string $region
+   *   The machine name of a region.
+   *
+   * @return string
+   *   The human-readable name of a region.
+   */
+  public function getRegionName($region);
+
+  /**
+   * Adds a block to this display variant.
+   *
+   * @param array $configuration
+   *   An array of block configuration.
+   *
+   * @return string
+   *   The block ID.
+   */
+  public function addBlock(array $configuration);
+
+  /**
+   * Returns the region a specific block is assigned to.
+   *
+   * @param string $block_id
+   *   The block ID.
+   *
+   * @return string
+   *   The machine name of the region this block is assigned to.
+   */
+  public function getRegionAssignment($block_id);
+
+  /**
+   * Returns an array of regions and their block plugins.
+   *
+   * @return array
+   *   The array is first keyed by region machine name, with the values
+   *   containing an array keyed by block ID, with block plugin instances as the
+   *   values.
+   */
+  public function getRegionAssignments();
+
+  /**
+   * Returns a specific block plugin.
+   *
+   * @param string $block_id
+   *   The block ID.
+   *
+   * @return \Drupal\Core\Block\BlockPluginInterface
+   *   The block plugin.
+   */
+  public function getBlock($block_id);
+
+  /**
+   * Updates the configuration of a specific block plugin.
+   *
+   * @param string $block_id
+   *   The block ID.
+   * @param array $configuration
+   *   The array of configuration to set.
+   *
+   * @return $this
+   */
+  public function updateBlock($block_id, array $configuration);
+
+  /**
+   * Removes a specific block from this display variant.
+   *
+   * @param string $block_id
+   *   The block ID.
+   *
+   * @return $this
+   */
+  public function removeBlock($block_id);
+
+}
diff --git a/src/Plugin/BlockVariantTrait.php b/src/Plugin/BlockVariantTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..bc97d822758969774677166e50e0f002d963bc79
--- /dev/null
+++ b/src/Plugin/BlockVariantTrait.php
@@ -0,0 +1,115 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\ctools\Plugin\BlockVariantTrait.
+ */
+
+namespace Drupal\ctools\Plugin;
+
+/**
+ * Provides methods for \Drupal\ctools\Plugin\BlockVariantInterface.
+ */
+trait BlockVariantTrait {
+
+  /**
+   * The plugin collection that holds the block plugins.
+   *
+   * @var \Drupal\ctools\Plugin\BlockPluginCollection
+   */
+  protected $blockPluginCollection;
+
+  /**
+   * @see \Drupal\ctools\Plugin\BlockVariantInterface::getRegionNames()
+   */
+  abstract public function getRegionNames();
+
+  /**
+   * @see \Drupal\ctools\Plugin\BlockVariantInterface::getBlock()
+   */
+  public function getBlock($block_id) {
+    return $this->getBlockCollection()->get($block_id);
+  }
+
+  /**
+   * @see \Drupal\ctools\Plugin\BlockVariantInterface::addBlock()
+   */
+  public function addBlock(array $configuration) {
+    $configuration['uuid'] = $this->uuidGenerator()->generate();
+    $this->getBlockCollection()->addInstanceId($configuration['uuid'], $configuration);
+    return $configuration['uuid'];
+  }
+
+  /**
+   * @see \Drupal\ctools\Plugin\BlockVariantInterface::removeBlock()
+   */
+  public function removeBlock($block_id) {
+    $this->getBlockCollection()->removeInstanceId($block_id);
+    return $this;
+  }
+
+  /**
+   * @see \Drupal\ctools\Plugin\BlockVariantInterface::updateBlock()
+   */
+  public function updateBlock($block_id, array $configuration) {
+    $existing_configuration = $this->getBlock($block_id)->getConfiguration();
+    $this->getBlockCollection()->setInstanceConfiguration($block_id, $configuration + $existing_configuration);
+    return $this;
+  }
+
+  /**
+   * @see \Drupal\ctools\Plugin\BlockVariantInterface::getRegionAssignment()
+   */
+  public function getRegionAssignment($block_id) {
+    $configuration = $this->getBlock($block_id)->getConfiguration();
+    return isset($configuration['region']) ? $configuration['region'] : NULL;
+  }
+
+  /**
+   * @see \Drupal\ctools\Plugin\BlockVariantInterface::getRegionAssignments()
+   */
+  public function getRegionAssignments() {
+    // Build an array of the region names in the right order.
+    $empty = array_fill_keys(array_keys($this->getRegionNames()), []);
+    $full = $this->getBlockCollection()->getAllByRegion();
+    // Merge it with the actual values to maintain the ordering.
+    return array_intersect_key(array_merge($empty, $full), $empty);
+  }
+
+  /**
+   * @see \Drupal\ctools\Plugin\BlockVariantInterface::getRegionName()
+   */
+  public function getRegionName($region) {
+    $regions = $this->getRegionNames();
+    return isset($regions[$region]) ? $regions[$region] : '';
+  }
+
+  /**
+   * Returns the block plugins used for this display variant.
+   *
+   * @return \Drupal\Core\Block\BlockPluginInterface[]|\Drupal\ctools\Plugin\BlockPluginCollection
+   *   An array or collection of configured block plugins.
+   */
+  protected function getBlockCollection() {
+    if (!$this->blockPluginCollection) {
+      $this->blockPluginCollection = new BlockPluginCollection(\Drupal::service('plugin.manager.block'), $this->getBlockConfig());
+    }
+    return $this->blockPluginCollection;
+  }
+
+  /**
+   * Returns the UUID generator.
+   *
+   * @return \Drupal\Component\Uuid\UuidInterface
+   */
+  abstract protected function uuidGenerator();
+
+  /**
+   * Returns the configuration for stored blocks.
+   *
+   * @return array
+   *   An array of block configuration, keyed by the unique block ID.
+   */
+  abstract protected function getBlockConfig();
+
+}
diff --git a/src/Plugin/ConditionVariantInterface.php b/src/Plugin/ConditionVariantInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..2d592013d04129753f87279ce01d0cd5bac271da
--- /dev/null
+++ b/src/Plugin/ConditionVariantInterface.php
@@ -0,0 +1,92 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\ctools\Plugin\ConditionVariantInterface.
+ */
+
+namespace Drupal\ctools\Plugin;
+
+use Drupal\Core\Display\VariantInterface;
+
+/**
+ * Provides an interface for variant plugins that use condition plugins.
+ */
+interface ConditionVariantInterface extends VariantInterface {
+
+  /**
+   * Gets the values for all defined contexts.
+   *
+   * @return \Drupal\Component\Plugin\Context\ContextInterface[]
+   *   An array of set contexts, keyed by context name.
+   */
+  public function getContexts();
+
+  /**
+   * Returns the conditions used for determining if this variant is selected.
+   *
+   * @return \Drupal\Core\Condition\ConditionInterface[]|\Drupal\Core\Condition\ConditionPluginCollection
+   *   An array of configured condition plugins.
+   */
+  public function getSelectionConditions();
+
+  /**
+   * Removes a specific selection condition.
+   *
+   * @param string $condition_id
+   *   The selection condition ID.
+   *
+   * @return $this
+   */
+  public function removeSelectionCondition($condition_id);
+
+  /**
+   * Adds a new selection condition to the page entity.
+   *
+   * @param array $configuration
+   *   An array of configuration for the new selection condition.
+   *
+   * @return string
+   *   The selection condition ID.
+   */
+  public function addSelectionCondition(array $configuration);
+
+  /**
+   * Returns the logic used to compute selections, either 'and' or 'or'.
+   *
+   * @return string
+   *   The string 'and', or the string 'or'.
+   */
+  public function getSelectionLogic();
+
+  /**
+   * Returns the definition of the plugin implementation.
+   *
+   * @return array
+   *   The plugin definition, as returned by the discovery object used by the
+   *   plugin manager.
+   */
+  public function getPluginDefinition();
+
+  /**
+   * Sets the context values for this display variant.
+   *
+   * @param \Drupal\Component\Plugin\Context\ContextInterface[] $contexts
+   *   An array of contexts, keyed by context name.
+   *
+   * @return $this
+   */
+  public function setContexts(array $contexts);
+
+  /**
+   * Retrieves a specific selection condition.
+   *
+   * @param string $condition_id
+   *   The selection condition ID.
+   *
+   * @return \Drupal\Core\Condition\ConditionInterface
+   *   The selection condition object.
+   */
+  public function getSelectionCondition($condition_id);
+
+}
diff --git a/src/Plugin/ConditionVariantTrait.php b/src/Plugin/ConditionVariantTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..ba1be95de3b7fc4e0c705eac0d09ad5f78a97346
--- /dev/null
+++ b/src/Plugin/ConditionVariantTrait.php
@@ -0,0 +1,108 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\ctools\Plugin\ConditionVariantTrait.
+ */
+
+namespace Drupal\ctools\Plugin;
+
+use Drupal\Core\Condition\ConditionAccessResolverTrait;
+use Drupal\Core\Condition\ConditionPluginCollection;
+use Drupal\Core\Plugin\ContextAwarePluginInterface;
+
+/**
+ * Provides methods for \Drupal\ctools\Plugin\ConditionVariantInterface.
+ */
+trait ConditionVariantTrait {
+
+  use ConditionAccessResolverTrait;
+
+  /**
+   * The plugin collection that holds the selection condition plugins.
+   *
+   * @var \Drupal\Component\Plugin\LazyPluginCollection
+   */
+  protected $selectionConditionCollection;
+
+  /**
+   * @see \Drupal\ctools\Plugin\ConditionVariantInterface::getSelectionConditions()
+   */
+  public function getSelectionConditions() {
+    if (!$this->selectionConditionCollection) {
+      $this->selectionConditionCollection = new ConditionPluginCollection(\Drupal::service('plugin.manager.condition'), $this->getSelectionConfiguration());
+    }
+    return $this->selectionConditionCollection;
+  }
+
+  /**
+   * @see \Drupal\ctools\Plugin\ConditionVariantInterface::addSelectionCondition()
+   */
+  public function addSelectionCondition(array $configuration) {
+    $configuration['uuid'] = $this->uuidGenerator()->generate();
+    $this->getSelectionConditions()->addInstanceId($configuration['uuid'], $configuration);
+    return $configuration['uuid'];
+  }
+
+  /**
+   * @see \Drupal\ctools\Plugin\ConditionVariantInterface::getSelectionCondition()
+   */
+  public function getSelectionCondition($condition_id) {
+    return $this->getSelectionConditions()->get($condition_id);
+  }
+
+  /**
+   * @see \Drupal\ctools\Plugin\ConditionVariantInterface::removeSelectionCondition()
+   */
+  public function removeSelectionCondition($condition_id) {
+    $this->getSelectionConditions()->removeInstanceId($condition_id);
+    return $this;
+  }
+
+  /**
+   * Determines if the selection conditions will pass given a set of contexts.
+   *
+   * @param \Drupal\Component\Plugin\Context\ContextInterface[] $contexts
+   *   An array of set contexts, keyed by context name.
+   *
+   * @return bool
+   *   TRUE if access is granted, FALSE otherwise.
+   */
+  protected function determineSelectionAccess(array $contexts) {
+    $conditions = $this->getSelectionConditions();
+    foreach ($conditions as $condition) {
+      if ($condition instanceof ContextAwarePluginInterface) {
+        $this->contextHandler()->applyContextMapping($condition, $contexts);
+      }
+    }
+    return $this->resolveConditions($conditions, $this->getSelectionLogic());
+  }
+
+  /**
+   * @see \Drupal\ctools\Plugin\ConditionVariantInterface::getSelectionLogic()
+   */
+  abstract public function getSelectionLogic();
+
+  /**
+   * Returns the configuration for stored selection conditions.
+   *
+   * @return array
+   *   An array of condition configuration, keyed by the unique condition ID.
+   */
+  abstract protected function getSelectionConfiguration();
+
+  /**
+   * Returns the UUID generator.
+   *
+   * @return \Drupal\Component\Uuid\UuidInterface
+   */
+  abstract protected function uuidGenerator();
+
+  /**
+   * Returns the context handler.
+   *
+   * @return \Drupal\Core\Plugin\Context\ContextHandlerInterface
+   */
+  abstract protected function contextHandler();
+
+}
diff --git a/src/Plugin/DisplayVariant/BlockDisplayVariant.php b/src/Plugin/DisplayVariant/BlockDisplayVariant.php
new file mode 100644
index 0000000000000000000000000000000000000000..238aa465397b3594c0c72bcedbcb76bc98790faa
--- /dev/null
+++ b/src/Plugin/DisplayVariant/BlockDisplayVariant.php
@@ -0,0 +1,210 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\ctools\Plugin\DisplayVariant\BlockDisplayVariant.
+ */
+
+namespace Drupal\ctools\Plugin\DisplayVariant;
+
+use Drupal\Component\Uuid\UuidInterface;
+use Drupal\Core\Cache\RefinableCacheableDependencyInterface;
+use Drupal\Core\Cache\RefinableCacheableDependencyTrait;
+use Drupal\Core\Display\VariantBase;
+use Drupal\Core\Display\ContextAwareVariantInterface;
+use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
+use Drupal\Core\Plugin\Context\ContextHandlerInterface;
+use Drupal\Core\Render\Element;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Utility\Token;
+use Drupal\ctools\Form\AjaxFormTrait;
+use Drupal\ctools\Plugin\BlockVariantInterface;
+use Drupal\ctools\Plugin\BlockVariantTrait;
+use Drupal\ctools\Plugin\ConditionVariantInterface;
+use Drupal\ctools\Plugin\ConditionVariantTrait;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a display variant that simply contains blocks.
+ *
+ * @DisplayVariant(
+ *   id = "block_display",
+ *   admin_label = @Translation("Block page")
+ * )
+ */
+abstract class BlockDisplayVariant extends VariantBase implements ContextAwareVariantInterface, ConditionVariantInterface, ContainerFactoryPluginInterface, BlockVariantInterface, RefinableCacheableDependencyInterface {
+
+  use AjaxFormTrait;
+  use BlockVariantTrait;
+  use ConditionVariantTrait;
+  use RefinableCacheableDependencyTrait;
+
+  /**
+   * The context handler.
+   *
+   * @var \Drupal\Core\Plugin\Context\ContextHandlerInterface
+   */
+  protected $contextHandler;
+
+  /**
+   * The UUID generator.
+   *
+   * @var \Drupal\Component\Uuid\UuidInterface
+   */
+  protected $uuidGenerator;
+
+  /**
+   * The current user.
+   *
+   * @var \Drupal\Core\Session\AccountInterface
+   */
+  protected $account;
+
+  /**
+   * The token service.
+   *
+   * @var \Drupal\Core\Utility\Token
+   */
+  protected $token;
+
+  /**
+   * Constructs a new BlockDisplayVariant.
+   *
+   * @param array $configuration
+   *   A configuration array containing information about the plugin instance.
+   * @param string $plugin_id
+   *   The plugin ID for the plugin instance.
+   * @param mixed $plugin_definition
+   *   The plugin implementation definition.
+   * @param \Drupal\Core\Plugin\Context\ContextHandlerInterface $context_handler
+   *   The context handler.
+   * @param \Drupal\Core\Session\AccountInterface $account
+   *   The current user.
+   * @param \Drupal\Component\Uuid\UuidInterface $uuid_generator
+   *   The UUID generator.
+   * @param \Drupal\Core\Utility\Token $token
+   *   The token service.
+   */
+  public function __construct(array $configuration, $plugin_id, $plugin_definition, ContextHandlerInterface $context_handler, AccountInterface $account, UuidInterface $uuid_generator, Token $token) {
+    parent::__construct($configuration, $plugin_id, $plugin_definition);
+
+    $this->contextHandler = $context_handler;
+    $this->account = $account;
+    $this->uuidGenerator = $uuid_generator;
+    $this->token = $token;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
+    return new static(
+      $configuration,
+      $plugin_id,
+      $plugin_definition,
+      $container->get('context.handler'),
+      $container->get('current_user'),
+      $container->get('uuid'),
+      $container->get('token')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function build() {
+    // Set default page cache keys that include the display.
+    $build['#cache']['keys'] = [
+      'ctools_block_display',
+      // The UUID of this display.
+      // @todo should have an API for this?
+      $this->configuration['uuid'],
+    ];
+    $build['#pre_render'][] = [$this, 'buildRegions'];
+    return $build;
+  }
+
+  /**
+   * #pre_render callback for building the regions.
+   */
+  abstract public function buildRegions(array $build);
+
+  /**
+   * {@inheritdoc}
+   */
+  public function access(AccountInterface $account = NULL) {
+    // Delegate to the conditions.
+    return $this->determineSelectionAccess($this->getContexts());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function defaultConfiguration() {
+    return parent::defaultConfiguration() + [
+      'blocks' => [],
+      'selection_conditions' => [],
+      'selection_logic' => 'and',
+      'page_title' => '',
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function calculateDependencies() {
+    foreach ($this->getBlockCollection() as $instance) {
+      $this->calculatePluginDependencies($instance);
+    }
+    foreach ($this->getSelectionConditions() as $instance) {
+      $this->calculatePluginDependencies($instance);
+    }
+    return $this->dependencies;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getConfiguration() {
+    return [
+      'selection_conditions' => $this->getSelectionConditions()->getConfiguration(),
+      'blocks' => $this->getBlockCollection()->getConfiguration(),
+    ] + parent::getConfiguration();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getSelectionLogic() {
+    return $this->configuration['selection_logic'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function contextHandler() {
+    return $this->contextHandler;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getSelectionConfiguration() {
+    return $this->configuration['selection_conditions'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getBlockConfig() {
+    return $this->configuration['blocks'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function uuidGenerator() {
+    return $this->uuidGenerator;
+  }
+
+}
diff --git a/tests/src/Unit/BlockDisplayVariantTest.php b/tests/src/Unit/BlockDisplayVariantTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..b2dd2d7b4dc9ac938999cee6efb7df4f38804ea1
--- /dev/null
+++ b/tests/src/Unit/BlockDisplayVariantTest.php
@@ -0,0 +1,422 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Tests\page_manager\Unit\BlockDisplayVariantTest.
+ */
+
+namespace Drupal\Tests\ctools\Unit;
+
+use Drupal\Component\Uuid\UuidInterface;
+use Drupal\Core\Access\AccessResult;
+use Drupal\Core\Block\BlockPluginInterface;
+use Drupal\Core\Cache\Context\CacheContextsManager;
+use Drupal\Core\Plugin\Context\ContextHandlerInterface;
+use Drupal\Core\Plugin\ContextAwarePluginInterface;
+use Drupal\Core\Form\FormState;
+use Drupal\Core\Session\AccountInterface;
+use Drupal\Core\Utility\Token;
+use Drupal\ctools\Plugin\BlockPluginCollection;
+use Drupal\ctools\Plugin\DisplayVariant\BlockDisplayVariant;
+use Drupal\Tests\UnitTestCase;
+use Symfony\Component\DependencyInjection\ContainerBuilder;
+
+/**
+ * Tests the block display variant plugin.
+ *
+ * @coversDefaultClass \Drupal\ctools\Plugin\DisplayVariant\BlockDisplayVariant
+ *
+ * @group PageManager
+ */
+class BlockDisplayVariantTest extends UnitTestCase {
+
+  /**
+   * Tests the access() method.
+   *
+   * @covers ::access
+   */
+  public function testAccess() {
+    $display_variant = $this->getMockBuilder(TestBlockDisplayVariant::class)
+      ->disableOriginalConstructor()
+      ->setMethods(['determineSelectionAccess'])
+      ->getMock();
+    $display_variant->expects($this->once())
+      ->method('determineSelectionAccess')
+      ->willReturn(FALSE);
+    $this->assertSame(FALSE, $display_variant->access());
+
+    $display_variant = $this->getMockBuilder(TestBlockDisplayVariant::class)
+      ->disableOriginalConstructor()
+      ->setMethods(['determineSelectionAccess'])
+      ->getMock();
+    $display_variant->expects($this->once())
+      ->method('determineSelectionAccess')
+      ->willReturn(TRUE);
+    $this->assertSame(TRUE, $display_variant->access());
+  }
+
+  /**
+   * Tests the build() method when a block is empty.
+   *
+   * @covers ::build
+   */
+  public function testBuildEmptyBlock() {
+    $account = $this->prophesize(AccountInterface::class);
+    $block1 = $this->prophesize(BlockPluginInterface::class);
+    $block1->access($account)->willReturn(TRUE);
+
+    // Building a block with empty content.
+    $block1->build()->willReturn(['#cache' => [ 'tags' => [ 0 => 'tag_to_be_merged']]]);
+
+    $context_handler = $this->prophesize(ContextHandlerInterface::class);
+    $uuid_generator = $this->prophesize(UuidInterface::class);
+    $token = $this->prophesize(Token::class);
+
+    $display_variant = new TestBlockDisplayVariant([], '', [], $context_handler->reveal(), $account->reveal(), $uuid_generator->reveal(), $token->reveal());
+
+    // Empty block.
+    $expected_build = [
+      '#markup' => '',
+      '#cache' => [
+        'tags' => [
+          'block_plugin:block_plugin_id',
+          'page:page_id',
+          'tag_to_be_merged',
+        ],
+        'contexts' => [],
+        'max-age' => -1,
+      ],
+    ];
+
+    $build = [
+      '#block_plugin' => $block1->reveal(),
+      '#cache' => [
+        'tags' => [
+          'page:page_id',
+          'block_plugin:block_plugin_id',
+        ],
+      ],
+    ];
+
+    $build = $display_variant->buildBlock($build);
+
+    // Assert that cacheability metadata is merged.
+    $this->assertSame($expected_build, $build);
+  }
+
+  /**
+   * Tests the build() method when blocks can be cached.
+   *
+   * @covers ::build
+   */
+  public function testBuild() {
+    $container = new ContainerBuilder();
+    $cache_contexts = $this->prophesize(CacheContextsManager::class);
+    $container->set('cache_contexts_manager', $cache_contexts->reveal());
+    \Drupal::setContainer($container);
+
+    $account = $this->prophesize(AccountInterface::class);
+
+    // Define one block that allows access, access varies by permissions.
+    $block1 = $this->prophesize(BlockPluginInterface::class);
+    $block1->access($account, TRUE)->willReturn(AccessResult::allowed()->cachePerPermissions());
+    $block1->getConfiguration()->willReturn(['label' => 'Block label']);
+    $block1->getPluginId()->willReturn('block_plugin_id');
+    $block1->getBaseId()->willReturn('block_base_plugin_id');
+    $block1->getDerivativeId()->willReturn('block_derivative_plugin_id');
+    $block1->getCacheTags()->willReturn(['block_plugin1:block_plugin_id']);
+    $block1->getCacheMaxAge()->willReturn(3600);
+    $block1->getCacheContexts()->willReturn(['url']);
+
+    // Define another block that doesn't allow access
+    $block2 = $this->prophesize()->willImplement(ContextAwarePluginInterface::class)->willImplement(BlockPluginInterface::class);
+    $block2->access($account, TRUE)->willReturn(AccessResult::forbidden()->cachePerUser());
+    $block2->getConfiguration()->willReturn([]);
+    $block2->getPluginId()->willReturn('block_plugin_id');
+    $block2->getBaseId()->willReturn('block_base_plugin_id');
+    $block2->getDerivativeId()->willReturn('block_derivative_plugin_id');
+    // The block is not shown, so cacheability metadata is not collected.
+    $block2->getCacheContexts()->shouldNotBeCalled();
+    $block2->getCacheMaxAge()->shouldNotBeCalled();
+    $block2->getCacheTags()->shouldNotBeCalled();
+    $blocks = [
+      'top' => [
+        'block1' => $block1->reveal(),
+        'block2' => $block2->reveal(),
+      ],
+    ];
+    $block_collection = $this->getMockBuilder(BlockPluginCollection::class)
+      ->disableOriginalConstructor()
+      ->getMock();
+    $block_collection->expects($this->once())
+      ->method('getAllByRegion')
+      ->willReturn($blocks);
+
+    $context_handler = $this->prophesize(ContextHandlerInterface::class);
+    $context_handler->applyContextMapping($block2->reveal(), [])->shouldBeCalledTimes(1);
+
+    $uuid_generator = $this->prophesize(UuidInterface::class);
+    $page_title = 'Page title';
+    $token = $this->getMockBuilder(Token::class)
+      ->disableOriginalConstructor()
+      ->getMock();
+    $display_variant = $this->getMockBuilder(BlockDisplayVariant::class)
+      ->setConstructorArgs([['page_title' => $page_title, 'uuid' => 'UUID'], 'test', [], $context_handler->reveal(), $account->reveal(), $uuid_generator->reveal(), $token])
+      ->setMethods(['getBlockCollection', 'renderPageTitle'])
+      ->getMock();
+
+    $page = $this->prophesize(PageInterface::class);
+    $page->id()->willReturn('page_id');
+
+    $display_variant->expects($this->once())
+      ->method('getBlockCollection')
+      ->willReturn($block_collection);
+    $display_variant->expects($this->once())
+      ->method('renderPageTitle')
+      ->with($page_title)
+      ->willReturn($page_title);
+
+    $expected_cache_block1 = [
+      'keys' => ['page_manager_block_display', 'UUID', 'block', 'block1'],
+      'tags' => ['block_plugin1:block_plugin_id'],
+      'contexts' => ['url'],
+      'max-age' => 3600,
+    ];
+
+    // The page cacheability metadata contains the access cacheability metadata
+    // of accessible and non-accessible blocks. Additionally, the cacheability
+    // metadata of accessible blocks is merged to avoid cache redirects when
+    // possible.
+    $expected_cache_page = [
+      'keys' => ['page_manager_block_display', 'UUID'],
+      'contexts' => ['url', 'user', 'user.permissions'],
+      'tags' => ['block_plugin1:block_plugin_id'],
+      'max-age' => 3600,
+    ];
+
+    // Build the variant and ensure that pre_render is set only for the first
+    // block.
+    $build = $display_variant->build();
+    $build = $display_variant->buildRegions($build);
+    $this->assertSame([$display_variant, 'buildBlock'], $build['top']['block1']['#pre_render'][0]);
+    $this->assertTrue(empty($build['top']['block2']));
+    $this->assertSame($expected_cache_block1, $build['top']['block1']['#cache']);
+    $this->assertSame($expected_cache_page, $build['#cache']);
+
+    // Ensure that building the block returns the correct markup.
+    $block1->build()->willReturn([
+      '#markup' => 'block1_build_value',
+    ]);
+    $block1_build = $display_variant->buildBlock($build['top']['block1']);
+    $this->assertSame(['#markup' => 'block1_build_value'], $block1_build['content']);
+  }
+
+  /**
+   * Tests the submitConfigurationForm() method.
+   *
+   * @covers ::submitConfigurationForm
+   *
+   * @dataProvider providerTestSubmitConfigurationForm
+   */
+  public function testSubmitConfigurationForm($values) {
+    $account = $this->prophesize(AccountInterface::class);
+    $context_handler = $this->prophesize(ContextHandlerInterface::class);
+    $uuid_generator = $this->prophesize(UuidInterface::class);
+    $token = $this->prophesize(Token::class);
+
+    $display_variant = new BlockDisplayVariant([], '', [], $context_handler->reveal(), $account->reveal(), $uuid_generator->reveal(), $token->reveal());
+
+    $form = [];
+    $form_state = (new FormState())->setValues($values);
+    $display_variant->submitConfigurationForm($form, $form_state);
+    $this->assertSame($values['label'], $display_variant->label());
+  }
+
+  /**
+   * Provides data for testSubmitConfigurationForm().
+   */
+  public function providerTestSubmitConfigurationForm() {
+    $data = [];
+    $data[] = [
+      [
+        'label' => 'test_label1',
+      ],
+    ];
+    $data[] = [
+      [
+        'label' => 'test_label2',
+        'blocks' => ['foo1' => []],
+      ],
+    ];
+    $data[] = [
+      [
+        'label' => 'test_label3',
+        'blocks' => ['foo1' => [], 'foo2' => []],
+      ],
+    ];
+    return $data;
+  }
+
+}
+
+class TestBlockDisplayVariant extends BlockDisplayVariant {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function build() {
+    // Set default page cache keys that include the display.
+    $build['#cache']['keys'] = [
+      'test_block_display',
+      // The UUID of this display.
+      // @todo should have an API for this?
+      $this->configuration['uuid'],
+    ];
+    $build['#pre_render'][] = [$this, 'buildRegions'];
+    return $build;
+  }
+
+  /**
+   * #pre_render callback for building the regions.
+   */
+  public function buildRegions(array $build) {
+    $cacheability = CacheableMetadata::createFromRenderArray($build)
+      ->addCacheableDependency($this);
+
+    $contexts = $this->getContexts();
+    foreach ($this->getRegionAssignments() as $region => $blocks) {
+      if (!$blocks) {
+        continue;
+      }
+
+      $region_name = Html::getClass("block-region-$region");
+      $build[$region]['#prefix'] = '<div class="' . $region_name . '">';
+      $build[$region]['#suffix'] = '</div>';
+
+      /** @var $blocks \Drupal\Core\Block\BlockPluginInterface[] */
+      $weight = 0;
+      foreach ($blocks as $block_id => $block) {
+        if ($block instanceof ContextAwarePluginInterface) {
+          $this->contextHandler()->applyContextMapping($block, $contexts);
+        }
+        $access = $block->access($this->account, TRUE);
+        $cacheability->addCacheableDependency($access);
+        if (!$access->isAllowed()) {
+          continue;
+        }
+
+        $block_build = [
+          '#theme' => 'block',
+          '#attributes' => [],
+          '#weight' => $weight++,
+          '#configuration' => $block->getConfiguration(),
+          '#plugin_id' => $block->getPluginId(),
+          '#base_plugin_id' => $block->getBaseId(),
+          '#derivative_plugin_id' => $block->getDerivativeId(),
+          '#block_plugin' => $block,
+          '#pre_render' => [[$this, 'buildBlock']],
+          '#cache' => [
+            'keys' => ['page_manager_block_display', $this->configuration['uuid'], 'block', $block_id],
+            // Each block needs cache tags of the page and the block plugin, as
+            // only the page is a config entity that will trigger cache tag
+            // invalidations in case of block configuration changes.
+            'tags' => Cache::mergeTags($this->getCacheTags(), $block->getCacheTags()),
+            'contexts' => $block->getCacheContexts(),
+            'max-age' => $block->getCacheMaxAge(),
+          ],
+        ];
+
+        // Merge the cacheability metadata of blocks into the page. This helps
+        // to avoid cache redirects if the blocks have more cache contexts than
+        // the page, which the page must respect as well.
+        $cacheability->addCacheableDependency($block);
+
+        $build[$region][$block_id] = $block_build;
+      }
+    }
+
+    $build['#title'] = $this->renderPageTitle($this->configuration['page_title']);
+
+    $cacheability->applyTo($build);
+
+    return $build;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
+    $form = parent::buildConfigurationForm($form, $form_state);
+
+    // Allow to configure the page title, even when adding a new display.
+    // Default to the page label in that case.
+    $form['page_title'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Page title'),
+      '#description' => $this->t('Configure the page title that will be used for this display.'),
+      '#default_value' => $this->configuration['page_title'] ?: '',
+    ];
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
+    parent::submitConfigurationForm($form, $form_state);
+
+    if ($form_state->hasValue('page_title')) {
+      $this->configuration['page_title'] = $form_state->getValue('page_title');
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function defaultConfiguration() {
+    return parent::defaultConfiguration() + [
+      'blocks' => [],
+      'selection_conditions' => [],
+      'selection_logic' => 'and',
+      'page_title' => '',
+    ];
+  }
+
+  /**
+   * Renders the page title and replaces tokens.
+   *
+   * @param string $page_title
+   *   The page title that should be rendered.
+   *
+   * @return string
+   *   The page title after replacing any tokens.
+   */
+  protected function renderPageTitle($page_title) {
+    $data = $this->getContextAsTokenData();
+    return $this->token->replace($page_title, $data);
+  }
+
+  /**
+   * Returns available context as token data.
+   *
+   * @return array
+   *   An array with token data values keyed by token type.
+   */
+  protected function getContextAsTokenData() {
+    $data = [];
+    foreach ($this->getContexts() as $context) {
+      // @todo Simplify this when token and typed data types are unified in
+      //   https://drupal.org/node/2163027.
+      if (strpos($context->getContextDefinition()->getDataType(), 'entity:') === 0) {
+        $token_type = substr($context->getContextDefinition()->getDataType(), 7);
+        if ($token_type == 'taxonomy_term') {
+          $token_type = 'term';
+        }
+        $data[$token_type] = $context->getContextValue();
+      }
+    }
+    return $data;
+  }
+}
+
diff --git a/tests/src/Unit/BlockPluginCollectionTest.php b/tests/src/Unit/BlockPluginCollectionTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..0a2818b5dd4752b3b053f1d4a18587a186cfa3b8
--- /dev/null
+++ b/tests/src/Unit/BlockPluginCollectionTest.php
@@ -0,0 +1,86 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Tests\page_manager\Unit\BlockPluginCollectionTest.
+ */
+
+namespace Drupal\Tests\ctools\Unit;
+
+use Drupal\Core\Block\BlockManagerInterface;
+use Drupal\Core\Block\BlockPluginInterface;
+use Drupal\ctools\Plugin\BlockPluginCollection;
+use Drupal\Tests\UnitTestCase;
+use Prophecy\Argument;
+
+/**
+ * Tests the block plugin collection.
+ *
+ * @coversDefaultClass \Drupal\ctools\Plugin\BlockPluginCollection
+ *
+ * @group PageManager
+ */
+class BlockPluginCollectionTest extends UnitTestCase {
+
+  /**
+   * Tests the getAllByRegion() method.
+   *
+   * @covers ::getAllByRegion
+   */
+  public function testGetAllByRegion() {
+    $blocks = [
+      'foo' => [
+        'id' => 'foo',
+        'label' => 'Foo',
+        'plugin' => 'system_powered_by_block',
+        'region' => 'bottom',
+      ],
+      'bar' => [
+        'id' => 'bar',
+        'label' => 'Bar',
+        'plugin' => 'system_powered_by_block',
+        'region' => 'top',
+      ],
+      'bing' => [
+        'id' => 'bing',
+        'label' => 'Bing',
+        'plugin' => 'system_powered_by_block',
+        'region' => 'bottom',
+        'weight' => -10,
+      ],
+      'baz' => [
+        'id' => 'baz',
+        'label' => 'Baz',
+        'plugin' => 'system_powered_by_block',
+        'region' => 'bottom',
+      ],
+    ];
+    $block_manager = $this->prophesize(BlockManagerInterface::class);
+    $plugins = [];
+    foreach ($blocks as $block_id => $block) {
+      $plugin = $this->prophesize(BlockPluginInterface::class);
+      $plugin->label()->willReturn($block['label']);
+      $plugin->getConfiguration()->willReturn($block);
+      $plugins[$block_id] = $plugin->reveal();
+
+      $block_manager->createInstance($block_id, $block)
+        ->willReturn($plugin->reveal())
+        ->shouldBeCalled();
+    }
+
+
+    $block_plugin_collection = new BlockPluginCollection($block_manager->reveal(), $blocks);
+    $expected = [
+      'bottom' => [
+        'bing' => $plugins['bing'],
+        'baz' => $plugins['baz'],
+        'foo' => $plugins['foo'],
+      ],
+      'top' => [
+        'bar' => $plugins['bar'],
+      ],
+    ];
+    $this->assertSame($expected, $block_plugin_collection->getAllByRegion());
+  }
+
+}
diff --git a/tests/src/Unit/BlockVariantTraitTest.php b/tests/src/Unit/BlockVariantTraitTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..e95ef2c3fa060a539734cf9801819aeeec6f6b76
--- /dev/null
+++ b/tests/src/Unit/BlockVariantTraitTest.php
@@ -0,0 +1,144 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Tests\page_manager\Unit\BlockVariantTraitTest.
+ */
+
+namespace Drupal\Tests\page_manager\Unit;
+
+use Drupal\Component\Uuid\UuidInterface;
+use Drupal\ctools\Plugin\BlockPluginCollection;
+use Drupal\ctools\Plugin\BlockVariantTrait;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * Tests the methods of a block-based variant.
+ *
+ * @coversDefaultClass \Drupal\ctools\Plugin\BlockVariantTrait
+ *
+ * @group PageManager
+ */
+class BlockVariantTraitTest extends UnitTestCase {
+
+  /**
+   * Tests the getRegionAssignments() method.
+   *
+   * @covers ::getRegionAssignments
+   *
+   * @dataProvider providerTestGetRegionAssignments
+   */
+  public function testGetRegionAssignments($expected, $blocks = []) {
+    $block_collection = $this->prophesize(BlockPluginCollection::class);
+    $block_collection->getAllByRegion()
+      ->willReturn($blocks)
+      ->shouldBeCalled();
+
+    $display_variant = new TestBlockVariantTrait();
+    $display_variant->setBlockPluginCollection($block_collection->reveal());
+
+    $this->assertSame($expected, $display_variant->getRegionAssignments());
+  }
+
+  public function providerTestGetRegionAssignments() {
+    return [
+      [
+        [
+          'top' => [],
+          'bottom' => [],
+        ],
+      ],
+      [
+        [
+          'top' => ['foo'],
+          'bottom' => [],
+        ],
+        [
+          'top' => ['foo'],
+        ],
+      ],
+      [
+        [
+          'top' => [],
+          'bottom' => [],
+        ],
+        [
+          'invalid' => ['foo'],
+        ],
+      ],
+      [
+        [
+          'top' => [],
+          'bottom' => ['foo'],
+        ],
+        [
+          'bottom' => ['foo'],
+          'invalid' => ['bar'],
+        ],
+      ],
+    ];
+  }
+
+}
+
+class TestBlockVariantTrait {
+  use BlockVariantTrait;
+
+  /**
+   * @var array
+   */
+  protected $blockConfig = [];
+
+  /**
+   * @var \Drupal\Component\Uuid\UuidInterface
+   */
+  protected $uuidGenerator;
+
+  /**
+   * @param BlockPluginCollection $block_plugin_collection
+   *
+   * @return $this
+   */
+  public function setBlockPluginCollection(BlockPluginCollection $block_plugin_collection) {
+    $this->blockPluginCollection = $block_plugin_collection;
+    return $this;
+  }
+
+  /**
+   * @param \Drupal\Component\Uuid\UuidInterface $uuid_generator
+   *
+   * @return $this
+   */
+  public function setUuidGenerator(UuidInterface $uuid_generator) {
+    $this->uuidGenerator = $uuid_generator;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function uuidGenerator() {
+    return $this->uuidGenerator;
+  }
+
+  /**
+   * Sets the block configuration.
+   *
+   * @param array $config
+   *   The block configuration.
+   *
+   * @return $this
+   */
+  public function setBlockConfig(array $config) {
+    $this->blockConfig = $config;
+    return $this;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getBlockConfig() {
+    return $this->blockConfig;
+  }
+
+}
