diff --git a/core/includes/tablesort.inc b/core/includes/tablesort.inc
index 48a8658..4359eff 100644
--- a/core/includes/tablesort.inc
+++ b/core/includes/tablesort.inc
@@ -70,35 +70,6 @@ function tablesort_header($cell, $header, $ts) {
 }
 
 /**
- * Formats a table cell.
- *
- * Adds a class attribute to all cells in the currently active column.
- *
- * @param $cell
- *   The cell to format.
- * @param $header
- *   An array of column headers in the format described in '#theme' => 'table'.
- * @param $ts
- *   The current table sort context as returned from tablesort_init().
- * @param $i
- *   The index of the cell's table column.
- *
- * @return
- *   A properly formatted cell, ready for _theme_table_cell().
- */
-function tablesort_cell($cell, $header, $ts, $i) {
-  if (isset($header[$i]['data']) && $header[$i]['data'] == $ts['name'] && !empty($header[$i]['field'])) {
-    if (is_array($cell)) {
-      $cell['class'][] = 'active';
-    }
-    else {
-      $cell = array('data' => $cell, 'class' => array('active'));
-    }
-  }
-  return $cell;
-}
-
-/**
  * Composes a URL query parameter array for table sorting links.
  *
  * @return
diff --git a/core/includes/theme.inc b/core/includes/theme.inc
index f98aadd..f5d1ecd 100644
--- a/core/includes/theme.inc
+++ b/core/includes/theme.inc
@@ -1479,9 +1479,11 @@ function drupal_pre_render_table(array $element) {
 }
 
 /**
- * Returns HTML for a table.
+ * Prepares variables for table templates.
  *
- * @param $variables
+ * Default template: table.html.twig.
+ *
+ * @param array $variables
  *   An associative array containing:
  *   - header: An array containing the table headers. Each element of the array
  *     can be either a localized string or an associative array with the
@@ -1563,52 +1565,28 @@ function drupal_pre_render_table(array $element) {
  *   - empty: The message to display in an extra row if table does not have any
  *     rows.
  */
-function theme_table($variables) {
-  $header = $variables['header'];
-  $rows = $variables['rows'];
-  $attributes = $variables['attributes'];
-  $caption = $variables['caption'];
-  $colgroups = $variables['colgroups'];
-  $sticky = $variables['sticky'];
-  $responsive = $variables['responsive'];
-  $empty = $variables['empty'];
+function template_preprocess_table(&$variables) {
+  // Flags.
+  $is_sticky = !empty($variables['sticky']);
+  $is_responsive = !empty($variables['responsive']);
 
-  // Add sticky headers, if applicable.
-  if (count($header) && $sticky) {
-    drupal_add_library('system', 'drupal.tableheader');
-    // Add 'sticky-enabled' class to the table to identify it for JS.
-    // This is needed to target tables constructed by this function.
-    $attributes['class'][] = 'sticky-enabled';
-  }
-  // If the table has headers and it should react responsively to columns hidden
-  // with the classes represented by the constants RESPONSIVE_PRIORITY_MEDIUM
-  // and RESPONSIVE_PRIORITY_LOW, add the tableresponsive behaviors.
-  if (count($header) && $responsive) {
-    drupal_add_library('system', 'drupal.tableresponsive');
-    // Add 'responsive-enabled' class to the table to identify it for JS.
-    // This is needed to target tables constructed by this function.
-    $attributes['class'][] = 'responsive-enabled';
+  // Table header.
+  if (!empty($variables['header']) && is_array($variables['header'])) {
+    $variables['header'] = array_values($variables['header']);
   }
 
-  $output = '<table' . new Attribute($attributes) . ">\n";
+  if (!empty($variables['colgroups'])) {
+    foreach ($variables['colgroups'] as $colgroup_key => $colgroup) {
+      $colgroup_attributes = new Attribute();
 
-  if (isset($caption)) {
-    $output .= '<caption>' . $caption . "</caption>\n";
-  }
-
-  // Format the table columns:
-  if (count($colgroups)) {
-    foreach ($colgroups as $colgroup) {
-      $attributes = array();
-
-      // Check if we're dealing with a simple or complex column
+      // Support simple and complex colgroups.
       if (isset($colgroup['data'])) {
         foreach ($colgroup as $key => $value) {
           if ($key == 'data') {
             $cols = $value;
           }
           else {
-            $attributes[$key] = $value;
+            $colgroup_attributes[$key] = $value;
           }
         }
       }
@@ -1616,24 +1594,24 @@ function theme_table($variables) {
         $cols = $colgroup;
       }
 
-      // Build colgroup
-      if (is_array($cols) && count($cols)) {
-        $output .= ' <colgroup' . new Attribute($attributes) . '>';
-        foreach ($cols as $col) {
-          $output .= ' <col' . new Attribute($col) . ' />';
+      $variables['colgroups'][$colgroup_key]['attributes'] = $colgroup_attributes;
+
+      // Build colgroups and assign attributes
+      if (is_array($cols) && !empty($cols)) {
+        $variables['colgroups'][$colgroup_key]['cols'] = array();
+        foreach ($cols as $col_key => $col) {
+          $col_attributes = new Attribute($col);
+          $variables['colgroups'][$colgroup_key]['cols'][$col_key]['attributes'] = $col_attributes;
         }
-        $output .= " </colgroup>\n";
-      }
-      else {
-        $output .= ' <colgroup' . new Attribute($attributes) . " />\n";
       }
     }
   }
 
-  // Add the 'empty' row message if available.
-  if (!count($rows) && $empty) {
+  // Add one row spanning all columns for the 'empty' message if there are no
+  // data rows and the empty message is set.
+  if (empty($variables['rows']) && isset($variables['empty'])) {
     $header_count = 0;
-    foreach ($header as $header_cell) {
+    foreach ($variables['header'] as $header_cell) {
       if (is_array($header_cell)) {
         $header_count += isset($header_cell['colspan']) ? $header_cell['colspan'] : 1;
       }
@@ -1641,96 +1619,171 @@ function theme_table($variables) {
         $header_count++;
       }
     }
-    $rows[] = array(array('data' => $empty, 'colspan' => $header_count, 'class' => array('empty', 'message')));
+    // Add one row for the empty message spanning all columns.
+    $variables['rows'][] = array(array(
+      'data' => $variables['empty'],
+      'attributes' => new Attribute(array(
+        'colspan' => $header_count,
+        'class' => array('empty', 'message'),
+      ))
+    ));
   }
 
-  $responsive = array();
-  // Format the table header:
-  if (count($header)) {
-    $ts = tablesort_init($header);
-    // HTML requires that the thead tag has tr tags in it followed by tbody
-    // tags. Using ternary operator to check and see if we have any rows.
-    $output .= (count($rows) ? ' <thead><tr>' : ' <tr>');
-    $i = 0;
-    foreach ($header as $cell) {
-      $i++;
+  // Build an associative array of responsive classes keyed by column.
+  $responsive_classes = array();
+
+  // Preprocess table header.
+  if (!empty($variables['header'])) {
+
+    // Add sticky headers, if applicable.
+    if ($is_sticky) {
+      drupal_add_library('system', 'drupal.tableheader');
+      // Add 'sticky-enabled' class to the table to identify it for JavaScript.
+      $variables['attributes']['class'][] = 'sticky-enabled';
+    }
+
+    // If the table is responsive, set a class and add the necessary library.
+    if ($is_responsive) {
+      drupal_add_library('system', 'drupal.tableresponsive');
+      // Add 'responsive-enabled' class to the table to identify it
+      // for JavaScript.
+      $variables['attributes']['class'][] = 'responsive-enabled';
+    }
+
+    // Initialize table sorting.
+    $ts = tablesort_init($variables['header']);
+
+    foreach ($variables['header'] as $col_key => $cell) {
+      // If the cell is not an array, make it one.
+      if (!is_array($cell)) {
+        $cell = array(
+          'data' => $cell,
+          'attributes' => array(),
+        );
+      }
+
+      if (!isset($cell['attributes'])) {
+        $cell['attributes'] = array();
+      }
+
       // Track responsive classes for each column as needed. Only the header
       // cells for a column are marked up with the responsive classes by a
       // module developer or themer. The responsive classes on the header cells
       // must be transferred to the content cells.
       if (!empty($cell['class']) && is_array($cell['class'])) {
         if (in_array(RESPONSIVE_PRIORITY_MEDIUM, $cell['class'])) {
-          $responsive[$i] =  RESPONSIVE_PRIORITY_MEDIUM;
+          $responsive_classes[$col_key] = RESPONSIVE_PRIORITY_MEDIUM;
         }
         elseif (in_array(RESPONSIVE_PRIORITY_LOW, $cell['class'])) {
-          $responsive[$i] =  RESPONSIVE_PRIORITY_LOW;
+          $responsive_classes[$col_key] = RESPONSIVE_PRIORITY_LOW;
+        }
+      }
+
+      $cell = tablesort_header($cell, $variables['header'], $ts);
+
+      // Move cell properties that are not data or attributes into attributes.
+      // Doing this after tablesort_header as it removes its sort and field
+      // values.
+      foreach ($cell as $key => $value) {
+        if ($key != 'data' && $key != 'attributes') {
+          $cell['attributes'][$key] = $value;
         }
       }
-      $cell = tablesort_header($cell, $header, $ts);
-      $output .= _theme_table_cell($cell, TRUE);
+
+      $cell['attributes'] = new Attribute($cell['attributes']);
+
+      // Flag the cell as a header.
+      $cell['header'] = TRUE;
+
+      // Update the header cell with attributes and properties.
+      $variables['header'][$col_key] = $cell;
     }
-    // Using ternary operator to close the tags based on whether or not there are rows
-    $output .= (count($rows) ? " </tr></thead>\n" : "</tr>\n");
   }
   else {
     $ts = array();
   }
 
-  // Format the table rows:
-  if (count($rows)) {
-    $output .= "<tbody>\n";
-    $flip = array('even' => 'odd', 'odd' => 'even');
+  if (!empty($variables['rows'])) {
+    $flip = array(
+      'even' => 'odd',
+      'odd' => 'even',
+    );
     $class = 'even';
-    foreach ($rows as $row) {
-      // Check if we're dealing with a simple or complex row
+    foreach ($variables['rows'] as $row_key => $row) {
+      // Check if we're dealing with a simple or complex row.
       if (isset($row['data'])) {
         $cells = $row['data'];
         $no_striping = isset($row['no_striping']) ? $row['no_striping'] : FALSE;
 
         // Set the attributes array and exclude 'data' and 'no_striping'.
-        $attributes = $row;
-        unset($attributes['data']);
-        unset($attributes['no_striping']);
+        $row_attributes = $row;
+        unset($row_attributes['data']);
+        unset($row_attributes['no_striping']);
       }
       else {
         $cells = $row;
-        $attributes = array();
+        $row_attributes = array();
         $no_striping = FALSE;
       }
-      if (count($cells)) {
-        // Add odd/even class
-        if (!$no_striping) {
-          $class = $flip[$class];
-          $attributes['class'][] = $class;
-        }
 
-        // Build row
-        $output .= ' <tr' . new Attribute($attributes) . '>';
+      // Add odd/even class.
+      if (!$no_striping) {
+        $class = $flip[$class];
+        $row_attributes['class'][] = $class;
+      }
+
+      // Build row.
+      if (!empty($cells)) {
+        $variables['rows'][$row_key] = array();
+        $variables['rows'][$row_key]['attributes'] = new Attribute($row_attributes);
+        $variables['rows'][$row_key]['cells'] = array();
         $i = 0;
         foreach ($cells as $cell) {
-          $i++;
+          // If the cell is not an array, make it one.
+          if (!is_array($cell)) {
+            $cell = array(
+              'data' => $cell,
+              'attributes' => array(),
+            );
+          }
+
+          if (!isset($cell['attributes'])) {
+            $cell['attributes'] = array();
+          }
+
+          // Move cell properties that are not data, attributes or header
+          // property into attributes.
+          foreach ($cell as $key => $value) {
+            if ($key != 'data' && $key != 'attributes' && $key != 'header') {
+              $cell['attributes'][$key] = $value;
+            }
+          }
+
           // Add active class if needed for sortable tables.
-          $cell = tablesort_cell($cell, $header, $ts, $i);
+          if (isset($variables['header'][$i]['data']) && $variables['header'][$i]['data'] == $ts['name'] && !empty($variables['header'][$i]['field'])) {
+            $cell['attributes']['class'][] = 'active';
+          }
+
           // Copy RESPONSIVE_PRIORITY_LOW/RESPONSIVE_PRIORITY_MEDIUM
           // class from header to cell as needed.
-          if (isset($responsive[$i])) {
-            if (is_array($cell)) {
-              $cell['class'][] = $responsive[$i];
-            }
-            else {
-              $cell = array('data' => $cell, 'class' => $responsive[$i]);
-            }
+          if (isset($responsive_classes[$i])) {
+            $cell['attributes']['class'][] = $responsive_classes[$i];
+          }
+
+          $cell['attributes'] = new Attribute($cell['attributes']);
+
+          // Make sure that zeros get printed as '0'.
+          if (isset($cell['data']) && is_int($cell['data'])) {
+            $cell['data'] = (string) $cell['data'];
           }
-          $output .= _theme_table_cell($cell);
+
+          // Add the cell to the row.
+          $variables['rows'][$row_key]['cells'][$i] = $cell;
+          $i++;
         }
-        $output .= " </tr>\n";
       }
     }
-    $output .= "</tbody>\n";
   }
-
-  $output .= "</table>\n";
-  return $output;
 }
 
 /**
@@ -1910,47 +1963,6 @@ function theme_container($variables) {
  */
 
 /**
- * Returns HTML output for a single table cell for theme_table().
- *
- * @param $cell
- *   Array of cell information, or string to display in cell.
- * @param bool $header
- *   TRUE if this cell is a table header cell, FALSE if it is an ordinary
- *   table cell. If $cell is an array with element 'header' set to TRUE, that
- *   will override the $header parameter.
- *
- * @return
- *   HTML for the cell.
- */
-function _theme_table_cell($cell, $header = FALSE) {
-  $attributes = '';
-
-  if (is_array($cell)) {
-    $data = isset($cell['data']) ? $cell['data'] : '';
-    // Cell's data property can be a string or a renderable array.
-    if (is_array($data)) {
-      $data = drupal_render($data);
-    }
-    $header |= isset($cell['header']);
-    unset($cell['data']);
-    unset($cell['header']);
-    $attributes = new Attribute($cell);
-  }
-  else {
-    $data = $cell;
-  }
-
-  if ($header) {
-    $output = "<th$attributes>$data</th>";
-  }
-  else {
-    $output = "<td$attributes>$data</td>";
-  }
-
-  return $output;
-}
-
-/**
  * Adds a default set of helper variables for preprocessors and templates.
  *
  * This function is called for theme hooks implemented as templates only, not
@@ -2598,6 +2610,7 @@ function drupal_common_theme() {
     ),
     'table' => array(
       'variables' => array('header' => NULL, 'rows' => NULL, 'attributes' => array(), 'caption' => NULL, 'colgroups' => array(), 'sticky' => FALSE, 'responsive' => TRUE, 'empty' => ''),
+      'template' => 'table',
     ),
     'tablesort_indicator' => array(
       'variables' => array('style' => NULL),
diff --git a/core/modules/system/lib/Drupal/system/Tests/Theme/TableTest.php b/core/modules/system/lib/Drupal/system/Tests/Theme/TableTest.php
index 1a9d3d1..4d7efc8 100644
--- a/core/modules/system/lib/Drupal/system/Tests/Theme/TableTest.php
+++ b/core/modules/system/lib/Drupal/system/Tests/Theme/TableTest.php
@@ -27,7 +27,8 @@ public static function getInfo() {
   function testThemeTableStickyHeaders() {
     $header = array('one', 'two', 'three');
     $rows = array(array(1,2,3), array(4,5,6), array(7,8,9));
-    $this->content = theme('table', array('header' => $header, 'rows' => $rows, 'sticky' => TRUE));
+    $table = array('#theme' => 'table', '#header' => $header, '#rows' => $rows, '#sticky' => TRUE);
+    $this->content = drupal_render($table);
     $js = _drupal_add_js();
     $this->assertTrue(isset($js['core/misc/tableheader.js']), 'tableheader.js was included when $sticky = TRUE.');
     $this->assertRaw('sticky-enabled',  'Table has a class of sticky-enabled when $sticky = TRUE.');
@@ -43,7 +44,8 @@ function testThemeTableNoStickyHeaders() {
     $attributes = array();
     $caption = NULL;
     $colgroups = array();
-    $this->content = theme('table', array('header' => $header, 'rows' => $rows, 'attributes' => $attributes, 'caption' => $caption, 'colgroups' => $colgroups, 'sticky' => FALSE));
+    $table = array('#theme' => 'table', '#header' => $header, '#rows' => $rows, '#attributes' => $attributes, '#caption' => $caption, '#colgroups' => $colgroups, '#sticky' => FALSE);
+    $this->content = drupal_render($table);
     $js = _drupal_add_js();
     $this->assertFalse(isset($js['core/misc/tableheader.js']), 'tableheader.js was not included because $sticky = FALSE.');
     $this->assertNoRaw('sticky-enabled',  'Table does not have a class of sticky-enabled because $sticky = FALSE.');
@@ -62,9 +64,10 @@ function testThemeTableWithEmptyMessage() {
         'colspan' => 2,
       ),
     );
-    $this->content = theme('table', array('header' => $header, 'rows' => array(), 'empty' => t('No strings available.')));
-    $this->assertRaw('<tr class="odd"><td colspan="3" class="empty message">No strings available.</td>', 'Correct colspan was set on empty message.');
-    $this->assertRaw('<thead><tr><th>Header 1</th>', 'Table header was printed.');
+    $table = array('#theme' => 'table', '#header' => $header, '#rows' => array(), '#empty' => t('No strings available.'));
+    $this->content = drupal_render($table);
+    $this->assertTrue($this->xpath('//tr[@class="odd"]/td[@colspan="3" and @class="empty message" and text()=:text]', array(':text' => t('No strings available.'))), 'Correct colspan was set on empty message.');
+    $this->assertTrue($this->xpath('//thead/tr/th[text()=:text]', array(':text' => t('Header 1'))), 'Table header was printed.');
   }
 
   /**
@@ -77,8 +80,25 @@ function testThemeTableWithNoStriping() {
         'no_striping' => TRUE,
       ),
     );
-    $this->content = theme('table', array('rows' => $rows));
+    $table = array('#theme' => 'table', '#rows' => $rows);
+    $this->content = drupal_render($table);
     $this->assertNoRaw('class="odd"', 'Odd/even classes were not added because $no_striping = TRUE.');
     $this->assertNoRaw('no_striping', 'No invalid no_striping HTML attribute was printed.');
   }
+
+  /**
+   * Tests that the 'header' option in cells works correctly.
+   */
+  function testThemeTableHeaderCellOption() {
+    $rows = array(
+      array(
+        array('data' => 1, 'header' => TRUE),
+        array('data' => 1, 'header' => FALSE),
+        array('data' => 1),
+      ),
+    );
+    $table = array('#theme' => 'table', '#rows' => $rows);
+    $this->content = drupal_render($table);
+    $this->assertRaw('<th>1</th><td>1</td><td>1</td>', 'The th and td tags was printed correctly.');
+  }
 }
diff --git a/core/modules/system/templates/table.html.twig b/core/modules/system/templates/table.html.twig
new file mode 100644
index 0000000..0135898
--- /dev/null
+++ b/core/modules/system/templates/table.html.twig
@@ -0,0 +1,85 @@
+{#
+/**
+ * @file
+ * Default theme implementation to display a table.
+ *
+ * Available variables:
+ * - attributes: HTML attributes to apply to the <table> tag.
+ * - caption: A localized string for the <caption> tag.
+ * - colgroups: Column groups. Each group contains the following properties:
+ *   - attributes: HTML attributes to apply to the <col> tag.
+ *     Note: Drupal currently supports only one table header row, see
+ *     http://drupal.org/node/893530 and
+ *     http://api.drupal.org/api/drupal/includes!theme.inc/function/theme_table/7#comment-5109.
+ * - header: Table header cells. Each cell contains the following properties:
+ *   - attributes: HTML attributes to apply to the <th> tag.
+ *   - data: A localized string for the title of the column.
+ *   - 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.
+ *     A cell can be either a string or may contain the following keys:
+ *     - data: The string to display in the table cell.
+ *     - header: Indicates this cell is a header.
+ *     - attributes: Any HTML attributes, such as "colspan", to apply to the
+ *       table cell.
+ * - empty: The message to display in an extra row if table does not have
+ *   any rows.
+ *
+ * @see template_preprocess_table()
+ *
+ * @ingroup themeable
+ */
+#}
+<table{{ attributes }}>
+  {# Caption #}
+  {% if caption %}
+    <caption>{{ caption }}</caption>
+  {% endif %}
+
+  {# Columns #}
+  {% for colgroup in colgroups %}
+    {% if colgroup.cols %}
+      <colgroup{{ colgroup.attributes }}>
+        {% for col in colgroup.cols %}
+          <col{{ col.attributes }} />
+        {% endfor %}
+      </colgroup>
+    {% else %}
+      <colgroup{{ colgroup.attributes }} />
+    {% endif %}
+  {% endfor %}
+
+  {# Header #}
+  {% if header %}
+    <thead>
+      <tr>
+        {% for cell in header -%}
+          <th{{ cell.attributes }}>
+            {{- cell.data -}}
+          </th>
+        {%- endfor %}
+      </tr>
+    </thead>
+  {% endif %}
+
+  {# Rows #}
+  {% if rows %}
+    <tbody>
+      {% for row in rows %}
+        <tr{{ row.attributes }}>
+          {% for cell in row.cells -%}
+            {%- set cell_tag = cell.header ? 'th' : 'td' -%}
+            <{{ cell_tag }}{{ cell.attributes }}>
+              {{- cell.data -}}
+            </{{ cell_tag }}>
+          {%- endfor %}
+        </tr>
+      {% endfor %}
+    </tbody>
+  {% endif %}
+</table>
