diff --git a/core/includes/theme.inc b/core/includes/theme.inc
index 5998a235b4..3be2f721a7 100644
--- a/core/includes/theme.inc
+++ b/core/includes/theme.inc
@@ -906,6 +906,26 @@ function template_preprocess_image(&$variables) {
  *       ),
  *     );
  *     @endcode
+ *   - rowgroups: An array of groups of table rows. Every row is an array with
+ *     the following keys:
+ *     - rows: An array of rows; see the 'rows' key above.
+ *     - attributes: An array of HTML attributes, such as "class", to apply to
+ *       the 'tbody' element which will wrap these rows.
+ *     If both 'rows' and 'rowgroups' exist, the rows from 'rows' will be put in
+ *     their own 'tbody' element before the rows in 'rowgroups'.
+ *     Here's an example for $rowgroups:
+ *     @code
+ *     $rowgroups = [
+ *       [
+ *         'rows' => [$row1, $row2],
+ *         'attributes' => [
+ *           'class' => ['tbody-class'],
+ *         ],
+ *       ],
+ *       $rowgroup_2,
+ *       $rowgroup_3,
+ *     ];
+ *     @endcode
  *   - footer: An array of table rows which will be printed within a <tfoot>
  *     tag, in the same format as the rows element (see above).
  *   - attributes: An array of HTML attributes to apply to the table tag.
@@ -1037,74 +1057,109 @@ function template_preprocess_table(&$variables) {
   }
   $variables['header_columns'] = $header_columns;
 
+  // Ensures #rowgroups is set.
+  $variables['rowgroups'] = $variables['rowgroups'] ?? [];
+  // If there are any #rows, add those to the start of #rowgroups.
+  if (!empty($variables['rows'])) {
+    array_unshift($variables['rowgroups'], ['rows' => $variables['rows']]);
+  }
+  // Process each rowgroup to prepare it for the template.
+  // Empty #rows so it is ready to hold a list of all rows.
+  $variables['rows'] = [];
+  foreach ($variables['rowgroups'] as $key => $rowgroup) {
+    // Process attributes for each rowgroup.
+    if (!empty($rowgroup['attribute'])) {
+      $variables['rowgroups'][$key]['attribute'] = new Attribute($rowgroup['attribute']);
+    }
+    // Prepare each row for display.
+    $variables['rowgroups'][$key]['rows'] = _template_preprocess_table_process_rows($rowgroup['rows']);
+    // Make #rows contain pointers to all rows. Modern templates will refer
+    // to #rowgroups; #rows remains for backwards-compatibility.
+    foreach ($rowgroup['rows'] as &$row) {
+      $variables['rows'][] = $row;
+    }
+  }
+
   // Rows and footer have the same structure.
-  $sections = ['rows' , 'footer'];
-  foreach ($sections as $section) {
-    if (!empty($variables[$section])) {
-      foreach ($variables[$section] as $row_key => $row) {
-        $cells = $row;
-        $row_attributes = [];
-
-        // Check if we're dealing with a simple or complex row
-        if (isset($row['data'])) {
-          $cells = $row['data'];
-          $variables['no_striping'] = isset($row['no_striping']) ? $row['no_striping'] : FALSE;
-
-          // Set the attributes array and exclude 'data' and 'no_striping'.
-          $row_attributes = $row;
-          unset($row_attributes['data']);
-          unset($row_attributes['no_striping']);
-        }
+  if (!empty($variables['footer'])) {
+    $variables['footer'] = _template_preprocess_table_process_rows($variables['footer']);
+  }
 
-        // Build row.
-        $variables[$section][$row_key] = [];
-        $variables[$section][$row_key]['attributes'] = new Attribute($row_attributes);
-        $variables[$section][$row_key]['cells'] = [];
-        if (!empty($cells)) {
-          // Reset the responsive index.
-          $responsive_index = -1;
-          foreach ($cells as $col_key => $cell) {
-            // Increase the responsive index.
-            $responsive_index++;
-
-            if (!is_array($cell)) {
-              $cell_content = $cell;
-              $cell_attributes = [];
-              $is_header = FALSE;
-            }
-            else {
-              $cell_content = '';
-              if (isset($cell['data'])) {
-                $cell_content = $cell['data'];
-                unset($cell['data']);
-              }
+  if (empty($variables['no_striping'])) {
+    $variables['attributes']['data-striping'] = 1;
+  }
+}
 
-              // Flag the cell as a header or not and remove the flag.
-              $is_header = !empty($cell['header']);
-              unset($cell['header']);
+/**
+ * Prepares rows for table templates.
+ *
+ * @param array[] $rows
+ *   An array of arrays, each representing a row.
+ *
+ * @return array
+ *   The rows, prepared for the template.
+ */
+function _template_preprocess_table_process_rows(array $rows) {
+  foreach ($rows as &$row) {
+    $cells = $row;
+    $row_attributes = [];
+
+    // Check if we're dealing with a simple or complex row
+    if (isset($row['data'])) {
+      $cells = $row['data'];
+      $variables['no_striping'] = isset($row['no_striping']) ? $row['no_striping'] : FALSE;
+
+      // Set the attributes array and exclude 'data' and 'no_striping'.
+      $row_attributes = $row;
+      unset($row_attributes['data']);
+      unset($row_attributes['no_striping']);
+    }
 
-              $cell_attributes = $cell;
-            }
-            // Active table sort information.
-            if (isset($variables['header'][$col_key]['data']) && $variables['header'][$col_key]['data'] == $ts['name'] && !empty($variables['header'][$col_key]['field'])) {
-              $variables[$section][$row_key]['cells'][$col_key]['active_table_sort'] = TRUE;
-            }
-            // Copy RESPONSIVE_PRIORITY_LOW/RESPONSIVE_PRIORITY_MEDIUM
-            // class from header to cell as needed.
-            if (isset($responsive_classes[$responsive_index])) {
-              $cell_attributes['class'][] = $responsive_classes[$responsive_index];
-            }
-            $variables[$section][$row_key]['cells'][$col_key]['tag'] = $is_header ? 'th' : 'td';
-            $variables[$section][$row_key]['cells'][$col_key]['attributes'] = new Attribute($cell_attributes);
-            $variables[$section][$row_key]['cells'][$col_key]['content'] = $cell_content;
+    // Build row.
+    $row = [];
+    $row['attributes'] = new Attribute($row_attributes);
+    $row['cells'] = [];
+    if (!empty($cells)) {
+      // Reset the responsive index.
+      $responsive_index = -1;
+      foreach ($cells as $col_key => $cell) {
+        // Increase the responsive index.
+        $responsive_index++;
+
+        if (!is_array($cell)) {
+          $cell_content = $cell;
+          $cell_attributes = [];
+          $is_header = FALSE;
+        }
+        else {
+          $cell_content = '';
+          if (isset($cell['data'])) {
+            $cell_content = $cell['data'];
+            unset($cell['data']);
           }
+
+          // Flag the cell as a header or not and remove the flag.
+          $is_header = !empty($cell['header']);
+          unset($cell['header']);
+
+          $cell_attributes = $cell;
+        }
+        // Active table sort information.
+        if (isset($variables['header'][$col_key]['data']) && $variables['header'][$col_key]['data'] == $ts['name'] && !empty($variables['header'][$col_key]['field'])) {
+          $row['cells'][$col_key]['active_table_sort'] = TRUE;
         }
+        // Copy RESPONSIVE_PRIORITY_LOW/RESPONSIVE_PRIORITY_MEDIUM
+        // class from header to cell as needed.
+        if (isset($responsive_classes[$responsive_index])) {
+          $cell_attributes['class'][] = $responsive_classes[$responsive_index];
+        }
+        $row['cells'][$col_key]['tag'] = $is_header ? 'th' : 'td';
+        $row['cells'][$col_key]['attributes'] = new Attribute($cell_attributes);
+        $row['cells'][$col_key]['content'] = $cell_content;
       }
     }
   }
-  if (empty($variables['no_striping'])) {
-    $variables['attributes']['data-striping'] = 1;
-  }
+  return $rows;
 }
 
 /**
diff --git a/core/modules/system/templates/table.html.twig b/core/modules/system/templates/table.html.twig
index 443657014f..a72905d340 100644
--- a/core/modules/system/templates/table.html.twig
+++ b/core/modules/system/templates/table.html.twig
@@ -18,18 +18,21 @@
  *   - field: Field name (required for column sorting).
  *   - sort: Default sort order for this column ("asc" or "desc").
  * - sticky: A flag indicating whether to use a "sticky" table header.
- * - rows: Table rows. Each row contains the following properties:
- *   - attributes: HTML attributes to apply to the <tr> tag.
- *   - data: Table cells.
- *   - no_striping: A flag indicating that the row should receive no
- *     'even / odd' styling. Defaults to FALSE.
- *   - cells: Table cells of the row. Each cell contains the following keys:
- *     - tag: The HTML tag name to use; either 'th' or 'td'.
- *     - attributes: Any HTML attributes, such as "colspan", to apply to the
- *       table cell.
- *     - content: The string to display in the table cell.
- *     - active_table_sort: A boolean indicating whether the cell is the active
-         table sort.
+ * - rowgroups: Groups of table rows. Each will be wrapped in a 'tbody' element.
+ *   Each element contains the following properties:
+ *   - attributes: HTML attributes to apply to the <tbody> tag.
+ *   - rows: Table rows. Each row contains the following properties:
+ *     - attributes: HTML attributes to apply to the <tr> tag.
+ *     - data: Table cells.
+ *     - no_striping: A flag indicating that the row should receive no
+ *       'even / odd' styling. Defaults to FALSE.
+ *     - cells: Table cells of the row. Each cell contains the following keys:
+ *       - tag: The HTML tag name to use; either 'th' or 'td'.
+ *       - attributes: Any HTML attributes, such as "colspan", to apply to the
+ *         table cell.
+ *       - content: The string to display in the table cell.
+ *       - active_table_sort: A boolean indicating whether the cell is the
+ *         active table sort.
  * - footer: Table footer rows, in the same format as the rows variable.
  * - empty: The message to display in an extra row if table does not have
  *   any rows.
@@ -70,18 +73,22 @@
     </thead>
   {% endif %}
 
-  {% if rows %}
-    <tbody>
-      {% for row in rows %}
-        <tr{{ row.attributes }}>
-          {% for cell in row.cells %}
-            <{{ cell.tag }}{{ cell.attributes }}>
-              {{- cell.content -}}
-            </{{ cell.tag }}>
+  {% if rowgroups %}
+    {% for rowgroup in rowgroups %}
+      {% if rows %}
+        <tbody{{ rowgroup.attributes }}>
+          {% for row in rowgroup.rows %}
+            <tr{{ row.attributes }}>
+              {% for cell in row.cells %}
+                <{{ cell.tag }}{{ cell.attributes }}>
+                  {{- cell.content -}}
+                </{{ cell.tag }}>
+              {% endfor %}
+            </tr>
           {% endfor %}
-        </tr>
-      {% endfor %}
-    </tbody>
+        </tbody>
+    {% endfor %}
+  {% endif %}
   {% elseif empty %}
     <tbody>
       <tr>
diff --git a/core/themes/seven/templates/classy/dataset/table.html.twig b/core/themes/seven/templates/classy/dataset/table.html.twig
index 2afa9c1556..b0e8045394 100644
--- a/core/themes/seven/templates/classy/dataset/table.html.twig
+++ b/core/themes/seven/templates/classy/dataset/table.html.twig
@@ -18,18 +18,21 @@
  *   - field: Field name (required for column sorting).
  *   - sort: Default sort order for this column ("asc" or "desc").
  * - sticky: A flag indicating whether to use a "sticky" table header.
- * - rows: Table rows. Each row contains the following properties:
- *   - attributes: HTML attributes to apply to the <tr> tag.
- *   - data: Table cells.
- *   - no_striping: A flag indicating that the row should receive no
- *     'even / odd' styling. Defaults to FALSE.
- *   - cells: Table cells of the row. Each cell contains the following keys:
- *     - tag: The HTML tag name to use; either 'th' or 'td'.
- *     - attributes: Any HTML attributes, such as "colspan", to apply to the
- *       table cell.
- *     - content: The string to display in the table cell.
- *     - active_table_sort: A boolean indicating whether the cell is the active
-         table sort.
+ * - rowgroups: Groups of table rows. Each will be wrapped in a 'tbody' element.
+ *   Each element contains the following properties:
+ *   - attributes: HTML attributes to apply to the <tbody> tag.
+ *   - rows: Table rows. Each row contains the following properties:
+ *     - attributes: HTML attributes to apply to the <tr> tag.
+ *     - data: Table cells.
+ *     - no_striping: A flag indicating that the row should receive no
+ *       'even / odd' styling. Defaults to FALSE.
+ *     - cells: Table cells of the row. Each cell contains the following keys:
+ *       - tag: The HTML tag name to use; either 'th' or 'td'.
+ *       - attributes: Any HTML attributes, such as "colspan", to apply to the
+ *         table cell.
+ *       - content: The string to display in the table cell.
+ *       - active_table_sort: A boolean indicating whether the cell is the
+ *         active table sort.
  * - footer: Table footer rows, in the same format as the rows variable.
  * - empty: The message to display in an extra row if table does not have
  *   any rows.
@@ -73,23 +76,27 @@
     </thead>
   {% endif %}
 
-  {% if rows %}
-    <tbody>
-      {% for row in rows %}
-        {%
-          set row_classes = [
-            not no_striping ? cycle(['odd', 'even'], loop.index0),
-          ]
-        %}
-        <tr{{ row.attributes.addClass(row_classes) }}>
-          {% for cell in row.cells %}
-            <{{ cell.tag }}{{ cell.attributes }}>
-              {{- cell.content -}}
-            </{{ cell.tag }}>
+  {% if rowgroups %}
+    {% for rowgroup in rowgroups %}
+      {% if rowgroup.rows %}
+        <tbody{{ rowgroup.attributes }}>
+          {% for row in rowgroup.rows %}
+            {%
+              set row_classes = [
+                not no_striping ? cycle(['odd', 'even'], loop.index0),
+              ]
+            %}
+            <tr{{ row.attributes.addClass(row_classes) }}>
+              {% for cell in row.cells %}
+                <{{ cell.tag }}{{ cell.attributes }}>
+                  {{- cell.content -}}
+                </{{ cell.tag }}>
+              {% endfor %}
+            </tr>
           {% endfor %}
-        </tr>
-      {% endfor %}
-    </tbody>
+        </tbody>
+      {% endif %}
+    {% endfor %}
   {% elseif empty %}
     <tbody>
       <tr class="odd">
