diff --git a/core/includes/form.inc b/core/includes/form.inc
index 360155e..6e0d57b 100644
--- a/core/includes/form.inc
+++ b/core/includes/form.inc
@@ -191,10 +191,6 @@ function form_options_flatten($array) {
  *
  * Default template: select.html.twig.
  *
- * It is possible to group options together; to do this, change the format of
- * $options to an associative array in which the keys are group labels, and the
- * values are associative arrays in the normal $options format.
- *
  * @param $variables
  *   An associative array containing:
  *   - element: An associative array containing the properties of the element.
@@ -207,7 +203,41 @@ function template_preprocess_select(&$variables) {
   Element\RenderElement::setAttributes($element, array('form-select'));
 
   $variables['attributes'] = $element['#attributes'];
-  $variables['options'] = form_select_options($element);
+  $variables['options'] = drupal_render_children($element);
+}
+
+/**
+ * Prepares variables for option group element templates.
+ *
+ * Default template: optgroup.html.twig.
+ *
+ * @param $variables
+ *   An associative array containing:
+ *   - element: An associative array containing the properties of the element.
+ *     Properties used: #title, #value, #options, #multiple, #attributes.
+ */
+function template_preprocess_optgroup(&$variables) {
+  $element = $variables['element'];
+
+  $variables['attributes'] = $element['#attributes'];
+  $variables['options'] = drupal_render_children($element);
+}
+
+/**
+ * Prepares variables for option element templates.
+ *
+ * Default template: option.html.twig.
+ *
+ * @param $variables
+ *   An associative array containing:
+ *   - element: An associative array containing the properties of the element.
+ *     Properties used: #title, #value, #attributes.
+ */
+function template_preprocess_option(&$variables) {
+  $element = $variables['element'];
+
+  $variables['attributes'] = $element['#attributes'];
+  $variables['label'] = $element['#title'];
 }
 
 /**
diff --git a/core/includes/theme.inc b/core/includes/theme.inc
index b349918..8b668b3 100644
--- a/core/includes/theme.inc
+++ b/core/includes/theme.inc
@@ -2396,6 +2396,14 @@ function drupal_common_theme() {
       'render element' => 'element',
       'template' => 'select',
     ),
+    'option' => array(
+      'render element' => 'element',
+      'template' => 'option',
+    ),
+    'optgroup' => array(
+      'render element' => 'element',
+      'template' => 'optgroup',
+    ),
     'fieldset' => array(
       'render element' => 'element',
       'template' => 'fieldset',
diff --git a/core/lib/Drupal/Core/Form/FormValidator.php b/core/lib/Drupal/Core/Form/FormValidator.php
index ee2da65..51dd3fc 100644
--- a/core/lib/Drupal/Core/Form/FormValidator.php
+++ b/core/lib/Drupal/Core/Form/FormValidator.php
@@ -316,7 +316,7 @@ protected function performRequiredValidation(&$elements, FormStateInterface &$fo
 
     if (isset($elements['#options']) && isset($elements['#value'])) {
       if ($elements['#type'] == 'select') {
-        $options = OptGroup::flattenOptions($elements['#options']);
+        $options = Element\Select::getFlattenedOptions($elements);
       }
       else {
         $options = $elements['#options'];
diff --git a/core/lib/Drupal/Core/Render/Element/Optgroup.php b/core/lib/Drupal/Core/Render/Element/Optgroup.php
new file mode 100644
index 0000000..12290c7
--- /dev/null
+++ b/core/lib/Drupal/Core/Render/Element/Optgroup.php
@@ -0,0 +1,131 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\Render\Element\Option.
+ */
+
+namespace Drupal\Core\Render\Element;
+
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Render\Element;
+
+/**
+ * Provides a form element for a group of options inside of a select element.
+ *
+ * @FormElement("optgroup")
+ *
+ * @see \Drupal\Core\Render\Element\Select
+ * @see \Drupal\Core\Render\Element\Option
+ */
+class Optgroup extends RenderElement {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getInfo() {
+    $class = get_class($this);
+    return array(
+      '#process' => array(
+        array($class, 'processOptgroup'),
+        array($class, 'processAjaxForm'),
+      ),
+      '#pre_render' => array(
+        array($class, 'preRenderOptgroup'),
+      ),
+      '#theme' => 'optgroup',
+      '#options' => array(),
+    );
+  }
+
+  /**
+   * Processes an option group form element.
+   *
+   * This expands the #options property of option groups into proper option
+   * elements.
+   *
+   * @param array $element
+   *   The form element to process. Properties used:
+   *   - #options: (optional) An array of options for this option group where
+   *     the keys are the option values and the values are the respective
+   *     labels. Instead of specifying the options in this way, respective
+   *     option elements can be created as child elements of an option group
+   *     element.
+   * @param \Drupal\Core\Form\FormStateInterface $form_state
+   *   The current state of the form.
+   * @param array $complete_form
+   *   The complete form structure.
+   *
+   * @return array
+   *   The processed element.
+   *
+   * @see _form_validate()
+   */
+  public static function processOptgroup(&$element, FormStateInterface $form_state, &$complete_form) {
+    // @TODO This check is taken over from \Drupal\Core\Render\Element\Radios
+    //   and \Drupal\Core\Render\Element\Checkboxes, but I do not understand why
+    //   it is necessary.
+    if (count($element['#options']) > 0) {
+      // @TODO This is ported from form_select_options(). But instead of
+      //   checking #value, #default_value should be used here and below, right?
+      $value_valid = isset($element['#value']) || array_key_exists('#value', $element);
+      $value_is_array = $value_valid && is_array($element['#value']);
+      // Check if the element is multiple select and no value has been selected.
+      $empty_value = (empty($element['#value']) && !empty($element['#multiple']));
+
+      $weight = 0;
+      foreach ($element['#options'] as $key => $choice) {
+        $key = (string) $key;
+
+        // Maintain order of options as defined in #options, in case the element
+        // defines custom option sub-elements, but does not define all option
+        // sub-elements.
+        $weight += 0.001;
+
+        $empty_choice = $empty_value && $key == '_none';
+
+        $element += array($key => array());
+        $element[$key] += array(
+          '#type' => 'option',
+          '#title' => $choice,
+          // The key is sanitized in Drupal\Core\Template\Attribute during output
+          // from the theme function.
+          '#return_value' => $key,
+          '#selected' => $value_valid && ((!$value_is_array && (string) $element['#value'] === $key || ($value_is_array && in_array($key, $element['#value']))) || $empty_choice),
+          '#ajax' => isset($element['#ajax']) ? $element['#ajax'] : NULL,
+          '#weight' => $weight,
+        );
+      }
+    }
+
+    return $element;
+  }
+
+  /**
+   * Prepares an option group render element.
+   */
+  public static function preRenderOptgroup($element) {
+    $element['#attributes']['label'] = $element['#title'];
+    return $element;
+  }
+
+  /**
+   * Returns a list of options for an option group element.
+   *
+   * This function assumes that the passed element has been processed by
+   * Optgroup::processOptgroup() prior to calling this function.
+   */
+  public static function getOptions($element) {
+    $options = [];
+
+    // @TODO This does not respect weights.
+    foreach (Element::children($element) as $child) {
+      if ($element[$child]['#type'] == 'option') {
+        $options[$child] = $element[$child]['#title'];
+      }
+    }
+
+    return $options;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Render/Element/Option.php b/core/lib/Drupal/Core/Render/Element/Option.php
new file mode 100644
index 0000000..036a10e
--- /dev/null
+++ b/core/lib/Drupal/Core/Render/Element/Option.php
@@ -0,0 +1,50 @@
+<?php
+
+/**
+ * @file
+ * Contains \Drupal\Core\Render\Element\Option.
+ */
+
+namespace Drupal\Core\Render\Element;
+
+use Drupal\Core\Render\Element;
+
+/**
+ * Provides a form element for a single option of a select element.
+ *
+ * @FormElement("option")
+ *
+ * @see \Drupal\Core\Render\Element\Select
+ * @see \Drupal\Core\Render\Element\Optgroup
+ */
+class Option extends RenderElement {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getInfo() {
+    $class = get_class($this);
+    return array(
+      '#process' => array(
+        array($class, 'processAjaxForm'),
+      ),
+      '#pre_render' => array(
+        array($class, 'preRenderOption'),
+      ),
+      '#theme' => 'option',
+      '#selected' => FALSE,
+    );
+  }
+
+  /**
+   * Prepares an option render element.
+   */
+  public static function preRenderOption($element) {
+    $element['#attributes']['value'] = $element['#return_value'];
+    if ($element['#selected']) {
+      $element['#attributes']['selected'] = 'selected';
+    }
+    return $element;
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Render/Element/Select.php b/core/lib/Drupal/Core/Render/Element/Select.php
index 70f14d6..c4ab21d 100644
--- a/core/lib/Drupal/Core/Render/Element/Select.php
+++ b/core/lib/Drupal/Core/Render/Element/Select.php
@@ -17,6 +17,9 @@
  * a select element, including behavior if #required is TRUE or FALSE.
  *
  * @FormElement("select")
+ *
+ * @see \Drupal\Core\Render\Element\Option
+ * @see \Drupal\Core\Render\Element\Optgroup
  */
 class Select extends FormElement {
 
@@ -113,6 +116,59 @@ public static function processSelect(&$element, FormStateInterface $form_state,
         $element['#options'] = $empty_option + $element['#options'];
       }
     }
+
+    // If there are option group elements, pass the '#value' and '#multiple'
+    // keys down so they can properly set the selected option.
+    // @see \Drupal\Core\Render\Element\Optgroup::processOptgroup()
+    foreach (Element::children($element) as $child) {
+      if ($element[$child]['#type'] == 'optgroup') {
+        $element[$child]['#value'] = $element['#value'];
+        $element[$child]['#multiple'] = $element['#multiple'];
+      }
+    }
+
+    // Process option groups.
+    // @TODO Fix weights with mixed optgroups and options.
+    if (count($element['#options']) > 0) {
+      // @TODO This is ported from form_select_options(). But instead of
+      //   checking #value, #default_value should be used here and below, right?
+      $value_valid = isset($element['#value']) || array_key_exists('#value', $element);
+      $value_is_array = $value_valid && is_array($element['#value']);
+      // Check if the element is multiple select and no value has been selected.
+      $empty_value = (empty($element['#value']) && !empty($element['#multiple']));
+
+      $weight = 0;
+      foreach ($element['#options'] as $key => $choice) {
+        $key = (string) $key;
+
+        if (is_array($choice)) {
+          // Maintain order of options as defined in #options, in case the element
+          // defines custom option sub-elements, but does not define all option
+          // sub-elements.
+          $weight += 0.001;
+
+          $element += array($key => array());
+          $element[$key] += array(
+            '#type' => 'optgroup',
+            '#title' => $key,
+            // The key is sanitized in Drupal\Core\Template\Attribute during output
+            // from the theme function.
+            '#options' => $choice,
+            '#ajax' => isset($element['#ajax']) ? $element['#ajax'] : NULL,
+            '#weight' => $weight,
+          );
+
+          // @TODO
+          Optgroup::processOptgroup($element[$key], $form_state, $complete_form);
+
+          unset($element['#options'][$key]);
+        }
+      }
+    }
+
+    // Process the '#options' element in the same way that option groups do.
+    Optgroup::processOptgroup($element, $form_state, $complete_form);
+
     return $element;
   }
 
@@ -156,4 +212,25 @@ public static function preRenderSelect($element) {
     return $element;
   }
 
+  /**
+   * Returns a list of flattened options for a select element.
+   *
+   * This function assumes that the passed element has been processed by
+   * Select::processSelect() prior to calling this function.
+   */
+  public static function getFlattenedOptions($element) {
+    $options = [];
+
+    // @TODO This does not respect weights.
+    foreach (Element::children($element) as $child) {
+      if ($element[$child]['#type'] == 'optgroup') {
+        $options += Optgroup::getOptions($element[$child]);
+        unset($element[$child]);
+      }
+    }
+    $options += Optgroup::getOptions($element);
+
+    return $options;
+  }
+
 }
diff --git a/core/lib/Drupal/Core/Render/Element/Weight.php b/core/lib/Drupal/Core/Render/Element/Weight.php
index ffcda6b..7b4d088 100644
--- a/core/lib/Drupal/Core/Render/Element/Weight.php
+++ b/core/lib/Drupal/Core/Render/Element/Weight.php
@@ -52,6 +52,7 @@ public static function processWeight(&$element, FormStateInterface $form_state,
       }
       $element['#options'] = $weights;
       $element += $element_info_manager->getInfo('select');
+      Select::processSelect($element, $form_state, $complete_form);
     }
     // Otherwise, use a text field.
     else {
diff --git a/core/modules/system/templates/optgroup.html.twig b/core/modules/system/templates/optgroup.html.twig
new file mode 100644
index 0000000..1efe368
--- /dev/null
+++ b/core/modules/system/templates/optgroup.html.twig
@@ -0,0 +1,13 @@
+{#
+/**
+ * @file
+ * Default theme implementation for an optgroup element.
+ *
+ * Available variables:
+ * - attributes: HTML attributes for the optgroup tag.
+ * - options: The option element children.
+ *
+ * @ingroup themeable
+ */
+#}
+<optgroup{{ attributes }}>{{ options }}</optgroup>
diff --git a/core/modules/system/templates/option.html.twig b/core/modules/system/templates/option.html.twig
new file mode 100644
index 0000000..d795da9
--- /dev/null
+++ b/core/modules/system/templates/option.html.twig
@@ -0,0 +1,13 @@
+{#
+/**
+ * @file
+ * Default theme implementation for an option element.
+ *
+ * Available variables:
+ * - attributes: HTML attributes for the option tag.
+ * - label: The option label.
+ *
+ * @ingroup themeable
+ */
+#}
+<option{{ attributes }}>{{ label }}</option>
