diff --git a/core/core.libraries.yml b/core/core.libraries.yml
index 60bd7ed63c..cad302129a 100644
--- a/core/core.libraries.yml
+++ b/core/core.libraries.yml
@@ -196,6 +196,11 @@ drupal.dropbutton:
     - core/drupalSettings
     - core/jquery.once
 
+drupal.element.closest:
+  version: VERSION
+  js:
+    misc/polyfills/element.closest.js: { weight: -20 }
+
 drupal.entity-form:
   version: VERSION
   js:
@@ -232,6 +237,11 @@ drupal.message:
     - core/drupal
     - core/drupal.announce
 
+drupal.nodelist.prototype.foreach:
+  version: VERSION
+  js:
+    misc/polyfills/nodelist.prototype.foreach.js: { weight: -20 }
+
 drupal.object.assign:
   version: VERSION
   js:
diff --git a/core/misc/polyfills/element.closest.es6.js b/core/misc/polyfills/element.closest.es6.js
new file mode 100644
index 0000000000..5229d188ae
--- /dev/null
+++ b/core/misc/polyfills/element.closest.es6.js
@@ -0,0 +1,29 @@
+/**
+ * @file
+ * Provides a polyfill for Element.closest().
+ *
+ * This is needed for Internet Explorer 11 and Opera Mini.
+ *
+ * This has been copied from MDN Web Docs code samples. Code samples in the MDN
+ * Web Docs are licensed under CC0.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill
+ * @see https://developer.mozilla.org/en-US/docs/MDN/About#Code_samples_and_snippets
+ */
+if (!Element.prototype.matches) {
+  Element.prototype.matches =
+    Element.prototype.msMatchesSelector ||
+    Element.prototype.webkitMatchesSelector;
+}
+
+if (!Element.prototype.closest) {
+  Element.prototype.closest = s => {
+    let el = this;
+
+    do {
+      if (Element.prototype.matches.call(el, s)) return el;
+      el = el.parentElement || el.parentNode;
+    } while (el !== null && el.nodeType === 1);
+    return null;
+  };
+}
diff --git a/core/misc/polyfills/element.closest.js b/core/misc/polyfills/element.closest.js
new file mode 100644
index 0000000000..97eae32760
--- /dev/null
+++ b/core/misc/polyfills/element.closest.js
@@ -0,0 +1,25 @@
+/**
+* DO NOT EDIT THIS FILE.
+* See the following change record for more information,
+* https://www.drupal.org/node/2815083
+* @preserve
+**/
+
+var _this = this;
+
+if (!Element.prototype.matches) {
+  Element.prototype.matches = Element.prototype.msMatchesSelector || Element.prototype.webkitMatchesSelector;
+}
+
+if (!Element.prototype.closest) {
+  Element.prototype.closest = function (s) {
+    var el = _this;
+
+    do {
+      if (Element.prototype.matches.call(el, s)) return el;
+      el = el.parentElement || el.parentNode;
+    } while (el !== null && el.nodeType === 1);
+
+    return null;
+  };
+}
\ No newline at end of file
diff --git a/core/misc/polyfills/nodelist.prototype.foreach.es6.js b/core/misc/polyfills/nodelist.prototype.foreach.es6.js
new file mode 100644
index 0000000000..5e4f1d19a8
--- /dev/null
+++ b/core/misc/polyfills/nodelist.prototype.foreach.es6.js
@@ -0,0 +1,15 @@
+/**
+ * @file
+ * Provides a polyfill for Element.closest().
+ *
+ * This is needed for Internet Explorer 11 and Opera Mini.
+ *
+ * This has been copied from MDN Web Docs code samples. Code samples in the MDN
+ * Web Docs are licensed under CC0.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/API/NodeList/forEach#Polyfill
+ * @see https://developer.mozilla.org/en-US/docs/MDN/About#Code_samples_and_snippets
+ */
+if (window.NodeList && !NodeList.prototype.forEach) {
+  NodeList.prototype.forEach = Array.prototype.forEach;
+}
diff --git a/core/misc/polyfills/nodelist.prototype.foreach.js b/core/misc/polyfills/nodelist.prototype.foreach.js
new file mode 100644
index 0000000000..a0ecf7bbcd
--- /dev/null
+++ b/core/misc/polyfills/nodelist.prototype.foreach.js
@@ -0,0 +1,10 @@
+/**
+* DO NOT EDIT THIS FILE.
+* See the following change record for more information,
+* https://www.drupal.org/node/2815083
+* @preserve
+**/
+
+if (window.NodeList && !NodeList.prototype.forEach) {
+  NodeList.prototype.forEach = Array.prototype.forEach;
+}
\ No newline at end of file
diff --git a/core/tests/Drupal/FunctionalJavascriptTests/Theme/ClaroViewsBulkOperationsTest.php b/core/tests/Drupal/FunctionalJavascriptTests/Theme/ClaroViewsBulkOperationsTest.php
new file mode 100644
index 0000000000..bca5d973ab
--- /dev/null
+++ b/core/tests/Drupal/FunctionalJavascriptTests/Theme/ClaroViewsBulkOperationsTest.php
@@ -0,0 +1,102 @@
+<?php
+
+namespace Drupal\FunctionalJavascriptTests\Theme;
+
+use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
+use Drupal\Tests\node\Traits\ContentTypeCreationTrait;
+use Drupal\Tests\node\Traits\NodeCreationTrait;
+
+/**
+ * Tests Claro's Views Bulk Operations form.
+ *
+ * @group claro
+ */
+class ClaroViewsBulkOperationsTest extends WebDriverTestBase {
+  use ContentTypeCreationTrait;
+  use NodeCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['node', 'views'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'claro';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    // Create a Content type and two test nodes.
+    $this->createContentType(['type' => 'page']);
+    $this->createNode(['title' => 'Page One']);
+    $this->createNode(['title' => 'Page Two']);
+
+    // Create a user privileged enough to use exposed filters and view content.
+    $user = $this->drupalCreateUser([
+      'administer site configuration',
+      'access content',
+      'access content overview',
+      'edit any page content',
+    ]);
+    $this->drupalLogin($user);
+  }
+
+  /**
+   * Tests the dynamic Bulk Operations form.
+   */
+  public function testBulkOperationsUi() {
+    $this->drupalGet('admin/content');
+
+    $page = $this->getSession()->getPage();
+    $assert_session = $this->assertSession();
+
+    $no_items_selected = 'No items selected';
+    $one_item_selected = '1 item selected';
+    $two_items_selected = '2 items selected';
+    $vbo_available_message = 'Bulk actions are now available';
+    $this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$no_items_selected\")"));
+    $select_all = $page->find('css', '.select-all > input');
+
+    $page->checkField('node_bulk_form[0]');
+    $this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$one_item_selected\")"));
+
+    // When the bulk operations controls are first activated, this should be
+    // relayed to screen readers.
+    $this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$vbo_available_message\")"));
+    $this->assertFalse($select_all->isChecked());
+
+    $page->checkField('node_bulk_form[1]');
+    $this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$two_items_selected\")"));
+    $this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$two_items_selected\")"));
+    $assert_session->pageTextNotContains($vbo_available_message);
+    $this->assertTrue($select_all->isChecked());
+
+    $page->uncheckField('node_bulk_form[0]');
+    $this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$one_item_selected\")"));
+    $this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$one_item_selected\")"));
+    $assert_session->pageTextNotContains($vbo_available_message);
+    $this->assertFalse($select_all->isChecked());
+
+    $page->uncheckField('node_bulk_form[1]');
+    $this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$no_items_selected\")"));
+    $this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$no_items_selected\")"));
+    $assert_session->pageTextNotContains($vbo_available_message);
+    $this->assertFalse($select_all->isChecked());
+
+    $select_all->check();
+    $this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$two_items_selected\")"));
+    $this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$vbo_available_message\")"));
+    $this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$two_items_selected\")"));
+
+    $select_all->uncheck();
+    $this->assertNotNull($assert_session->waitForElementVisible('css', ".js-views-bulk-actions-status:contains(\"$no_items_selected\")"));
+    $this->assertNotNull($assert_session->waitForElement('css', "#drupal-live-announce:contains(\"$no_items_selected\")"));
+    $assert_session->pageTextNotContains($vbo_available_message);
+  }
+
+}
diff --git a/core/themes/claro/claro.info.yml b/core/themes/claro/claro.info.yml
index 44049c582a..ad74d5caea 100644
--- a/core/themes/claro/claro.info.yml
+++ b/core/themes/claro/claro.info.yml
@@ -105,6 +105,8 @@ libraries-extend:
     - claro/vertical-tabs
   core/jquery.ui:
     - claro/claro.jquery.ui
+  core/drupal.tableselect:
+    - claro/tableselect
   file/drupal.file:
     - claro/file
   filter/drupal.filter.admin:
diff --git a/core/themes/claro/claro.libraries.yml b/core/themes/claro/claro.libraries.yml
index 02ed758bc1..9369758866 100644
--- a/core/themes/claro/claro.libraries.yml
+++ b/core/themes/claro/claro.libraries.yml
@@ -278,6 +278,20 @@ filter:
     component:
       css/theme/filter.theme.css: {}
 
+tableselect:
+  version: VERSION
+  js:
+    js/tableselect.js: {}
+  dependencies:
+    - core/jquery
+    - core/drupal
+    - core/drupal.announce
+    - core/drupal.debounce
+    - core/drupal.nodelist.prototype.foreach
+    - core/drupal.element.closest
+    # Supplies the ':tabbable' pseudo selector.
+    - core/jquery.ui
+
 classy.book-navigation:
   version: VERSION
   css:
diff --git a/core/themes/claro/claro.theme b/core/themes/claro/claro.theme
index 5d735acb6b..d038f826b1 100644
--- a/core/themes/claro/claro.theme
+++ b/core/themes/claro/claro.theme
@@ -376,6 +376,87 @@ function claro_form_alter(array &$form, FormStateInterface $form_state, $form_id
     $form['actions']['delete'] = _claro_convert_link_to_action_link($form['actions']['delete'], 'trash', 'default', 'danger');
   }
 
+  if ($form_object instanceof ViewsForm && !empty($form['header'])) {
+    $view = $form_state->getBuildInfo()['args'][0];
+    $view_title = $view->getTitle();
+
+    // Determine if the Views form includes a bulk operations form. If it does,
+    // move it to the bottom and remove the second bulk operations submit.
+    foreach (Element::children($form['header']) as $key) {
+      if (strpos($key, '_bulk_form') !== FALSE) {
+        // Move the bulk actions form from the header to its own container.
+        $form['bulk_actions_container'] = $form['header'][$key];
+        unset($form['header'][$key]);
+
+        // Remove the supplementary bulk operations submit button as it appears
+        // in the same location the form was moved to.
+        unset($form['actions']);
+
+        $form['bulk_actions_container']['#attributes']['data-drupal-views-bulk-actions'] = '';
+        $form['bulk_actions_container']['#attributes']['class'][] = 'views-bulk-actions';
+        $form['bulk_actions_container']['actions']['submit']['#button_type'] = 'primary';
+        $form['bulk_actions_container']['actions']['submit']['#attributes']['class'][] = 'button--small';
+        $label = t('Perform actions on the selected items in the %view_title view', ['%view_title' => $view_title]);
+        $label_id = $key . '_group_label';
+
+        // Group the bulk actions select and submit elements, and add a label
+        // that makes the purpose of these elements more clear to
+        // screenreaders.
+        $form['bulk_actions_container']['#attributes']['role'] = 'group';
+        $form['bulk_actions_container']['#attributes']['aria-labelledby'] = $label_id;
+        $form['bulk_actions_container']['group_label'] = [
+          '#type' => 'container',
+          '#markup' => $label,
+          '#attributes' => [
+            'id' => $label_id,
+            'class' => ['visually-hidden'],
+          ],
+          '#weight' => -1,
+        ];
+
+        // Add a status label for counting the number of items selected.
+        $form['bulk_actions_container']['status'] = [
+          '#type' => 'container',
+          '#markup' => t('No items selected'),
+          '#weight' => -1,
+          '#attributes' => [
+            'class' => [
+              'js-views-bulk-actions-status',
+              'views-bulk-actions__item',
+              'views-bulk-actions__item--status',
+              'js-show',
+            ],
+            'data-drupal-views-bulk-actions-status' => '',
+          ],
+        ];
+
+        // Loop through bulk actions items and add the needed CSS classes.
+        $bulk_action_item_keys = Element::children($form['bulk_actions_container'], TRUE);
+        $bulk_last_key = NULL;
+        $bulk_child_before_actions_key = NULL;
+        foreach ($bulk_action_item_keys as $bulk_action_item_key) {
+          if (!empty($form['bulk_actions_container'][$bulk_action_item_key]['#type'])) {
+            if ($form['bulk_actions_container'][$bulk_action_item_key]['#type'] === 'actions') {
+              // We need the key of the element that precedes the actions
+              // element.
+              $bulk_child_before_actions_key = $bulk_last_key;
+              $form['bulk_actions_container'][$bulk_action_item_key]['#attributes']['class'][] = 'views-bulk-actions__item';
+            }
+
+            if (!in_array($form['bulk_actions_container'][$bulk_action_item_key]['#type'], ['hidden', 'actions'])) {
+              $form['bulk_actions_container'][$bulk_action_item_key]['#wrapper_attributes']['class'][] = 'views-bulk-actions__item';
+              $bulk_last_key = $bulk_action_item_key;
+            }
+          }
+        }
+
+        if ($bulk_child_before_actions_key) {
+          $form['bulk_actions_container'][$bulk_child_before_actions_key]['#wrapper_attributes']['class'][] = 'views-bulk-actions__item--preceding-actions';
+        }
+      }
+    }
+  }
+
   if ($form_object instanceof ViewsForm && strpos($form_object->getBaseFormId(), 'views_form_media_library') === 0) {
     if (isset($form['header'])) {
       $form['header']['#attributes']['class'][] = 'media-library-views-form__header';
@@ -969,7 +1050,7 @@ function claro_preprocess_field_ui_table(&$variables) {
  */
 function claro_preprocess_views_view_table(&$variables) {
   if (!empty($variables['header'])) {
-    foreach ($variables['header'] as &$header_cell) {
+    foreach ($variables['header'] as $key => &$header_cell) {
       if (!empty($header_cell['url'])) {
         $parsed_url = UrlHelper::parse($header_cell['url']);
         $query = !empty($parsed_url['query']) ? $parsed_url['query'] : [];
diff --git a/core/themes/claro/css/base/variables.pcss.css b/core/themes/claro/css/base/variables.pcss.css
index c0987bb801..cafa53e3ea 100644
--- a/core/themes/claro/css/base/variables.pcss.css
+++ b/core/themes/claro/css/base/variables.pcss.css
@@ -146,6 +146,7 @@
   --button-bg-color--danger: var(--color-maximumred);
   --button--hover-bg-color--danger: var(--color-maximumred-hover);
   --button--active-bg-color--danger: var(--color-maximumred-active);
+  --dropbutton-widget-z-index: 100;
   /**
    * jQuery.UI dropdown.
    */
diff --git a/core/themes/claro/css/components/dropbutton.pcss.css b/core/themes/claro/css/components/dropbutton.pcss.css
index 2da904bd59..91e30521e7 100644
--- a/core/themes/claro/css/components/dropbutton.pcss.css
+++ b/core/themes/claro/css/components/dropbutton.pcss.css
@@ -52,7 +52,7 @@
 }
 
 .js .dropbutton-wrapper.open .dropbutton-widget {
-  z-index: 100;
+  z-index: var(--dropbutton-widget-z-index);
 }
 
 /**
diff --git a/core/themes/claro/css/components/tableselect.css b/core/themes/claro/css/components/tableselect.css
index 0f5382b68d..d61129d3c2 100644
--- a/core/themes/claro/css/components/tableselect.css
+++ b/core/themes/claro/css/components/tableselect.css
@@ -7,7 +7,7 @@
 
 /**
  * @file
- * Table select — replaces implementation of Classy theme.
+ * Table select styles for Claro.
  *
  * @see tableselect.js
  */
@@ -26,3 +26,117 @@ th.checkbox {
 tr.selected td {
   background-color: #e6ecf8;
 }
+
+.views-bulk-actions {
+  position: relative;
+  display: flex;
+  flex: 1;
+  flex-wrap: wrap;
+  padding: 1rem 2rem;
+  color: #fff;
+  border: 1px solid rgba(216, 217, 224, 0.8);
+  border-radius: 4px;
+  background-color: #222330;
+}
+
+.views-bulk-actions[data-drupal-sticky-vbo="true"] {
+  position: fixed;
+  z-index: 101;
+  bottom: 0;
+  left: 0;
+  animation: fadeInBottom 320ms 1 forwards;
+  border-bottom-right-radius: 0;
+  border-bottom-left-radius: 0;
+}
+
+@supports ((position: -webkit-sticky) or (position: sticky)) {
+  .views-bulk-actions[data-drupal-sticky-vbo="true"] {
+    position: -webkit-sticky;
+    position: sticky;
+  }
+}
+
+@keyframes fadeInBottom {
+  0% {
+    transform: translateY(100%);
+  }
+  100% {
+    transform: translateY(0);
+  }
+}
+
+.views-bulk-actions.views-form__header--bypass-animation {
+  animation: none;
+}
+
+.views-bulk-actions__item {
+  -ms-grid-row-align: center;
+  align-self: center;
+  margin: 0 1.5rem 0.75rem 0; /* LTR */
+}
+
+.views-bulk-actions__item:last-of-type {
+  margin-right: 0; /* LTR */
+}
+
+[dir="rtl"] .views-bulk-actions__item {
+  margin-right: 0;
+  margin-left: 1.5rem;
+}
+
+[dir="rtl"] .views-bulk-actions__item:last-of-type {
+  margin-left: 0;
+}
+
+.views-bulk-actions__item--status {
+  width: 100%;
+  white-space: nowrap;
+  font-size: 0.79rem;
+  font-weight: bold;
+}
+
+/**
+ * Labels within a bulk operations form are styled the same as .visually-hidden.
+ * This is done instead of giving them the visually-hidden class as this will
+ * work at any nesting level.
+ */
+
+.views-bulk-actions__item .form-item__label {
+  position: absolute !important;
+  overflow: hidden;
+  clip: rect(1px, 1px, 1px, 1px);
+  width: 1px;
+  height: 1px;
+  word-wrap: normal;
+}
+
+.views-bulk-actions__item input,
+.views-bulk-actions__item .button {
+  margin-top: 0;
+  margin-bottom: 0;
+}
+
+.views-bulk-actions__item .form-element--type-select {
+  min-height: 2rem;
+  padding: calc(0.5rem - 1px) calc(2.25rem - 1px) calc(0.5rem - 1px) calc(1rem - 1px);
+  font-size: 0.79rem;
+  line-height: 1rem;
+}
+
+.views-field__skip-to-bulk-actions {
+  display: block;
+  white-space: nowrap;
+  font-size: 0.79rem;
+}
+
+@media screen and (min-width: 48em) {
+  .views-bulk-actions {
+    flex-wrap: nowrap;
+  }
+  .views-bulk-actions__item {
+    margin-bottom: 0;
+  }
+  .views-bulk-actions__item--status {
+    width: auto;
+  }
+}
diff --git a/core/themes/claro/css/components/tableselect.pcss.css b/core/themes/claro/css/components/tableselect.pcss.css
index efb4ea1392..64648d7cd2 100644
--- a/core/themes/claro/css/components/tableselect.pcss.css
+++ b/core/themes/claro/css/components/tableselect.pcss.css
@@ -1,6 +1,6 @@
 /**
  * @file
- * Table select — replaces implementation of Classy theme.
+ * Table select styles for Claro.
  *
  * @see tableselect.js
  */
@@ -20,3 +20,110 @@ th.checkbox {
 tr.selected td {
   background-color: var(--color-bgblue-active);
 }
+
+.views-bulk-actions {
+  position: relative;
+  display: flex;
+  flex: 1;
+  flex-wrap: wrap;
+  padding: var(--space-m) 2rem;
+  color: var(--color-white);
+  border: var(--details-border-size) solid var(--details-border-color);
+  border-radius: 4px;
+  background-color: var(--color-text);
+}
+
+.views-bulk-actions[data-drupal-sticky-vbo="true"] {
+  position: fixed;
+  z-index: calc(var(--dropbutton-widget-z-index) + 1);
+  bottom: 0;
+  left: 0;
+  animation: fadeInBottom 320ms 1 forwards;
+  border-bottom-right-radius: 0;
+  border-bottom-left-radius: 0;
+}
+
+@supports (position: sticky) {
+  .views-bulk-actions[data-drupal-sticky-vbo="true"] {
+    position: sticky;
+  }
+}
+
+@keyframes fadeInBottom {
+  0% {
+    transform: translateY(100%);
+  }
+  100% {
+    transform: translateY(0);
+  }
+}
+.views-bulk-actions.views-form__header--bypass-animation {
+  animation: none;
+}
+
+.views-bulk-actions__item {
+  align-self: center;
+  margin: 0 var(--space-l) var(--space-s) 0; /* LTR */
+}
+.views-bulk-actions__item:last-of-type {
+  margin-right: 0; /* LTR */
+}
+[dir="rtl"] .views-bulk-actions__item {
+  margin-right: 0;
+  margin-left: var(--space-l);
+}
+[dir="rtl"] .views-bulk-actions__item:last-of-type {
+  margin-left: 0;
+}
+
+.views-bulk-actions__item--status {
+  width: 100%;
+  white-space: nowrap;
+  font-size: var(--font-size-xs);
+  font-weight: bold;
+}
+
+/**
+ * Labels within a bulk operations form are styled the same as .visually-hidden.
+ * This is done instead of giving them the visually-hidden class as this will
+ * work at any nesting level.
+ */
+.views-bulk-actions__item .form-item__label {
+  position: absolute !important;
+  overflow: hidden;
+  clip: rect(1px, 1px, 1px, 1px);
+  width: 1px;
+  height: 1px;
+  word-wrap: normal;
+}
+
+.views-bulk-actions__item input,
+.views-bulk-actions__item .button {
+  margin-top: 0;
+  margin-bottom: 0;
+}
+
+.views-bulk-actions__item .form-element--type-select {
+  min-height: 2rem;
+  padding: calc(0.5rem - 1px) calc(2.25rem - 1px) calc(0.5rem - 1px) calc(1rem - 1px);
+  font-size: var(--font-size-xs);
+  line-height: 1rem;
+}
+
+.views-field__skip-to-bulk-actions {
+  display: block;
+  white-space: nowrap;
+  font-size: var(--font-size-xs);
+}
+
+@media screen and (min-width: 48em) {
+  .views-bulk-actions {
+    flex-wrap: nowrap;
+  }
+  .views-bulk-actions__item {
+    margin-bottom: 0;
+  }
+  .views-bulk-actions__item--status {
+    width: auto;
+  }
+}
diff --git a/core/themes/claro/js/tableselect.es6.js b/core/themes/claro/js/tableselect.es6.js
new file mode 100644
index 0000000000..962d2aca1e
--- /dev/null
+++ b/core/themes/claro/js/tableselect.es6.js
@@ -0,0 +1,370 @@
+/**
+ * @file
+ * Extends table select functionality for Claro.
+ */
+
+(($, Drupal) => {
+  Drupal.ClaroBulkActions = class {
+    constructor(bulkActions) {
+      this.bulkActions = bulkActions;
+      this.form = this.bulkActions.closest('form');
+      this.form.querySelectorAll('tr').forEach(element => {
+        element.classList.add('views-form__bulk-operations-row');
+      });
+      this.checkboxes = this.form.querySelectorAll(
+        '[class$="bulk-form"]:not(.select-all) input[type="checkbox"]',
+      );
+      this.selectAll = this.form.querySelectorAll(
+        '.select-all > [type="checkbox"]',
+      );
+      this.$tabbable = $(this.form).find(':tabbable');
+      this.bulkActionsSticky = false;
+      this.scrollingTimeout = '';
+      this.ignoreScrollEvent = false;
+
+      $(this.checkboxes).on('change', event => this.rowCheckboxHandler(event));
+      $(this.selectAll).on('change', event => this.selectAllHandler(event));
+      this.$tabbable.on('focus', event => this.focusHandler(event));
+      this.$tabbable.on('blur', event => this.blurHandler(event));
+
+      // The will contain the CSS that hides the spacer during scroll
+      // and resize.
+      this.spacerCss = document.createElement('style');
+      document.body.appendChild(this.spacerCss);
+
+      const scrollResizeHandler = Drupal.debounce(() => {
+        this.scrollResizeHandler();
+      }, 10);
+      $(window).on('scroll', () => scrollResizeHandler());
+      $(window).on('resize', () => scrollResizeHandler());
+
+      // Execute checkbox handler after the load event. This ensures that the
+      // actions form is sticky if any checkboxes are already checked on page
+      // load. One of the situations where it is possible to have pre-checked
+      // checkboxes on load is when the page is requested via the back button.
+      // window.addEventListener('load', () => this.rowCheckboxHandler({}));
+      $(window).on('load', () => this.rowCheckboxHandler({}));
+    }
+
+    /**
+     * Ensures that focusable elements hidden under a sticky remain focusable.
+     *
+     * @param {Object} event
+     *   A jQuery Event object.
+     */
+    /* eslint-disable-next-line class-methods-use-this */
+    blurHandler(event) {
+      // This event handler should only proceed if the event came from direct
+      // interaction with the form element. If this fires on events triggered
+      // via JavaScript there may be undesirable side effects.
+      if (!event.hasOwnProperty('isTrigger')) {
+        const row = event.target.closest('tr');
+        const nextSibling = row ? row.nextElementSibling : null;
+
+        // Any row in this table potentially has a spacer div preceding it. The
+        // spacer is added to prevent focusable elements from appearing
+        // underneath the sticky Views Bulk Actions form. Any element underneath
+        // this spacer is beneath the viewport. If an element beneath
+        // the viewport receives focus and the previously focused element was
+        // above the spacer, some browsers have difficulty determining how much
+        // scrolling is neccessary to bring the newly focused element into view.
+        // To prevent this potential miscalculation, the spacer is momentarily
+        // removed when blur occurs on rows preceding it. The spacer is
+        // reintroduced immediately after the next item receives focus.
+        if (
+          nextSibling &&
+          nextSibling.getAttribute('data-drupal-table-row-spacer')
+        ) {
+          nextSibling.parentNode.removeChild(nextSibling);
+        }
+      }
+    }
+
+    /**
+     * If a partially covered element receives focus, scroll it into full view.
+     *
+     * @param {Object} event
+     *   A jQuery Event object.
+     */
+    focusHandler(event) {
+      const stickyRect = this.bulkActions.getBoundingClientRect();
+      const stickyStart = stickyRect.y;
+      const elementRect = event.target.getBoundingClientRect();
+      const elementStart = elementRect.y;
+      const elementEnd = elementStart + elementRect.height;
+      if (elementEnd > stickyStart) {
+        window.scrollBy(0, elementEnd - stickyStart);
+      }
+      this.underStickyHandler();
+    }
+
+    /**
+     * Temporarily hides the spacer before calling underStickyHandler().
+     *
+     * The spacer is added to prevent the "show numbers" functionality of speech
+     * navigation from labeling inputs under the stickied bulk actions form. It
+     * does this by pushing these elements further down the page so they are out
+     * of the viewport entirely. The presence of this spacer should be invisible
+     * to users. Because this invisibility is partially achieved via
+     * calculations based on scroll position and viewport size, the spacer is
+     * hidden during these events, and reintroduced 500 milliseconds after all
+     * scroll and resize events have completed.
+     */
+    scrollResizeHandler() {
+      // Add CSS rule that hides the spacer. CSS is used instead of removing
+      // the spacer from the DOM as the change occurs faster.
+      this.spacerCss.innerHTML =
+        '[data-drupal-table-row-spacer] { display: none; }';
+
+      if (!this.ignoreScrollEvent) {
+        // Remove the timeout that unhides the spacer. If this function is called,
+        // then scrolling is still happening and spacers should stay hidden.
+        clearTimeout(this.scrollingTimeout);
+
+        // Shortly after scrolling tops, the spacer is re-added.
+        this.scrollingTimeout = setTimeout(() => {
+          this.spacerCss.innerHTML = '';
+          this.underStickyHandler();
+        }, 500);
+      }
+    }
+
+    /**
+     * Moves tabbable elements that are underneath the bulk actions form.
+     *
+     * Focusable elements inside a table row should not be positioned underneath
+     * a sticky Views Bulk Action form. If this isn't prevented, it can be
+     * confusing for speech navigation users when the "show numbers" feature
+     * is enabled. Numbers will be provided for the elements within the Bulk
+     * Actions form and the table row elements directly underneath, and it can
+     * be difficult to discern which number corresponds to which element. To
+     * prevent this confusion, a spacer div is added before the table row, and
+     * this spacer pushes the row further down so the focusable elements are out
+     * of viewport.
+     */
+    underStickyHandler() {
+      document
+        .querySelectorAll('[data-drupal-table-row-spacer]')
+        .forEach(element => {
+          element.parentNode.removeChild(element);
+        });
+
+      if (this.bulkActionsSticky) {
+        // Will be set to true as soon as the forEach() hits a row that is
+        // completely under the sticky header, indicating that no further
+        // processing is needed. Using a For...Of loop to accomplish this
+        // is preferable, but not supported by IE11.
+        let pastStickyHeader = false;
+        const stickyRect = this.bulkActions.getBoundingClientRect();
+        const stickyStart = stickyRect.y;
+        const stickyEnd = stickyStart + stickyRect.height;
+
+        // Loop through each table row. If a row has focusable elements under
+        // the sticky Views Bulk Actions form, add a spacer that pushes the row
+        // down the page and outside of the viewport.
+        this.form.querySelectorAll('tbody tr').forEach(row => {
+          if (!pastStickyHeader) {
+            const rowRect = row.getBoundingClientRect();
+            const rowStart = rowRect.y;
+            const rowEnd = rowStart + rowRect.height;
+            if (rowStart > stickyEnd) {
+              pastStickyHeader = true;
+            } else if (rowEnd > stickyStart) {
+              // Get padding amount for the row's cells, which are used to
+              // determine where a row can be pushed out of the viewport
+              // without any visible difference.
+              const cellTopPadding = Array.from(
+                row.querySelectorAll('td.views-field'),
+              ).map(element =>
+                document.defaultView
+                  .getComputedStyle(element, '')
+                  .getPropertyValue('padding-top')
+                  .replace('px', ''),
+              );
+              const minimumTopPadding = Math.min.apply(null, cellTopPadding);
+
+              // If all parts of the table row that could be displaying content
+              // are under the sticky.
+              if (rowStart + minimumTopPadding >= stickyStart) {
+                // If the row scrolled underneath the sticky has the element
+                // with focus, the addition of a spacer can potentially create
+                // an additional scroll event that can lead to unwanted results.
+                // The variables below are used to identify this so a flag can
+                // be set to bypass scroll handler actions in just those
+                // instances.
+                const oldScrollTop =
+                  window.pageYOffset || document.documentElement.scrollTop;
+                const scrollLeft =
+                  window.pageXOffset || document.documentElement.scrollLeft;
+                const rowContainsActiveElement = row.contains(
+                  document.activeElement,
+                );
+
+                // If the row contains the active element, set the flag that
+                // bypasses the actions of scrollResizeHandler() as a call to
+                // window.scrollTo() may be needed.
+                if (rowContainsActiveElement) {
+                  this.ignoreScrollEvent = true;
+                }
+
+                // a spacer to push it out of the viewport. Because the elements
+                // are fully underneath the sticky, the added spacer should not
+                // result in any visible difference.
+                const spacer = document.createElement('div');
+                spacer.style.height = `${stickyRect.height}px`;
+                spacer.setAttribute('data-drupal-table-row-spacer', true);
+                row.parentNode.insertBefore(spacer, row);
+
+                // Will be used to determine if a scroll position change
+                // occured due to adding the spacer.
+                const newScrollTop =
+                  window.pageYOffset || document.documentElement.scrollTop;
+
+                // If the browser pushed the row back into the viewport after
+                // the spacer was added, return the scroll position to the
+                // intended location.
+                const windowBottom =
+                  window.innerHeight || document.documentElement.clientHeight;
+                if (
+                  rowContainsActiveElement &&
+                  oldScrollTop !== newScrollTop &&
+                  rowStart < windowBottom
+                ) {
+                  window.scrollTo(scrollLeft, oldScrollTop);
+                }
+
+                // Set this flag back to its default value of false.
+                this.ignoreScrollEvent = false;
+              }
+            }
+          }
+        });
+      }
+    }
+
+    /**
+     * Triggered when the `select all` button is clicked.
+     *
+     * @param {Object} event
+     *   A jQuery Event object.
+     */
+    selectAllHandler(event) {
+      // This event handler should only proceed if the event came from direct
+      // interaction with the form element. If this fires on events triggered
+      // via JavaScript there may be undesirable side effects.
+      if (!event.hasOwnProperty('isTrigger')) {
+        const itemsCheckedCount = event.target.checked
+          ? this.checkboxes.length
+          : 0;
+        this.updateStatus(itemsCheckedCount);
+        this.underStickyHandler();
+      }
+    }
+
+    /**
+     * Triggered when a row is checked or unchecked.
+     *
+     * @param {Object} event
+     *   A jQuery Event object.
+     */
+    rowCheckboxHandler(event) {
+      // This event handler should only proceed if the event came from direct
+      // interaction with the form element. If this fires on events triggered
+      // via JavaScript there may be undesirable side effects.
+      if (!event.hasOwnProperty('isTrigger')) {
+        this.updateStatus(
+          Array.prototype.slice
+            .call(this.checkboxes)
+            .filter(checkbox => checkbox.checked).length,
+        );
+      }
+    }
+
+    /**
+     * Update the bulk actions label and announcements.
+     *
+     * @param {number} count
+     *   The number of checkboxes checked.
+     */
+    updateStatus(count) {
+      // A status message that will be displayed in the bulk actions form and
+      // announced by the screen reader.
+      let statusMessage = '';
+
+      // This will remain empty unless the actions form is made sticky and
+      // previously was not.
+      let operationsAvailableMessage = '';
+      if (count > 0) {
+        // Check if bulk operations has changed from not-sticky to sticky.
+        if (!this.bulkActionsSticky) {
+          operationsAvailableMessage = Drupal.t(
+            'Bulk actions are now available. These actions will be applied to all selected items. This can be accessed via the "Skip to bulk actions" link that appears after every enabled checkbox. ',
+          );
+          this.bulkActionsSticky = true;
+
+          // Run the underStickyHandler after the CSS animation completes.
+          // Near the end of this there is an additional call to
+          // underStickyHandler without a timeout. This covers users who have
+          // animations disabled, and resets all items to visible if the bulk
+          // actions form is no longer sticky.
+          setTimeout(() => this.underStickyHandler(), 350);
+
+          // When the actions form becomes sticky, it appears via an animation
+          // at the bottom of the viewport. If this form is already above the
+          // viewport, the animation would look odd. In these instances the
+          // animation is bypassed.
+          const stickyRect = this.bulkActions.getBoundingClientRect();
+
+          const bypassAnimation =
+            stickyRect.top + stickyRect.height <
+            window.scrollY + window.innerHeight;
+
+          // Determine add/remove with ternary since IE11 does not support the
+          // second argument for classList.toggle().
+          const classAction = bypassAnimation ? 'add' : 'remove';
+          this.bulkActions.classList[classAction](
+            'views-form__header--bypass-animation',
+          );
+        }
+
+        statusMessage = Drupal.formatPlural(
+          count,
+          '1 item selected',
+          '@count items selected',
+        );
+      } else {
+        this.bulkActionsSticky = false;
+        statusMessage = Drupal.t('No items selected');
+        setTimeout(() => this.underStickyHandler(), 350);
+      }
+
+      // Update the attribute that instructs the bulk actions form to be sticky.
+      this.bulkActions.setAttribute(
+        'data-drupal-sticky-vbo',
+        this.bulkActionsSticky,
+      );
+
+      // Update the bulk actions form label with the number of items checked.
+      this.bulkActions.querySelector(
+        '[data-drupal-views-bulk-actions-status]',
+      ).textContent = statusMessage;
+
+      // Announce these changes to the screen reader.
+      Drupal.announce(operationsAvailableMessage + statusMessage);
+      this.underStickyHandler();
+    }
+  };
+
+  Drupal.behaviors.claroTableSelect = {
+    attach(context) {
+      const $bulkActions = $(context)
+        .find('[data-drupal-views-bulk-actions]')
+        .once('ClaroBulkActions');
+      $bulkActions.map(
+        (index, bulkActionForm) =>
+          /* eslint-disable-next-line no-new */
+          new Drupal.ClaroBulkActions(bulkActionForm),
+      );
+    },
+  };
+})(jQuery, Drupal);
diff --git a/core/themes/claro/js/tableselect.js b/core/themes/claro/js/tableselect.js
new file mode 100644
index 0000000000..e331f4c28b
--- /dev/null
+++ b/core/themes/claro/js/tableselect.js
@@ -0,0 +1,224 @@
+/**
+* DO NOT EDIT THIS FILE.
+* See the following change record for more information,
+* https://www.drupal.org/node/2815083
+* @preserve
+**/
+
+function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } }
+
+function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }
+
+function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }
+
+(function ($, Drupal) {
+  Drupal.ClaroBulkActions = function () {
+    function _class(bulkActions) {
+      var _this = this;
+
+      _classCallCheck(this, _class);
+
+      this.bulkActions = bulkActions;
+      this.form = this.bulkActions.closest('form');
+      this.form.querySelectorAll('tr').forEach(function (element) {
+        element.classList.add('views-form__bulk-operations-row');
+      });
+      this.checkboxes = this.form.querySelectorAll('[class$="bulk-form"]:not(.select-all) input[type="checkbox"]');
+      this.selectAll = this.form.querySelectorAll('.select-all > [type="checkbox"]');
+      this.$tabbable = $(this.form).find(':tabbable');
+      this.bulkActionsSticky = false;
+      this.scrollingTimeout = '';
+      this.ignoreScrollEvent = false;
+      $(this.checkboxes).on('change', function (event) {
+        return _this.rowCheckboxHandler(event);
+      });
+      $(this.selectAll).on('change', function (event) {
+        return _this.selectAllHandler(event);
+      });
+      this.$tabbable.on('focus', function (event) {
+        return _this.focusHandler(event);
+      });
+      this.$tabbable.on('blur', function (event) {
+        return _this.blurHandler(event);
+      });
+      this.spacerCss = document.createElement('style');
+      document.body.appendChild(this.spacerCss);
+      var scrollResizeHandler = Drupal.debounce(function () {
+        _this.scrollResizeHandler();
+      }, 10);
+      $(window).on('scroll', function () {
+        return scrollResizeHandler();
+      });
+      $(window).on('resize', function () {
+        return scrollResizeHandler();
+      });
+      $(window).on('load', function () {
+        return _this.rowCheckboxHandler({});
+      });
+    }
+
+    _createClass(_class, [{
+      key: "blurHandler",
+      value: function blurHandler(event) {
+        if (!event.hasOwnProperty('isTrigger')) {
+          var row = event.target.closest('tr');
+          var nextSibling = row ? row.nextElementSibling : null;
+
+          if (nextSibling && nextSibling.getAttribute('data-drupal-table-row-spacer')) {
+            nextSibling.parentNode.removeChild(nextSibling);
+          }
+        }
+      }
+    }, {
+      key: "focusHandler",
+      value: function focusHandler(event) {
+        var stickyRect = this.bulkActions.getBoundingClientRect();
+        var stickyStart = stickyRect.y;
+        var elementRect = event.target.getBoundingClientRect();
+        var elementStart = elementRect.y;
+        var elementEnd = elementStart + elementRect.height;
+
+        if (elementEnd > stickyStart) {
+          window.scrollBy(0, elementEnd - stickyStart);
+        }
+
+        this.underStickyHandler();
+      }
+    }, {
+      key: "scrollResizeHandler",
+      value: function scrollResizeHandler() {
+        var _this2 = this;
+
+        this.spacerCss.innerHTML = '[data-drupal-table-row-spacer] { display: none; }';
+
+        if (!this.ignoreScrollEvent) {
+          clearTimeout(this.scrollingTimeout);
+          this.scrollingTimeout = setTimeout(function () {
+            _this2.spacerCss.innerHTML = '';
+
+            _this2.underStickyHandler();
+          }, 500);
+        }
+      }
+    }, {
+      key: "underStickyHandler",
+      value: function underStickyHandler() {
+        var _this3 = this;
+
+        document.querySelectorAll('[data-drupal-table-row-spacer]').forEach(function (element) {
+          element.parentNode.removeChild(element);
+        });
+
+        if (this.bulkActionsSticky) {
+          var pastStickyHeader = false;
+          var stickyRect = this.bulkActions.getBoundingClientRect();
+          var stickyStart = stickyRect.y;
+          var stickyEnd = stickyStart + stickyRect.height;
+          this.form.querySelectorAll('tbody tr').forEach(function (row) {
+            if (!pastStickyHeader) {
+              var rowRect = row.getBoundingClientRect();
+              var rowStart = rowRect.y;
+              var rowEnd = rowStart + rowRect.height;
+
+              if (rowStart > stickyEnd) {
+                pastStickyHeader = true;
+              } else if (rowEnd > stickyStart) {
+                var cellTopPadding = Array.from(row.querySelectorAll('td.views-field')).map(function (element) {
+                  return document.defaultView.getComputedStyle(element, '').getPropertyValue('padding-top').replace('px', '');
+                });
+                var minimumTopPadding = Math.min.apply(null, cellTopPadding);
+
+                if (rowStart + minimumTopPadding >= stickyStart) {
+                  var oldScrollTop = window.pageYOffset || document.documentElement.scrollTop;
+                  var scrollLeft = window.pageXOffset || document.documentElement.scrollLeft;
+                  var rowContainsActiveElement = row.contains(document.activeElement);
+
+                  if (rowContainsActiveElement) {
+                    _this3.ignoreScrollEvent = true;
+                  }
+
+                  var spacer = document.createElement('div');
+                  spacer.style.height = "".concat(stickyRect.height, "px");
+                  spacer.setAttribute('data-drupal-table-row-spacer', true);
+                  row.parentNode.insertBefore(spacer, row);
+                  var newScrollTop = window.pageYOffset || document.documentElement.scrollTop;
+                  var windowBottom = window.innerHeight || document.documentElement.clientHeight;
+
+                  if (rowContainsActiveElement && oldScrollTop !== newScrollTop && rowStart < windowBottom) {
+                    window.scrollTo(scrollLeft, oldScrollTop);
+                  }
+
+                  _this3.ignoreScrollEvent = false;
+                }
+              }
+            }
+          });
+        }
+      }
+    }, {
+      key: "selectAllHandler",
+      value: function selectAllHandler(event) {
+        if (!event.hasOwnProperty('isTrigger')) {
+          var itemsCheckedCount = event.target.checked ? this.checkboxes.length : 0;
+          this.updateStatus(itemsCheckedCount);
+          this.underStickyHandler();
+        }
+      }
+    }, {
+      key: "rowCheckboxHandler",
+      value: function rowCheckboxHandler(event) {
+        if (!event.hasOwnProperty('isTrigger')) {
+          this.updateStatus(Array.prototype.slice.call(this.checkboxes).filter(function (checkbox) {
+            return checkbox.checked;
+          }).length);
+        }
+      }
+    }, {
+      key: "updateStatus",
+      value: function updateStatus(count) {
+        var _this4 = this;
+
+        var statusMessage = '';
+        var operationsAvailableMessage = '';
+
+        if (count > 0) {
+          if (!this.bulkActionsSticky) {
+            operationsAvailableMessage = Drupal.t('Bulk actions are now available. These actions will be applied to all selected items. This can be accessed via the "Skip to bulk actions" link that appears after every enabled checkbox. ');
+            this.bulkActionsSticky = true;
+            setTimeout(function () {
+              return _this4.underStickyHandler();
+            }, 350);
+            var stickyRect = this.bulkActions.getBoundingClientRect();
+            var bypassAnimation = stickyRect.top + stickyRect.height < window.scrollY + window.innerHeight;
+            var classAction = bypassAnimation ? 'add' : 'remove';
+            this.bulkActions.classList[classAction]('views-form__header--bypass-animation');
+          }
+
+          statusMessage = Drupal.formatPlural(count, '1 item selected', '@count items selected');
+        } else {
+          this.bulkActionsSticky = false;
+          statusMessage = Drupal.t('No items selected');
+          setTimeout(function () {
+            return _this4.underStickyHandler();
+          }, 350);
+        }
+
+        this.bulkActions.setAttribute('data-drupal-sticky-vbo', this.bulkActionsSticky);
+        this.bulkActions.querySelector('[data-drupal-views-bulk-actions-status]').textContent = statusMessage;
+        Drupal.announce(operationsAvailableMessage + statusMessage);
+        this.underStickyHandler();
+      }
+    }]);
+
+    return _class;
+  }();
+
+  Drupal.behaviors.claroTableSelect = {
+    attach: function attach(context) {
+      var $bulkActions = $(context).find('[data-drupal-views-bulk-actions]').once('ClaroBulkActions');
+      $bulkActions.map(function (index, bulkActionForm) {
+        return new Drupal.ClaroBulkActions(bulkActionForm);
+      });
+    }
+  };
+})(jQuery, Drupal);
\ No newline at end of file
