diff --git a/core/core.libraries.yml b/core/core.libraries.yml
index 67ced51148..e37f474796 100644
--- a/core/core.libraries.yml
+++ b/core/core.libraries.yml
@@ -245,6 +245,21 @@ drupal.progress:
     - core/jquery
     - core/drupalSettings
 
+drupal.splitbutton:
+  version: VERSION
+  js:
+    misc/splitbutton/splitbutton.js: {}
+    misc/splitbutton/splitbutton-init.js: {}
+  css:
+    component:
+      misc/splitbutton/splitbutton.css: {}
+  dependencies:
+    - core/drupal
+    - core/popperjs
+    - core/jquery
+    - core/jquery.once
+
+
 drupal.states:
   version: VERSION
   js:
diff --git a/core/includes/theme.inc b/core/includes/theme.inc
index 5998a235b4..0549efe82e 100644
--- a/core/includes/theme.inc
+++ b/core/includes/theme.inc
@@ -630,6 +630,35 @@ function template_preprocess_datetime_wrapper(&$variables) {
   $variables['content'] = $element['#children'];
 }
 
+/**
+ * Prepares variables for an operation list template.
+ *
+ * Default template: operation-list.html.twig.
+ *
+ * @param array $variables
+ *   An associative array containing:
+ *   - operations: An array of render elements that will be displayed in a list.
+ *     This element can be a submit, button, or link render element. All other
+ *     items will be filtered from the array.
+ *   - attributes: A keyed array of attributes for the <ul> containing the list
+ *     of operations.
+ *   - list_item_attributes: A keyed array of attributes for the <li> containing
+ *     each operation.
+ */
+function template_preprocess_operation_list(array &$variables) {
+  $operations = $variables['operations'];
+  $variables['list_item_attributes'] = new Attribute($variables['list_item_attributes']);
+
+  if (!empty($operations)) {
+    $elements = ['submit', 'button', 'link'];
+    foreach ($operations as $key => $operation) {
+      if (isset($operation['#type']) && in_array($operation['#type'], $elements)) {
+        $variables['items'][] = $operation;
+      }
+    }
+  }
+}
+
 /**
  * Prepares variables for links templates.
  *
@@ -2008,6 +2037,9 @@ function drupal_common_theme() {
     'links' => [
       'variables' => ['links' => [], 'attributes' => ['class' => ['links']], 'heading' => [], 'set_active_class' => FALSE],
     ],
+    'operation_list' => [
+      'variables' => ['attributes' => [], 'operations' => NULL, '#theme' => 'operation_list', 'list_item_attributes' => []],
+    ],
     'dropbutton_wrapper' => [
       'variables' => ['children' => NULL],
     ],
diff --git a/core/lib/Drupal/Core/Render/Element/OperationList.php b/core/lib/Drupal/Core/Render/Element/OperationList.php
new file mode 100644
index 0000000000..7a2f6027ce
--- /dev/null
+++ b/core/lib/Drupal/Core/Render/Element/OperationList.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Drupal\Core\Render\Element;
+
+/**
+ * A list of links, buttons and submit elements.
+ *
+ * @RenderElement("operation_list")
+ */
+class OperationList extends RenderElement {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getInfo() {
+    return [
+      '#theme' => 'operation_list',
+      '#operations' => [],
+    ];
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Render/Element/Splitbutton.php b/core/lib/Drupal/Core/Render/Element/Splitbutton.php
new file mode 100644
index 0000000000..341dc0f174
--- /dev/null
+++ b/core/lib/Drupal/Core/Render/Element/Splitbutton.php
@@ -0,0 +1,195 @@
+<?php
+
+namespace Drupal\Core\Render\Element;
+
+use Drupal\Component\Utility\Html;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * Provides a form element for a toggleable menu button.
+ *
+ * Properties:
+ * - #splitbutton_items: Items that will be themed as an operations_list. This
+ *   can include submit, link and button. All other elements will be filtered
+ *   out.
+ * - #splitbutton_type: A string or an array or strings defining a type of
+ *   dropbutton variant for styling purposes. This adds the class
+ *   `splitbutton--#splitbutton_type` to the splitbutton wrapper and the class
+ *   `button--#splitbutton_type` to the primary and toggle buttons.
+ * - #title: This changes the default splitbutton behavior of displaying a
+ *   primary splitbutton item next a separate toggle button. When this property
+ *   is present, there is no primary item, just a toggle.
+ *
+ * Deprecated Properties:
+ * - #links: An array of links to actions. See template_preprocess_links() for
+ *   documentation the properties of links in this array. This property exists
+ *   so dropbuttons can easily be converted to splitbuttons. New splitbuttons
+ *   should not use this property, and it will be removed in Drupal 10.
+ * - #dropbutton_type: The value is copied or appended to #splitbutton_type.
+ *   This will be removed in Drupal 10.
+ *
+ * @RenderElement("splitbutton")
+ */
+class Splitbutton extends RenderElement {
+
+  use StringTranslationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getInfo() {
+    $class = get_class($this);
+    return [
+      '#pre_render' => [
+        [$class, 'preRenderSplitbutton'],
+      ],
+      '#theme_wrappers' => [
+        'container' => [
+          '#attributes' => [
+            'class' => ['splitbutton' , 'js-splitbutton'],
+          ],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Pre-render callback. Builds Splitbutton render array.
+   *
+   * @param array $element
+   *   The render element.
+   *
+   * @return array
+   *   Render array.
+   */
+  public static function preRenderSplitbutton(array $element) {
+    $element['#attached']['library'][] = 'core/drupal.splitbutton';
+
+    if (isset($element['#attributes'])) {
+      $element['#theme_wrappers']['container']['#attributes'] += $element['#attributes'];
+    }
+
+    $splitbutton_types = [];
+
+    if (!empty($element['#splitbutton_type'])) {
+      // If #splitbutton_type exists and it is a string, place it in an array.
+      $splitbutton_types = is_array($element['#splitbutton_type']) ? $element['#splitbutton_type'] : [$element['#splitbutton_type']];
+    };
+
+    if (!empty($element['#dropbutton_type'])) {
+      @trigger_error("The #dropbutton_type property in splitbutton is deprecated in drupal:9.1.0 and is removed from drupal:10.0.0.", E_USER_DEPRECATED);
+      $splitbutton_types[] = $element['#dropbutton_type'];
+    }
+
+    // Add modifier class to wrapper.
+    foreach ($splitbutton_types as $container_splitbutton_type) {
+      $element['#theme_wrappers']['container']['#attributes']['class'][] = 'splitbutton--' . $container_splitbutton_type;
+    }
+
+    $items = $element['#splitbutton_items'] ?? [];
+
+    if (isset($element['#links'])) {
+      @trigger_error("The #links property in splitbutton is deprecated in drupal:9.1.0 and is removed from drupal:10.0.0.", E_USER_DEPRECATED);
+
+      foreach ($element['#links'] as &$op) {
+        if (isset($op['url']) && isset($op['title'])) {
+          $op['#url'] = $op['url'];
+          unset($op['url']);
+          $op['#title'] = $op['title'];
+          unset($op['title']);
+          $op['#type'] = 'link';
+        }
+      }
+      $items += $element['#links'];
+    }
+
+    $element['trigger'] = [
+      '#type' => 'container',
+      '#attributes' => [
+        'class' => [
+          'splitbutton__main',
+        ],
+      ],
+    ];
+
+    $trigger_id = Html::getUniqueId('splitbutton');
+
+    $toggle_element = [
+      '#type' => 'html_tag',
+      '#tag' => 'button',
+      '#attributes' => [
+        'class' => ['button', 'splitbutton__toggle'],
+        'type' => 'button',
+        'role' => 'button',
+        'aria-haspopup' => 'true',
+        'aria-controls' => "$trigger_id-menu",
+        'aria-expanded' => 'false',
+        'id' => "$trigger_id-trigger",
+        'data-splitbutton-trigger' => $trigger_id,
+      ],
+    ];
+
+    // If the #title property is present, Splitbutton has a single main button
+    // that toggles the operations list instead of a link/button accompanied by
+    // a separate toggle.
+    if (isset($element['#title'])) {
+      $element['trigger']['title'] = $toggle_element;
+      $element['trigger']['title']['#attributes']['class'][] = 'splitbutton__toggle--with-title';
+      $element['trigger']['title']['value']['#markup'] = $element['#title'];
+    }
+    else {
+      $first_item = array_shift($items);
+      $element['trigger']['title'] = $first_item;
+      $element['trigger']['title']['#attributes']['class'][] = 'button';
+      $element['trigger']['title']['#attributes']['class'][] = 'splitbutton__main-button';
+      $element['trigger']['title']['#attributes']['class'][] = 'splitbutton__main-button--' . $first_item['#type'];
+
+      // If there are additional items, add a toggle for their visibility.
+      if (count($items)) {
+        $element['trigger']['toggle'] = $toggle_element;
+        $element['trigger']['toggle']['#value'] = '';
+        $element['trigger']['toggle']['#attributes']['class'][] = 'splitbutton__toggle--no-title';
+        $element['trigger']['toggle']['#attributes']['aria-label'] = t('List additional actions');
+        foreach ($splitbutton_types as $toggle_splitbutton_type) {
+          $element['trigger']['toggle']['#attributes']['class'][] = 'button--' . $toggle_splitbutton_type;
+        }
+      }
+    }
+
+    // Add modifier classes based on #splitbutton_type to the main button.
+    foreach ($splitbutton_types as $main_splitbutton_type) {
+      $element['trigger']['title']['#attributes']['class'][] = 'button--' . $main_splitbutton_type;
+    }
+
+    // If additional items are present, place them in an operation list.
+    if (count($items)) {
+      $element['#theme_wrappers']['container']['#attributes']['class'][] = 'splitbutton--multiple';
+      $element['#theme_wrappers']['container']['#attributes']['class'][] = 'js-splitbutton-multiple';
+      foreach ($items as &$item) {
+        $item['#attributes']['class'][] = 'splitbutton__operation-list-item';
+        $item['#attributes']['role'] = 'menuitem';
+        $item['#attributes']['tabindex'] = '-1';
+      }
+      $element['operation_list'] = [
+        '#operations' => $items,
+        '#theme' => 'operation_list',
+        '#list_item_attributes' => [
+          'role' => 'none',
+        ],
+        '#attributes' => [
+          'class' => ['splitbutton__operation-list'],
+          'data-splitbutton-target' => $trigger_id,
+          'role' => 'menu',
+          'aria-labelledby' => "$trigger_id-trigger",
+          'id' => "$trigger_id-menu",
+        ],
+      ];
+    }
+    else {
+      $element['#theme_wrappers']['container']['#attributes']['class'][] = 'splitbutton--single';
+    }
+
+    return $element;
+  }
+
+}
diff --git a/core/misc/splitbutton/splitbutton-init.es6.js b/core/misc/splitbutton/splitbutton-init.es6.js
new file mode 100644
index 0000000000..97ad2cf4b6
--- /dev/null
+++ b/core/misc/splitbutton/splitbutton-init.es6.js
@@ -0,0 +1,29 @@
+/**
+ * @file
+ * Splitbutton initialization.
+ */
+
+(($, Drupal) => {
+  /**
+   * Process elements with the .splitbutton class on page load.
+   *
+   * @type {Drupal~behavior}
+   *
+   * @prop {Drupal~behaviorAttach} attach
+   *   Attaches splitButton behaviors.
+   */
+  Drupal.behaviors.splitButton = {
+    attach(context) {
+      const $splitbuttons = $(context)
+        .find('.js-splitbutton-multiple')
+        .once('splitbutton');
+      if ($splitbuttons.length) {
+        for (let i = 0; i < $splitbuttons.length; i++) {
+          Drupal.splitbuttons.push(new Drupal.SplitButton($splitbuttons[i]));
+        }
+      }
+    },
+  };
+
+  Drupal.splitbuttons = [];
+})(jQuery, Drupal);
diff --git a/core/misc/splitbutton/splitbutton-init.js b/core/misc/splitbutton/splitbutton-init.js
new file mode 100644
index 0000000000..5824d774c4
--- /dev/null
+++ b/core/misc/splitbutton/splitbutton-init.js
@@ -0,0 +1,21 @@
+/**
+* DO NOT EDIT THIS FILE.
+* See the following change record for more information,
+* https://www.drupal.org/node/2815083
+* @preserve
+**/
+
+(function ($, Drupal) {
+  Drupal.behaviors.splitButton = {
+    attach: function attach(context) {
+      var $splitbuttons = $(context).find('.js-splitbutton-multiple').once('splitbutton');
+
+      if ($splitbuttons.length) {
+        for (var i = 0; i < $splitbuttons.length; i++) {
+          Drupal.splitbuttons.push(new Drupal.SplitButton($splitbuttons[i]));
+        }
+      }
+    }
+  };
+  Drupal.splitbuttons = [];
+})(jQuery, Drupal);
\ No newline at end of file
diff --git a/core/misc/splitbutton/splitbutton.css b/core/misc/splitbutton/splitbutton.css
new file mode 100644
index 0000000000..bb976aa577
--- /dev/null
+++ b/core/misc/splitbutton/splitbutton.css
@@ -0,0 +1,41 @@
+.splitbutton {
+  box-sizing: border-box;
+}
+
+.splitbutton__operation-list {
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.splitbutton__main {
+  position: relative;
+  display: inline-flex;
+  font-size: 0.889rem;
+}
+
+.splitbutton__main .button {
+  margin: 0;
+}
+
+.js .splitbutton__operation-list {
+  display: none;
+}
+.js .splitbutton--enabled.open .splitbutton__operation-list {
+  z-index: 4;
+  display: block;
+}
+
+.splitbutton__operation-list-item {
+  display: block;
+  padding: 0;
+  border: none;
+  background: #fff;
+}
+.splitbutton__operation-list-item:hover {
+  text-decoration: none;
+}
+
+html:not(.js) .splitbutton__toggle {
+  display: none;
+}
diff --git a/core/misc/splitbutton/splitbutton.es6.js b/core/misc/splitbutton/splitbutton.es6.js
new file mode 100644
index 0000000000..7bdde8ec09
--- /dev/null
+++ b/core/misc/splitbutton/splitbutton.es6.js
@@ -0,0 +1,355 @@
+/**
+ * @file
+ * Splitbutton feature.
+ */
+
+((Drupal, Popper) => {
+  Drupal.SplitButton = class {
+    constructor(splitbutton) {
+      splitbutton.classList.add(
+        'splitbutton--enabled',
+        'js-splitbutton-enabled',
+      );
+
+      this.keyCode = Object.freeze({
+        TAB: 9,
+        RETURN: 13,
+        ESC: 27,
+        SPACE: 32,
+        PAGEUP: 33,
+        PAGEDOWN: 34,
+        END: 35,
+        HOME: 36,
+        LEFT: 37,
+        UP: 38,
+        RIGHT: 39,
+        DOWN: 40,
+      });
+      this.menuItems = [];
+      this.firstChars = [];
+
+      this.splitbutton = splitbutton;
+      this.trigger = splitbutton.querySelector('[data-splitbutton-trigger]');
+      this.menu = splitbutton.querySelector('[data-splitbutton-target]');
+      this.triggerContainer = splitbutton.querySelector('.splitbutton__main');
+
+      this.splitbutton.addEventListener('mouseenter', () => this.activeIn());
+      this.splitbutton.addEventListener('focusin', () => this.activeIn());
+      this.splitbutton.addEventListener('mouseleave', () => this.hoverOut());
+      this.splitbutton.addEventListener('focusout', () => this.focusOut());
+      this.splitbutton.addEventListener('keydown', e => this.keydown(e));
+      this.trigger.addEventListener('click', e => this.clickToggle(e));
+    }
+
+    /**
+     * Populate instance variables that facilitate keyboard navigation.
+     */
+    initMenuItems() {
+      // If this.menuItems is empty, the initialization hasn't occurred yet.
+      if (this.menuItems.length === 0) {
+        Array.prototype.slice
+          .call(this.menu.querySelectorAll('input, a, button'))
+          .forEach((item, index) => {
+            // Add attribute to each item to identify its focus order.
+            item.setAttribute('data-splitbutton-item', index);
+            this.menuItems.push(item);
+
+            const itemText =
+              item.tagName === 'A'
+                ? item.textContent
+                : item.getAttribute('value');
+
+            // Store the first character of each item, used for selection by
+            // typing a character.
+            this.firstChars.push(
+              itemText
+                .trim()
+                .substring(0, 1)
+                .toLowerCase(),
+            );
+            this.lastItemIndex = index;
+          });
+      }
+    }
+
+    /**
+     * Initialize positioning of items with PopperJS.
+     */
+    initPopper() {
+      // The items width should be at least as wide as the element that
+      // triggered their visibility.
+      this.menu.style['min-width'] = `${this.triggerContainer.offsetWidth}px`;
+
+      this.popper = Popper.createPopper(this.triggerContainer, this.menu, {
+        placement: 'bottom-start',
+        modifiers: [
+          {
+            name: 'flip',
+            options: {
+              fallbackPlacements: [],
+            },
+          },
+        ],
+      });
+    }
+
+    /**
+     * Toggle button click listener.
+     *
+     * @param {Event} e
+     *   The click event.
+     */
+    clickToggle(e) {
+      e.preventDefault();
+      e.stopPropagation();
+      const state = this.splitbutton.classList.contains('open')
+        ? 'close'
+        : 'open';
+      this[state]();
+    }
+
+    /**
+     * Toggles visibility of menu items
+     *
+     * @param {boolean} show
+     *   Force visibility based on this value.
+     */
+    toggle(show) {
+      const isBool = typeof show === 'boolean';
+      show = isBool ? show : !this.splitbutton.classList.contains('open');
+      const expanded = show ? 'true' : 'false';
+      this.splitbutton.classList.toggle('open', show);
+      this.trigger.setAttribute('aria-expanded', expanded);
+    }
+
+    /**
+     * Opens splitbutton menu.
+     */
+    open() {
+      this.initMenuItems();
+      this.toggle(true);
+      if (!this.hasOwnProperty('popper')) {
+        this.initPopper();
+      } else {
+        this.popper.forceUpdate();
+      }
+      // Wrap in a zero-wait timeout to ensure it isn't called until
+      // initMenuItems() completes.
+      setTimeout(() => this.focusFirst(), 0);
+    }
+
+    /**
+     * Closes splitbutton menu.
+     */
+    close() {
+      this.toggle(false);
+    }
+
+    /**
+     * Event listener for hover and focus in.
+     */
+    activeIn() {
+      // Clear any previous timer we were using.
+      if (this.timerID) {
+        window.clearTimeout(this.timerID);
+      }
+    }
+
+    /**
+     * Event listener for hover and focus out.
+     */
+    hoverOut() {
+      // Wait half a second before closing.
+      this.timerID = window.setTimeout(() => this.toggle(false), 500);
+    }
+
+    focusOut() {
+      // Provide a brief timeout before closing to prevent flickering.
+      this.timerID = window.setTimeout(() => this.toggle(false), 50);
+    }
+
+    /**
+     * Keydown listener.
+     *
+     * @param {Event} e
+     *   The keydown event.
+     */
+    keydown(e) {
+      let preventDefault = true;
+      const char = e.key;
+
+      if (
+        e.ctrlKey ||
+        e.altKey ||
+        e.metaKey ||
+        e.keyCode === this.keyCode.SPACE ||
+        e.keyCode === this.keyCode.RETURN ||
+        (e.keyCode === this.keyCode.TAB &&
+          e.target.getAttribute('data-splitbutton-item') === null)
+      ) {
+        return;
+      }
+
+      switch (e.keyCode) {
+        case this.keyCode.ESC:
+          this.focusTrigger();
+          this.close();
+          break;
+
+        case this.keyCode.UP:
+          if (this.splitbutton.classList.contains('open')) {
+            this.focusPrev(e);
+          } else {
+            this.open();
+            this.focusLast();
+          }
+          break;
+
+        case this.keyCode.DOWN:
+          this.focusNext(e);
+          if (this.splitbutton.classList.contains('open')) {
+            this.focusNext(e);
+          } else {
+            this.open();
+            this.focusFirst();
+          }
+          break;
+
+        case this.keyCode.HOME:
+        case this.keyCode.PAGEUP:
+          this.focusFirst();
+          break;
+
+        case this.keyCode.END:
+        case this.keyCode.PAGEDOWN:
+          this.focusLast();
+          break;
+
+        case this.keyCode.TAB:
+          this.focusTrigger();
+          this.close(true);
+          break;
+
+        default:
+          preventDefault = false;
+          if (
+            char.length === 1 &&
+            char.match(/\S/) &&
+            this.splitbutton.classList.contains('open')
+          ) {
+            this.setFocusByFirstCharacter(e, char.toLowerCase());
+          }
+          break;
+      }
+
+      if (preventDefault) {
+        e.stopPropagation();
+        e.preventDefault();
+      }
+    }
+
+    /**
+     * Assigns focus to the next menu element.
+     *
+     * @param {Event} e
+     *   A keydown event.
+     */
+    focusNext(e) {
+      const currentItem = e.target.getAttribute('data-splitbutton-item');
+      if (currentItem === null) {
+        this.menuItems[0].focus();
+      } else {
+        const nextIndex = parseInt(currentItem, 10) + 1;
+        const focusIndex = nextIndex > this.lastItemIndex ? 0 : nextIndex;
+        this.menuItems[focusIndex].focus();
+      }
+    }
+
+    /**
+     * Assigns focus to the previous menu element.
+     *
+     * @param {Event} e
+     *   A keydown event.
+     */
+    focusPrev(e) {
+      const currentItem = e.target.getAttribute('data-splitbutton-item');
+      if (currentItem === null) {
+        this.menuItems[this.lastItemIndex].focus();
+      } else {
+        const prevIndex = parseInt(currentItem, 10) - 1;
+        const focusIndex = prevIndex < 0 ? this.lastItemIndex : prevIndex;
+        this.menuItems[focusIndex].focus();
+      }
+    }
+
+    /**
+     * Assigns focus to the trigger element.
+     */
+    focusTrigger() {
+      this.trigger.focus();
+    }
+
+    /**
+     * Assigns focus to the first menu item.
+     */
+    focusFirst() {
+      this.menuItems[0].focus();
+    }
+
+    /**
+     * Assigns focus to the last menu item.
+     */
+    focusLast() {
+      this.menuItems[this.lastItemIndex].focus();
+    }
+
+    /**
+     * Assigns focus based on characters in menu items.
+     *
+     * @param {Event} e
+     *   A keydown event.
+     * @param {string} char
+     *   The character being searched for
+     */
+    setFocusByFirstCharacter(e, char) {
+      const currentItem = e.target.getAttribute('data-splitbutton-item');
+      // Get start index for search based on position of current item.
+      let start = currentItem === null ? parseInt(currentItem, 10) + 1 : 0;
+      if (start === this.menuItems.length) {
+        start = 0;
+      }
+
+      // Check remaining slots in the menu.
+      let index = this.getIndexFirstChars(start, char);
+
+      // If not found in remaining slots, check from beginning.
+      if (index === -1) {
+        index = this.getIndexFirstChars(0, char);
+      }
+
+      if (index > -1) {
+        this.menuItems[index].focus();
+      }
+    }
+
+    /**
+     * Returns the index of a menu item beginning with char.
+     *
+     * @param {number} startIndex
+     *   The menu item index to begin searching with.
+     * @param {string} char
+     *   The character being searched for.
+     *
+     * @return {number}
+     *   The index of the first matching menu item.
+     */
+    getIndexFirstChars(startIndex, char) {
+      for (let i = startIndex; i < this.firstChars.length; i++) {
+        if (char === this.firstChars[i]) {
+          return i;
+        }
+      }
+      return -1;
+    }
+  };
+})(Drupal, Popper);
diff --git a/core/misc/splitbutton/splitbutton.js b/core/misc/splitbutton/splitbutton.js
new file mode 100644
index 0000000000..8ff26f37dd
--- /dev/null
+++ b/core/misc/splitbutton/splitbutton.js
@@ -0,0 +1,304 @@
+/**
+* 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, Popper) {
+  Drupal.SplitButton = function () {
+    function _class(splitbutton) {
+      var _this = this;
+
+      _classCallCheck(this, _class);
+
+      splitbutton.classList.add('splitbutton--enabled', 'js-splitbutton-enabled');
+      this.keyCode = Object.freeze({
+        TAB: 9,
+        RETURN: 13,
+        ESC: 27,
+        SPACE: 32,
+        PAGEUP: 33,
+        PAGEDOWN: 34,
+        END: 35,
+        HOME: 36,
+        LEFT: 37,
+        UP: 38,
+        RIGHT: 39,
+        DOWN: 40
+      });
+      this.menuItems = [];
+      this.firstChars = [];
+      this.splitbutton = splitbutton;
+      this.trigger = splitbutton.querySelector('[data-splitbutton-trigger]');
+      this.menu = splitbutton.querySelector('[data-splitbutton-target]');
+      this.triggerContainer = splitbutton.querySelector('.splitbutton__main');
+      this.splitbutton.addEventListener('mouseenter', function () {
+        return _this.activeIn();
+      });
+      this.splitbutton.addEventListener('focusin', function () {
+        return _this.activeIn();
+      });
+      this.splitbutton.addEventListener('mouseleave', function () {
+        return _this.hoverOut();
+      });
+      this.splitbutton.addEventListener('focusout', function () {
+        return _this.focusOut();
+      });
+      this.splitbutton.addEventListener('keydown', function (e) {
+        return _this.keydown(e);
+      });
+      this.trigger.addEventListener('click', function (e) {
+        return _this.clickToggle(e);
+      });
+    }
+
+    _createClass(_class, [{
+      key: "initMenuItems",
+      value: function initMenuItems() {
+        var _this2 = this;
+
+        if (this.menuItems.length === 0) {
+          Array.prototype.slice.call(this.menu.querySelectorAll('input, a, button')).forEach(function (item, index) {
+            item.setAttribute('data-splitbutton-item', index);
+
+            _this2.menuItems.push(item);
+
+            var itemText = item.tagName === 'A' ? item.textContent : item.getAttribute('value');
+
+            _this2.firstChars.push(itemText.trim().substring(0, 1).toLowerCase());
+
+            _this2.lastItemIndex = index;
+          });
+        }
+      }
+    }, {
+      key: "initPopper",
+      value: function initPopper() {
+        this.menu.style['min-width'] = "".concat(this.triggerContainer.offsetWidth, "px");
+        this.popper = Popper.createPopper(this.triggerContainer, this.menu, {
+          placement: 'bottom-start',
+          modifiers: [{
+            name: 'flip',
+            options: {
+              fallbackPlacements: []
+            }
+          }]
+        });
+      }
+    }, {
+      key: "clickToggle",
+      value: function clickToggle(e) {
+        e.preventDefault();
+        e.stopPropagation();
+        var state = this.splitbutton.classList.contains('open') ? 'close' : 'open';
+        this[state]();
+      }
+    }, {
+      key: "toggle",
+      value: function toggle(show) {
+        var isBool = typeof show === 'boolean';
+        show = isBool ? show : !this.splitbutton.classList.contains('open');
+        var expanded = show ? 'true' : 'false';
+        this.splitbutton.classList.toggle('open', show);
+        this.trigger.setAttribute('aria-expanded', expanded);
+      }
+    }, {
+      key: "open",
+      value: function open() {
+        var _this3 = this;
+
+        this.initMenuItems();
+        this.toggle(true);
+
+        if (!this.hasOwnProperty('popper')) {
+          this.initPopper();
+        } else {
+          this.popper.forceUpdate();
+        }
+
+        setTimeout(function () {
+          return _this3.focusFirst();
+        }, 0);
+      }
+    }, {
+      key: "close",
+      value: function close() {
+        this.toggle(false);
+      }
+    }, {
+      key: "activeIn",
+      value: function activeIn() {
+        if (this.timerID) {
+          window.clearTimeout(this.timerID);
+        }
+      }
+    }, {
+      key: "hoverOut",
+      value: function hoverOut() {
+        var _this4 = this;
+
+        this.timerID = window.setTimeout(function () {
+          return _this4.toggle(false);
+        }, 500);
+      }
+    }, {
+      key: "focusOut",
+      value: function focusOut() {
+        var _this5 = this;
+
+        this.timerID = window.setTimeout(function () {
+          return _this5.toggle(false);
+        }, 50);
+      }
+    }, {
+      key: "keydown",
+      value: function keydown(e) {
+        var preventDefault = true;
+        var char = e.key;
+
+        if (e.ctrlKey || e.altKey || e.metaKey || e.keyCode === this.keyCode.SPACE || e.keyCode === this.keyCode.RETURN || e.keyCode === this.keyCode.TAB && e.target.getAttribute('data-splitbutton-item') === null) {
+          return;
+        }
+
+        switch (e.keyCode) {
+          case this.keyCode.ESC:
+            this.focusTrigger();
+            this.close();
+            break;
+
+          case this.keyCode.UP:
+            if (this.splitbutton.classList.contains('open')) {
+              this.focusPrev(e);
+            } else {
+              this.open();
+              this.focusLast();
+            }
+
+            break;
+
+          case this.keyCode.DOWN:
+            this.focusNext(e);
+
+            if (this.splitbutton.classList.contains('open')) {
+              this.focusNext(e);
+            } else {
+              this.open();
+              this.focusFirst();
+            }
+
+            break;
+
+          case this.keyCode.HOME:
+          case this.keyCode.PAGEUP:
+            this.focusFirst();
+            break;
+
+          case this.keyCode.END:
+          case this.keyCode.PAGEDOWN:
+            this.focusLast();
+            break;
+
+          case this.keyCode.TAB:
+            this.focusTrigger();
+            this.close(true);
+            break;
+
+          default:
+            preventDefault = false;
+
+            if (char.length === 1 && char.match(/\S/) && this.splitbutton.classList.contains('open')) {
+              this.setFocusByFirstCharacter(e, char.toLowerCase());
+            }
+
+            break;
+        }
+
+        if (preventDefault) {
+          e.stopPropagation();
+          e.preventDefault();
+        }
+      }
+    }, {
+      key: "focusNext",
+      value: function focusNext(e) {
+        var currentItem = e.target.getAttribute('data-splitbutton-item');
+
+        if (currentItem === null) {
+          this.menuItems[0].focus();
+        } else {
+          var nextIndex = parseInt(currentItem, 10) + 1;
+          var focusIndex = nextIndex > this.lastItemIndex ? 0 : nextIndex;
+          this.menuItems[focusIndex].focus();
+        }
+      }
+    }, {
+      key: "focusPrev",
+      value: function focusPrev(e) {
+        var currentItem = e.target.getAttribute('data-splitbutton-item');
+
+        if (currentItem === null) {
+          this.menuItems[this.lastItemIndex].focus();
+        } else {
+          var prevIndex = parseInt(currentItem, 10) - 1;
+          var focusIndex = prevIndex < 0 ? this.lastItemIndex : prevIndex;
+          this.menuItems[focusIndex].focus();
+        }
+      }
+    }, {
+      key: "focusTrigger",
+      value: function focusTrigger() {
+        this.trigger.focus();
+      }
+    }, {
+      key: "focusFirst",
+      value: function focusFirst() {
+        this.menuItems[0].focus();
+      }
+    }, {
+      key: "focusLast",
+      value: function focusLast() {
+        this.menuItems[this.lastItemIndex].focus();
+      }
+    }, {
+      key: "setFocusByFirstCharacter",
+      value: function setFocusByFirstCharacter(e, char) {
+        var currentItem = e.target.getAttribute('data-splitbutton-item');
+        var start = currentItem === null ? parseInt(currentItem, 10) + 1 : 0;
+
+        if (start === this.menuItems.length) {
+          start = 0;
+        }
+
+        var index = this.getIndexFirstChars(start, char);
+
+        if (index === -1) {
+          index = this.getIndexFirstChars(0, char);
+        }
+
+        if (index > -1) {
+          this.menuItems[index].focus();
+        }
+      }
+    }, {
+      key: "getIndexFirstChars",
+      value: function getIndexFirstChars(startIndex, char) {
+        for (var i = startIndex; i < this.firstChars.length; i++) {
+          if (char === this.firstChars[i]) {
+            return i;
+          }
+        }
+
+        return -1;
+      }
+    }]);
+
+    return _class;
+  }();
+})(Drupal, Popper);
\ No newline at end of file
diff --git a/core/modules/system/templates/operation-list.html.twig b/core/modules/system/templates/operation-list.html.twig
new file mode 100644
index 0000000000..255dbecc29
--- /dev/null
+++ b/core/modules/system/templates/operation-list.html.twig
@@ -0,0 +1,21 @@
+{#
+/**
+ * @file
+ * Default theme implementation for an operation list.
+ *
+ * Available variables:
+ * - attributes: Attributes for the <ul> tag.
+ * - items: items to output. Can be a link, button or submit element.
+ * - list_item_attributes: Attributes for each <li>.
+ *
+ * @see template_preprocess_operation_list()
+ *
+ * @ingroup themeable
+ */
+#}{%- if items -%}
+    <ul {{ attributes }}>
+        {%- for item in items -%}
+            <li {{ list_item_attributes }}>{{ item }}</li>
+        {%- endfor -%}
+    </ul>
+{%- endif -%}
diff --git a/core/modules/system/tests/modules/splitbutton_test/splitbutton_test.info.yml b/core/modules/system/tests/modules/splitbutton_test/splitbutton_test.info.yml
new file mode 100644
index 0000000000..ec9dbcc87d
--- /dev/null
+++ b/core/modules/system/tests/modules/splitbutton_test/splitbutton_test.info.yml
@@ -0,0 +1,5 @@
+name: 'Splitbutton Test'
+type: module
+description: 'For testing splitbuttons'
+core_version_requirement: ^9
+package: Testing
diff --git a/core/modules/system/tests/modules/splitbutton_test/splitbutton_test.routing.yml b/core/modules/system/tests/modules/splitbutton_test/splitbutton_test.routing.yml
new file mode 100644
index 0000000000..01ab66049b
--- /dev/null
+++ b/core/modules/system/tests/modules/splitbutton_test/splitbutton_test.routing.yml
@@ -0,0 +1,44 @@
+splitbutton.test:
+  path: '/splitbuttons'
+  defaults:
+    _form: '\Drupal\splitbutton_test\Form\SplitbuttonTestForm'
+    _title: 'Splitbutton Test'
+    disabled: false
+  requirements:
+    _permission: 'access content'
+
+splitbutton.test_link_1:
+  path: '/splitbuttons-test-link-1'
+  defaults:
+    _form: '\Drupal\splitbutton_test\Form\SplitbuttonTestForm'
+    _title: 'Splitbutton Test'
+    disabled: false
+  requirements:
+    _permission: 'access content'
+
+splitbutton.test_link_2:
+  path: '/splitbuttons-test-link-2'
+  defaults:
+    _form: '\Drupal\splitbutton_test\Form\SplitbuttonTestForm'
+    _title: 'Splitbutton Test'
+    disabled: false
+  requirements:
+    _permission: 'access content'
+
+splitbutton.test_link_3:
+  path: '/splitbuttons-test-link-3'
+  defaults:
+    _form: '\Drupal\splitbutton_test\Form\SplitbuttonTestForm'
+    _title: 'Splitbutton Test'
+    disabled: false
+  requirements:
+    _permission: 'access content'
+
+splitbutton.test_link_4:
+  path: '/splitbuttons-test-link-4'
+  defaults:
+    _form: '\Drupal\splitbutton_test\Form\SplitbuttonTestForm'
+    _title: 'Splitbutton Test'
+    disabled: false
+  requirements:
+    _permission: 'access content'
\ No newline at end of file
diff --git a/core/modules/system/tests/modules/splitbutton_test/src/Form/SplitbuttonTestForm.php b/core/modules/system/tests/modules/splitbutton_test/src/Form/SplitbuttonTestForm.php
new file mode 100644
index 0000000000..9d2b826304
--- /dev/null
+++ b/core/modules/system/tests/modules/splitbutton_test/src/Form/SplitbuttonTestForm.php
@@ -0,0 +1,259 @@
+<?php
+
+namespace Drupal\splitbutton_test\Form;
+
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Url;
+
+/**
+ * Form for testing splitbuttons.
+ */
+class SplitbuttonTestForm extends FormBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'splitbutton_test_form';
+  }
+
+  /**
+   * Returns a renderable array for a test page.
+   */
+  public function buildForm(array $form, FormStateInterface $form_state, $disabled = FALSE) {
+    $button_types = [
+      'default' => 'Default',
+      'primary' => 'Primary',
+      'danger' => 'Danger',
+      'small' => 'Small',
+      'extrasmall' => 'Extra Small',
+    ];
+
+    $links_dropbutton = [
+      'link_one' => [
+        'title' => $this->t('Link One'),
+        'url' => Url::fromRoute('splitbutton.test_link_1'),
+      ],
+      'link_two' => [
+        'title' => $this->t('Link Two'),
+        'url' => Url::fromRoute('splitbutton.test_link_2'),
+      ],
+      'link_three' => [
+        'title' => $this->t('Link Three'),
+        'url' => Url::fromRoute('splitbutton.test_link_3'),
+      ],
+      'link_four' => [
+        'title' => $this->t('Link Four'),
+        'url' => Url::fromRoute('splitbutton.test_link_4'),
+      ],
+    ];
+
+    $links_plus_button = [
+      'link_one' => [
+        '#type' => 'link',
+        '#title' => $this->t('Link One'),
+        '#url' => Url::fromRoute('splitbutton.test_link_1'),
+      ],
+      'link_two' => [
+        '#type' => 'link',
+        '#title' => $this->t('Link Two'),
+        '#url' => Url::fromRoute('splitbutton.test_link_2'),
+      ],
+      'link_three' => [
+        '#type' => 'link',
+        '#title' => $this->t('Link Three'),
+        '#url' => Url::fromRoute('splitbutton.test_link_3'),
+      ],
+      'link_four' => [
+        '#type' => 'link',
+        '#title' => $this->t('Link Four'),
+        '#url' => Url::fromRoute('splitbutton.test_link_4'),
+      ],
+      'added_button' => [
+        '#type' => 'button',
+        '#value' => $this->t('Added Button'),
+      ],
+      'another_added_button' => [
+        '#type' => 'submit',
+        '#value' => $this->t('Another Added Button'),
+      ],
+    ];
+
+    $links_starts_with_button = [
+      'start_button' => [
+        '#type' => 'submit',
+        '#value' => $this->t('Beginning Button'),
+      ],
+    ] + $links_plus_button;
+
+    $scenarios = [
+      'splitbutton_link_first' => [
+        'title' => 'Splitbuttons with link as first item',
+        'element_type' => 'splitbutton',
+        'list' => $links_plus_button,
+      ],
+      'splitbutton_submit_first' => [
+        'title' => 'Splitbuttons with submit as first item',
+        'element_type' => 'splitbutton',
+        'list' => $links_starts_with_button,
+      ],
+      'splitbutton_with_title' => [
+        'title' => 'Splitbuttons where the primary button is just a toggle',
+        'element_type' => 'splitbutton',
+        'list' => $links_plus_button,
+        'splitbutton_title' => 'Toggle Only',
+      ],
+      'dropbutton_converted' => [
+        'title' => 'Dropbuttons converted to Splitbuttons by changing #type',
+        'element_type' => 'splitbutton',
+        'list' => $links_dropbutton,
+        'use_links' => TRUE,
+      ],
+    ];
+
+    foreach ($scenarios as $scenario_key => $scenario) {
+      $form[$scenario_key] = [
+        '#type' => 'container',
+      ];
+      $form[$scenario_key]['title'] = [
+        '#type' => 'item',
+        '#name' => $scenario_key,
+        '#title' => $scenario['title'],
+      ];
+      foreach ($button_types as $button_type => $button_type_label) {
+        $form[$scenario_key][$button_type] = [
+          '#type' => $scenario['element_type'],
+          '#attributes' => [
+            'data-splitbutton-test-id' => "$scenario_key-$button_type",
+          ],
+        ];
+        if (empty($scenario['splitbutton_title'])) {
+          $first_item_key = key($scenario['list']);
+          if (isset($scenario['list'][$first_item_key]['#type'])) {
+            $first_item_type = $scenario['list'][$first_item_key]['#type'] ?? 'link';
+            $label_key = $first_item_type === 'link' ? '#title' : '#value';
+            $scenario['list'][$first_item_key][$label_key] = "$button_type_label - $first_item_type ";
+            if ($first_item_type === 'link') {
+              $scenario['list'][$first_item_key]['#url'] = Url::fromRoute('splitbutton.test', ['prevent_generated_link' => microtime()]);
+            }
+          }
+          else {
+            $scenario['list'][$first_item_key]['title'] = "$button_type_label - link";
+          }
+        }
+        else {
+          $form[$scenario_key][$button_type]['#title'] = "{$scenario['splitbutton_title']} - $button_type_label";
+        }
+
+        if ($scenario['element_type'] === 'splitbutton') {
+          if ($button_type !== 'default') {
+            $form[$scenario_key][$button_type]['#splitbutton_type'] = $button_type;
+          }
+        }
+        else {
+          $form[$scenario_key][$button_type]['#dropbutton_type'] = $button_type;
+        }
+
+        if (!empty($scenario['use_links'])) {
+          $scenario['list'][key($scenario['list'])]['url'] = Url::fromRoute('splitbutton.test', ['prevent_generated_link' => microtime()]);
+          $form[$scenario_key][$button_type]['#links'] = $scenario['list'];
+        }
+        else {
+          $form[$scenario_key][$button_type]['#splitbutton_items'] = $scenario['list'];
+        }
+
+      }
+    }
+
+    $form['combined'] = [
+      '#type' => 'container',
+    ];
+    $form['combined']['title'] = [
+      '#type' => 'item',
+      '#name' => 'combined_types',
+      '#title' => $this->t('Combined types'),
+    ];
+    $form['combined']['primary_small'] = [
+      '#type' => 'splitbutton',
+      '#attributes' => [
+        'data-splitbutton-test-id' => 'splitbutton-primary-small',
+      ],
+      '#splitbutton_type' => [
+        'small',
+        'primary',
+      ],
+      '#splitbutton_items' => [
+        'item1' => [
+          '#type' => 'link',
+          '#title' => $this->t('Small + Primary'),
+          '#url' => Url::fromRoute('splitbutton.test', ['prevent_generated_link' => microtime()]),
+        ],
+      ] + $links_plus_button,
+    ];
+    $form['combined']['danger_extrasmall'] = [
+      '#type' => 'splitbutton',
+      '#attributes' => [
+        'data-splitbutton-test-id' => 'splitbutton-danger-extrasmall',
+      ],
+      '#splitbutton_type' => [
+        'extrasmall',
+        'danger',
+      ],
+      '#splitbutton_items' => [
+        'item1' => [
+          '#type' => 'link',
+          '#title' => $this->t('Extrasmall + danger'),
+          '#url' => Url::fromRoute('splitbutton.test', ['prevent_generated_link' => microtime()]),
+        ],
+      ] + $links_plus_button,
+    ];
+
+    $form['single_items'] = [
+      '#type' => 'container',
+    ];
+    $form['single_items']['title'] = [
+      '#type' => 'item',
+      '#name' => 'single_items',
+      '#title' => $this->t('Single item splitbuttons'),
+    ];
+    $form['single_items']['primary_small'] = [
+      '#type' => 'splitbutton',
+      '#attributes' => [
+        'data-splitbutton-test-id' => 'splitbutton-single-default',
+      ],
+      '#splitbutton_items' => [
+        'item1' => [
+          '#type' => 'link',
+          '#title' => $this->t('Single and Default'),
+          '#url' => Url::fromRoute('splitbutton.test', ['prevent_generated_link' => microtime()]),
+        ],
+      ],
+    ];
+
+    // The Danger button has different styling so it get its own item here.
+    $form['single_items']['danger_single'] = [
+      '#type' => 'splitbutton',
+      '#attributes' => [
+        'data-splitbutton-test-id' => 'splitbutton-single-danger',
+      ],
+      '#splitbutton_type' => 'danger',
+      '#splitbutton_items' => [
+        'item1' => [
+          '#type' => 'link',
+          '#title' => $this->t('Single and Danger'),
+          '#url' => Url::fromRoute('splitbutton.test', ['prevent_generated_link' => microtime()]),
+        ],
+      ],
+    ];
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+  }
+
+}
diff --git a/core/tests/Drupal/FunctionalJavascriptTests/Core/Render/Element/SplitButtonTest.php b/core/tests/Drupal/FunctionalJavascriptTests/Core/Render/Element/SplitButtonTest.php
new file mode 100644
index 0000000000..b195f4548e
--- /dev/null
+++ b/core/tests/Drupal/FunctionalJavascriptTests/Core/Render/Element/SplitButtonTest.php
@@ -0,0 +1,249 @@
+<?php
+
+namespace Drupal\FunctionalJavascriptTests\Core\Render\Element;
+
+use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
+
+/**
+ * Tests for the splitbutton render element.
+ *
+ * @group Render
+ */
+class SplitButtonTest extends WebDriverTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['splitbutton_test'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * General splitbutton tests.
+   */
+  public function testSplitbuttons() {
+    $user = $this->createUser(['access content']);
+    $this->drupalLogin($user);
+    $this->drupalGet('/splitbuttons');
+    $assert_session = $this->assertSession();
+
+    $button_types = [
+      'default',
+      'primary',
+      'danger',
+      'small',
+      'extrasmall',
+    ];
+
+    $scenarios = [
+      'splitbutton_link_first' => [
+        'primary_selector' => 'a.splitbutton__main-button',
+        'number_links' => 3,
+        'number_submit' => 2,
+        'number_items' => 5,
+      ],
+      'splitbutton_submit_first' => [
+        'primary_selector' => 'input.splitbutton__main-button',
+        'number_links' => 4,
+        'number_submit' => 2,
+        'number_items' => 6,
+      ],
+      'splitbutton_with_title' => [
+        'primary_selector' => '.splitbutton__toggle',
+        'number_links' => 4,
+        'number_submit' => 2,
+        'number_items' => 6,
+      ],
+      'dropbutton_converted' => [
+        'primary_selector' => 'a.splitbutton__main-button--link',
+        'number_links' => 3,
+        'number_submit' => 0,
+        'number_items' => 3,
+      ],
+    ];
+
+    foreach ($button_types as $button_type) {
+      foreach ($scenarios as $scenario => $scenario_data) {
+        $test_key = "$scenario-$button_type";
+        $splitbutton_selector = "[data-splitbutton-test-id=\"$test_key\"]";
+        $splitbutton = $assert_session->waitForElement('css', $splitbutton_selector);
+        $this->assertNotNull($splitbutton);
+        $toggle = $splitbutton->find('css', '.splitbutton__toggle');
+        $this->assertNotNull($toggle);
+        $this->assertFalse($splitbutton->hasClass('open'));
+        $toggle->press();
+        $open_splitbutton = $assert_session->waitForElement('css', "$splitbutton_selector.open");
+        $this->assertNotNull($open_splitbutton);
+        $operation_list = $assert_session->waitForElementVisible('css', "$splitbutton_selector .splitbutton__operation-list");
+        $this->assertNotNull($operation_list);
+        $primary_button_selector = $scenario_data['primary_selector'];
+        $assert_session->elementExists('css', "$splitbutton_selector $primary_button_selector");
+        $operation_list_links = $operation_list->findAll('css', 'a');
+        $operation_list_submits = $operation_list->findAll('css', 'input');
+        $this->assertCount($scenario_data['number_links'], $operation_list_links);
+        $this->assertCount($scenario_data['number_submit'], $operation_list_submits);
+
+        $toggle->press();
+        $closed_splitbutton = $assert_session->waitForElement('css', "$splitbutton_selector:not(.open)");
+        $this->assertNotNull($closed_splitbutton);
+
+        if ($button_type !== 'default') {
+          $this->assertTrue($toggle->hasClass("button--$button_type"));
+          if ($scenario !== 'splitbutton_with_title') {
+            $assert_session->elementExists('css', "$splitbutton_selector .splitbutton__main-button.button--$button_type");
+          }
+        }
+      }
+    }
+
+    // Confirm classes were properly added to splitbuttons that have multiple
+    // types.
+    $assert_session->elementExists('css', '[data-splitbutton-test-id="splitbutton-primary-small"].splitbutton--primary.splitbutton--small');
+    $assert_session->elementExists('css', '[data-splitbutton-test-id="splitbutton-primary-small"] .splitbutton__main-button.button.button--primary.button--small');
+    $assert_session->elementExists('css', '[data-splitbutton-test-id="splitbutton-primary-small"] .splitbutton__toggle.button.button--primary.button--small');
+
+    $assert_session->elementExists('css', '[data-splitbutton-test-id="splitbutton-danger-extrasmall"].splitbutton--danger.splitbutton--extrasmall');
+    $assert_session->elementExists('css', '[data-splitbutton-test-id="splitbutton-danger-extrasmall"] .splitbutton__main-button.button.button--danger.button--extrasmall');
+    $assert_session->elementExists('css', '[data-splitbutton-test-id="splitbutton-danger-extrasmall"] .splitbutton__toggle.button.button--danger.button--extrasmall');
+
+    // Test single-item splitbuttons.
+    $test_ids = [
+      'splitbutton-single-default',
+      'splitbutton-single-danger',
+    ];
+    foreach ($test_ids as $test_id) {
+      $splitbutton = $assert_session->waitForElement('css', "[data-splitbutton-test-id=\"$test_id\"]");
+      $this->assertFalse($splitbutton->hasClass('splitbutton--multiple'));
+      $this->assertFalse($splitbutton->hasClass('splitbutton--enabled'));
+      $this->assertTrue($splitbutton->hasClass('splitbutton--single'));
+      $this->assertNull($splitbutton->find('css', '.splitbutton__operation-list'));
+    }
+  }
+
+  /**
+   * Tests keyboard navigation of splitbutton.
+   *
+   * Many keyboard events can't be simulated in FunctionalJavascript tests, this
+   * covers those that can: Navigating to new items via arrow keys and pageup/
+   * pagedown, and closing open menus with escape and tan.
+   */
+  public function testSplitbuttonKeyboard() {
+    $assert_session = $this->assertSession();
+    $user = $this->createUser(['access content']);
+    $this->drupalLogin($user);
+    $this->drupalGet('/splitbuttons');
+    $keys = [
+      'tab' => 9,
+      'esc' => 27,
+      'pageup' => 33,
+      'pagedown' => 34,
+      'up' => 38,
+      'down' => 40,
+    ];
+
+    // Expected link or input test of each menu item.
+    $menu_item_values = [
+      'Link Two',
+      'Link Three',
+      'Link Four',
+      'Added Button',
+      'Another Added Button',
+    ];
+
+    // Find splitbutton and toggle, confirm splitbutton is closed.
+    $splitbutton = $assert_session->elementExists('css', '[data-splitbutton-test-id="splitbutton_link_first-default"]');
+    $this->assertFalse($splitbutton->hasClass('open'));
+    $toggle = $splitbutton->find('css', '.splitbutton__toggle');
+    $this->assertNotNull($toggle);
+
+    // Open splitbutton and add newly visible menu items to a variable.
+    $toggle->press();
+    $this->assertNotNull($assert_session->waitForElementVisible('css', '[data-splitbutton-test-id="splitbutton_link_first-default"] .splitbutton__operation-list'));
+    $this->assertTrue($splitbutton->hasClass('open'));
+    $menu_items = $splitbutton->findAll('css', '.splitbutton__operation-list-item');
+    $this->assertCount(5, $menu_items);
+
+    // Use down key to select first item in the menu.
+    $toggle->keyDown($keys['down']);
+    $toggle->keyUp($keys['down']);
+
+    // Script that finds the focused element and returns the inner text or its
+    // value, depending on the type of element it is.
+    $script = <<<EndOfScript
+(document.activeElement.tagName === 'INPUT') ? document.activeElement.getAttribute("value") : document.activeElement.innerText
+EndOfScript;
+
+    // Each item in the array is a keyboard key to be pressed, and the index of
+    // the menu item that should be focused after that keypress.
+    $key_steps = [
+      [
+        'key' => $keys['down'],
+        'expected_destination' => 1,
+      ],
+      [
+        'key' => $keys['down'],
+        'expected_destination' => 2,
+      ],
+      [
+        'key' => $keys['down'],
+        'expected_destination' => 3,
+      ],
+      [
+        'key' => $keys['up'],
+        'expected_destination' => 2,
+      ],
+      [
+        'key' => $keys['pagedown'],
+        'expected_destination' => 4,
+      ],
+      [
+        'key' => $keys['up'],
+        'expected_destination' => 3,
+      ],
+      [
+        'key' => $keys['pageup'],
+        'expected_destination' => 0,
+      ],
+    ];
+
+    $focused_element_text = $this->getSession()->evaluateScript($script);
+
+    // Navigate through the menu via various keypresses and confirm it moves
+    // focus to the expected element.
+    foreach ($key_steps as $step) {
+      $menu_items[array_search($focused_element_text, $menu_item_values)]->keyDown($step['key']);
+      $menu_items[array_search($focused_element_text, $menu_item_values)]->keyUp($step['key']);
+      $focused_element_text = $this->getSession()->evaluateScript($script);
+      $this->assertEquals($step['expected_destination'], array_search($focused_element_text, $menu_item_values));
+    }
+
+    // Pressing the escape key should close the menu and return focus to the
+    // toggle.
+    $menu_items[array_search($focused_element_text, $menu_item_values)]->keyDown($keys['esc']);
+    $menu_items[array_search($focused_element_text, $menu_item_values)]->keyUp($keys['esc']);
+    $this->assertNotNull($assert_session->waitForElement('css', '[data-splitbutton-test-id="splitbutton_link_first-default"]:not(.open)'));
+    $this->assertJsCondition('document.querySelector("#edit-default .splitbutton__toggle") === document.activeElement');
+
+    // Reopen the menu.
+    $toggle->press();
+    $this->assertNotNull($assert_session->waitForElementVisible('css', '[data-splitbutton-test-id="splitbutton_link_first-default"] .splitbutton__operation-list'));
+    $this->assertTrue($splitbutton->hasClass('open'));
+
+    // Navigate into the menu.
+    $toggle->keyDown($keys['down']);
+    $toggle->keyUp($keys['down']);
+    $focused_element_text = $this->getSession()->evaluateScript($script);
+    $this->assertEquals(0, array_search($focused_element_text, $menu_item_values));
+
+    // Pressing the tab key should close the menu and return focus to the
+    // toggle.
+    $menu_items[array_search($focused_element_text, $menu_item_values)]->keyDown($keys['tab']);
+    $menu_items[array_search($focused_element_text, $menu_item_values)]->keyUp($keys['tab']);
+    $this->assertNotNull($assert_session->waitForElement('css', '[data-splitbutton-test-id="splitbutton_link_first-default"]:not(.open)'));
+    $this->assertJsCondition('document.querySelector("#edit-default .splitbutton__toggle") === document.activeElement');
+  }
+
+}
diff --git a/core/themes/bartik/bartik.info.yml b/core/themes/bartik/bartik.info.yml
index 7a184637bb..418accaaf1 100644
--- a/core/themes/bartik/bartik.info.yml
+++ b/core/themes/bartik/bartik.info.yml
@@ -30,6 +30,8 @@ libraries-extend:
     - bartik/classy.dropbutton
   core/drupal.progress:
     - bartik/classy.progress
+  core/drupal.splitbutton:
+    - bartik/splitbutton
   file/drupal.file:
     - bartik/classy.file
   filter/drupal.filter.admin:
diff --git a/core/themes/bartik/bartik.libraries.yml b/core/themes/bartik/bartik.libraries.yml
index 820687e001..d2dfec360f 100644
--- a/core/themes/bartik/bartik.libraries.yml
+++ b/core/themes/bartik/bartik.libraries.yml
@@ -87,6 +87,12 @@ maintenance_page:
     - system/maintenance
     - bartik/global-styling
 
+splitbutton:
+  version: VERSION
+  css:
+    component:
+      css/components/splitbutton.css: {}
+
 classy.base:
   version: VERSION
   css:
diff --git a/core/themes/bartik/css/components/splitbutton.css b/core/themes/bartik/css/components/splitbutton.css
new file mode 100644
index 0000000000..99c8da8bb2
--- /dev/null
+++ b/core/themes/bartik/css/components/splitbutton.css
@@ -0,0 +1,108 @@
+.splitbutton * {
+  box-sizing: border-box;
+}
+
+.splitbutton *:focus {
+  outline: none;
+  box-shadow: 0 0 0 2px #0071b3;
+}
+
+.js .splitbutton__toggle {
+  position: relative;
+  border-radius: 0 20em 20em 0; /* LTR */
+  background-color: #e8e8e8;
+  background-image: -webkit-linear-gradient(top, #e8e8e8, #d2d2d2);
+  background-image: linear-gradient(to bottom, #e8e8e8, #d2d2d2);
+}
+[dir="rtl"].js .splitbutton__toggle {
+  border-radius: 20em 0 0 20em;
+}
+
+.js .splitbutton__toggle--with-title,
+[dir="rtl"].js .splitbutton__toggle--with-title {
+  border-radius: 20em;
+}
+
+.splitbutton__toggle:focus {
+  z-index: 5;
+}
+
+.splitbutton__toggle--with-title {
+  position: relative;
+  padding-right: 2.5em; /* LTR */
+}
+[dir="rtl"] .splitbutton__toggle--with-title {
+  padding-right: 1.063em;
+  padding-left: 2.5em;
+}
+
+.splitbutton__toggle::after {
+  position: absolute;
+  top: 52%;
+  right: calc(14px + (0.3333em / 4)); /* LTR */
+  width: 0;
+  height: 0;
+  content: "";
+  transform: translate(50%, -50%) rotate(0);
+  border-width: 0.3333em 0.3333em 0;
+  border-style: solid;
+  border-right-color: transparent;
+  border-bottom-color: transparent;
+  border-left-color: transparent;
+}
+[dir="rtl"] .splitbutton__toggle::after {
+  right: auto;
+  left: 7px;
+}
+.splitbutton.open .splitbutton__toggle::after {
+  top: 48%;
+  transform: translate(50%, -50%) rotate(180deg);
+}
+
+.js .splitbutton--multiple .splitbutton__main-button {
+  border-radius: 20em 0 0 20em;
+}
+[dir="rtl"].js .splitbutton--multiple .splitbutton__main-button {
+  border-radius: 0 20em 20em 0;
+}
+
+.js .splitbutton__operation-list {
+  padding-top: 2px;
+}
+
+.js .splitbutton__operation-list li:first-child {
+  border-radius: 0.5em 0.5em 0 0;
+}
+.js .splitbutton__operation-list li:last-child {
+  border-radius: 0 0 0.5em 0.5em;
+}
+
+.js .splitbutton__operation-list-item {
+  width: 100%;
+  margin: 0;
+  padding: 0.25em 1.063em;
+  text-align: left;
+  color: #3a3a3a;
+  border: 1px solid #a6a6a6;
+  border-top-width: 0;
+  border-bottom-width: 0;
+  border-radius: 0;
+  background-color: #fff;
+  background-image: -webkit-linear-gradient(top, #f3f3f3, #e8e8e8);
+  background-image: linear-gradient(to bottom, #f3f3f3, #e8e8e8);
+  font-family: "Lucida Grande", "Lucida Sans Unicode", Verdana, sans-serif;
+  font-size: 0.875rem;
+  font-weight: normal;
+}
+.js .splitbutton__operation-list-item:hover {
+  background: #dedede;
+}
+
+.js .splitbutton__operation-list li:first-child .splitbutton__operation-list-item {
+  border-top-width: 1px;
+  border-radius: 0.5em 0.5em 0 0;
+}
+.js .splitbutton__operation-list li:last-child .splitbutton__operation-list-item {
+  border-bottom-width: 1px;
+  border-radius: 0 0 0.5em 0.5em;
+}
diff --git a/core/themes/claro/claro.info.yml b/core/themes/claro/claro.info.yml
index d04bf1df4d..e94896aa5c 100644
--- a/core/themes/claro/claro.info.yml
+++ b/core/themes/claro/claro.info.yml
@@ -93,6 +93,8 @@ libraries-extend:
     - claro/messages
   core/drupal.progress:
     - claro/progress
+  core/drupal.splitbutton:
+    - claro/splitbutton
   core/drupal.vertical-tabs:
     - claro/vertical-tabs
   core/jquery.ui:
diff --git a/core/themes/claro/claro.libraries.yml b/core/themes/claro/claro.libraries.yml
index 99baaeb38c..e2c1088317 100644
--- a/core/themes/claro/claro.libraries.yml
+++ b/core/themes/claro/claro.libraries.yml
@@ -265,6 +265,12 @@ progress:
     component:
       css/components/progress.css: {}
 
+splitbutton:
+  version: VERSION
+  css:
+    component:
+      css/components/splitbutton.css: {}
+
 filter:
   version: VERSION
   css:
diff --git a/core/themes/claro/claro.theme b/core/themes/claro/claro.theme
index a016277372..f95f6884d1 100644
--- a/core/themes/claro/claro.theme
+++ b/core/themes/claro/claro.theme
@@ -248,6 +248,8 @@ function claro_element_info_alter(&$type) {
   // Add a pre-render function that handles dropbutton variants.
   if (isset($type['dropbutton'])) {
     $type['dropbutton']['#pre_render'][] = [ClaroPreRender::class, 'dropButton'];
+    $type['dropbutton']['#type'] = 'splitbutton';
+    unset($type['dropbutton']['theme']);
   }
 
   if (isset($type['vertical_tabs'])) {
diff --git a/core/themes/claro/css/base/variables.css b/core/themes/claro/css/base/variables.css
index aa4737c042..c17e8fd92a 100644
--- a/core/themes/claro/css/base/variables.css
+++ b/core/themes/claro/css/base/variables.css
@@ -31,6 +31,7 @@
   /**
    * Buttons.
    */
+
   /**
    * jQuery.UI dropdown.
    */ /* Light gray with 0.8 opacity. */ /* Text color with 0.1 opacity. */
diff --git a/core/themes/claro/css/base/variables.pcss.css b/core/themes/claro/css/base/variables.pcss.css
index fbbfa04c7e..6abcf73e79 100644
--- a/core/themes/claro/css/base/variables.pcss.css
+++ b/core/themes/claro/css/base/variables.pcss.css
@@ -146,6 +146,13 @@
   --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);
+  --button--vertical-padding: calc(var(--space-m) - 1px);
+  --button--horizontal-padding: calc(var(--space-l) - 1px);
+  --button--small--vertical-padding: calc(var(--space-xs) - 1px);
+  --button--small--horizontal-padding: calc(var(--space-m) - 1px);
+  --button--extrasmall--vertical-padding: calc(calc(var(--space-xs) / 2) - 1px);
+  --button--extrasmall--horizontal-padding: calc(var(--space-s) - 1px);
+
   /**
    * jQuery.UI dropdown.
    */
diff --git a/core/themes/claro/css/components/button.css b/core/themes/claro/css/components/button.css
index 8594ae4390..e2132fec7e 100644
--- a/core/themes/claro/css/components/button.css
+++ b/core/themes/claro/css/components/button.css
@@ -39,6 +39,7 @@
   /**
    * Buttons.
    */
+
   /**
    * jQuery.UI dropdown.
    */ /* Light gray with 0.8 opacity. */ /* Text color with 0.1 opacity. */
diff --git a/core/themes/claro/css/components/button.pcss.css b/core/themes/claro/css/components/button.pcss.css
index f1f95222bd..fd0463f1d5 100644
--- a/core/themes/claro/css/components/button.pcss.css
+++ b/core/themes/claro/css/components/button.pcss.css
@@ -32,7 +32,7 @@
 .button {
   display: inline-block;
   margin: var(--space-m) var(--space-s) var(--space-m) 0; /* LTR */
-  padding: calc(var(--space-m) - 1px) calc(var(--space-l) - 1px); /* 1 */
+  padding: var(--button--vertical-padding) var(--button--horizontal-padding); /* 1 */
   cursor: pointer;
   text-align: center;
   text-decoration: none;
@@ -70,7 +70,7 @@
 /* Common styles for small buttons */
 .no-touchevents .button--small {
   margin: var(--space-s) var(--space-xs) var(--space-s) 0; /* LTR */
-  padding: calc(var(--space-xs) - 1px) calc(var(--space-m) - 1px); /* 1 */
+  padding: var(--button--small--vertical-padding) var(--button--small--horizontal-padding); /* 1 */
   font-size: var(--font-size-xs);
 }
 
diff --git a/core/themes/claro/css/components/splitbutton.css b/core/themes/claro/css/components/splitbutton.css
new file mode 100644
index 0000000000..d72730b5f5
--- /dev/null
+++ b/core/themes/claro/css/components/splitbutton.css
@@ -0,0 +1,195 @@
+/*
+ * DO NOT EDIT THIS FILE.
+ * See the following change record for more information,
+ * https://www.drupal.org/node/2815083
+ * @preserve
+ */
+
+:root {
+  /*
+   * Color Palette.
+   */
+  /* Secondary. */
+  /* Variations. */ /* 5% darker than base. */ /* 10% darker than base. */ /* 10% darker than base. */ /* 20% darker than base. */ /* 5% darker than base. */ /* 10% darker than base. */ /* 5% darker than base. */ /* 10% darker than base. */ /* 5% darker than base. */ /* 10% darker than base. */
+  /*
+   * Base.
+   */
+  /*
+   * Typography.
+   */ /* 1rem = 16px if font root is 100% ands browser defaults are used. */ /* ~32px */ /* ~29px */ /* ~26px */ /* ~23px */ /* ~20px */ /* 18px */ /* ~14px */ /* ~13px */ /* ~11px */
+  /**
+   * Spaces.
+   */ /* 3 * 16px = 48px */ /* 1.5 * 16px = 24px */ /* 1 * 16px = 16px */ /* 0.75 * 16px = 12px */ /* 0.5 * 16px = 8px */
+  /*
+   * Common.
+   */
+  /*
+   * Inputs.
+   */ /* Absolute zero with opacity. */ /* Davy's grey with 0.6 opacity. */ /* Light gray with 0.3 opacity on white bg. */ /* Old silver with 0.5 opacity on white bg. */ /* (1/8)em ~ 2px */ /* (1/16)em ~ 1px */ /* Font size is too big to use 1rem for extrasmall line-height */ /* 7px inside the form element label. */ /* 8px with the checkbox width of 19px */
+  /*
+   * Details.
+   */
+  /**
+   * Buttons.
+   */
+
+  /**
+   * jQuery.UI dropdown.
+   */ /* Light gray with 0.8 opacity. */ /* Text color with 0.1 opacity. */
+  /**
+   * Progress bar.
+   */
+  /**
+   * Tabledrag icon size.
+   */ /* 17px */
+  /**
+   * Ajax progress.
+   */
+  /**
+   * Breadcrumb.
+   */
+}
+
+.splitbutton {
+  display: inline-flex;
+}
+
+html:not(.js) .splitbutton {
+  display: block;
+}
+
+.splitbutton__main .splitbutton__toggle {
+  margin-left: 1px;
+}
+
+.splitbutton__main .splitbutton__toggle:focus {
+  z-index: 5;
+}
+
+.splitbutton__main .button {
+  margin-top: 0;
+  margin-right: 0; /* LTR */
+  margin-bottom: 0;
+}
+
+[dir="rtl"] .splitbutton__main .button {
+  margin-right: 1px;
+  margin-left: 0;
+}
+
+.splitbutton .button:not(:focus) {
+  box-shadow: 0 1px 0.5px rgba(0, 0, 0, 0.25);
+}
+
+.splitbutton__toggle {
+  position: relative;
+  box-shadow: none;
+}
+
+.splitbutton__toggle::before {
+  position: absolute;
+  top: 50%;
+  right: calc(1.5rem - 1px);
+  width: 0.875rem;
+  height: 0.5625rem;
+  content: "";
+  transform: translate(50%, -50%) rotate(0);
+  background: url("data:image/svg+xml,%3Csvg width='14' height='9' viewBox='0 0 14 9' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.2384999,1.9384769 1.646703,0.5166019 7.0002189,5.8193359 12.353735,0.5166019 13.761938,1.9384769 7.0002189,8.635742Z' fill='%23222330'/%3E%3C/svg%3E") no-repeat center;
+  background-size: contain;
+}
+
+.splitbutton.open .splitbutton__toggle::before {
+  transform: translate(50%, -50%) rotate(180deg);
+}
+
+[dir="rtl"] .splitbutton.open .splitbutton__toggle::before {
+  transform: translate(50%, -50%) rotate(-180deg);
+}
+
+.splitbutton__toggle--with-title.button {
+  padding-right: calc(3rem - 2px);
+}
+
+.js .splitbutton__operation-list li {
+  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
+}
+
+.js .splitbutton__operation-list-item {
+  position: relative;
+  display: block;
+  box-sizing: border-box;
+  width: 100%;
+  margin: 0;
+  padding: calc(1rem - 1px);
+  text-align: left;
+  text-decoration: none;
+  color: #545560;
+  border-right: 1px solid #d4d4d8;
+  border-left: 1px solid #d4d4d8;
+  border-radius: 2px;
+  background: #fff;
+  font-size: 1rem;
+  font-weight: normal;
+  line-height: 1rem;
+  -webkit-font-smoothing: antialiased;
+}
+
+.js .splitbutton__operation-list-item:focus {
+  z-index: 5;
+}
+
+.js .splitbutton__operation-list-item:not(:focus) {
+  box-shadow: none;
+}
+
+.js .splitbutton__operation-list-item:hover {
+  color: #222330;
+  background: #f3f4f9;
+}
+
+.js li:first-child .splitbutton__operation-list-item {
+  border-top: 1px solid #d4d4d8;
+}
+
+.js li:last-child .splitbutton__operation-list-item {
+  border-bottom: 1px solid #d4d4d8;
+}
+
+/* Variants */
+
+.no-touchevents .splitbutton--small .splitbutton__toggle::before,
+.no-touchevents .splitbutton--extrasmall .splitbutton__toggle::before {
+  right: calc(1rem - 1px);
+  width: 0.75rem;
+}
+
+.js .splitbutton__toggle--with-title.button--small {
+  padding-right: 2rem;
+}
+
+.no-touchevents .splitbutton--extrasmall .splitbutton__toggle::before {
+  right: calc(0.75rem - 1px);
+}
+
+.js .splitbutton__toggle--with-title.button--extrasmall {
+  padding-right: calc(1.5rem + 2px);
+}
+
+.js .splitbutton--primary .splitbutton__toggle::before,
+.js .splitbutton--danger .splitbutton__toggle::before {
+  background: url("data:image/svg+xml,%3Csvg width='14' height='9' fill='%23FF00FF' viewBox='0 0 14 9' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.2384999,1.9384769 1.646703,0.5166019 7.0002189,5.8193359 12.353735,0.5166019 13.761938,1.9384769 7.0002189,8.635742Z' fill='white'/%3E%3C/svg%3E") no-repeat center;
+}
+
+.no-touchevents .splitbutton--small .splitbutton__operation-list-item {
+  padding-top: calc(0.625rem - 1px);
+  padding-bottom: calc(0.625rem - 1px);
+  font-size: 0.79rem;
+  line-height: 0.75rem;
+}
+
+.no-touchevents .splitbutton--extrasmall .splitbutton__operation-list-item {
+  padding-top: calc(0.375rem - 1px);
+  padding-bottom: calc(0.375rem - 1px);
+  font-size: 0.79rem;
+  line-height: 0.75rem;
+}
diff --git a/core/themes/claro/css/components/splitbutton.pcss.css b/core/themes/claro/css/components/splitbutton.pcss.css
new file mode 100644
index 0000000000..e2d8951a89
--- /dev/null
+++ b/core/themes/claro/css/components/splitbutton.pcss.css
@@ -0,0 +1,136 @@
+@import "../base/variables.pcss.css";
+
+.splitbutton {
+  display: inline-flex;
+}
+
+html:not(.js) .splitbutton {
+  display: block;
+}
+
+.splitbutton__main .splitbutton__toggle {
+  margin-left: 1px;
+}
+
+.splitbutton__main .splitbutton__toggle:focus {
+  z-index: 5;
+}
+
+.splitbutton__main .button {
+  margin-top: 0;
+  margin-right: 0; /* LTR */
+  margin-bottom: 0;
+}
+[dir="rtl"] .splitbutton__main .button {
+  margin-right: 1px;
+  margin-left: 0;
+}
+
+.splitbutton .button:not(:focus) {
+  box-shadow: 0 1px 0.5px rgba(0, 0, 0, 0.25);
+}
+
+.splitbutton__toggle {
+  position: relative;
+  box-shadow: none;
+}
+.splitbutton__toggle::before {
+  position: absolute;
+  top: 50%;
+  right: var(--button--horizontal-padding);
+  width: 0.875rem;
+  height: 0.5625rem;
+  content: "";
+  transform: translate(50%, -50%) rotate(0);
+  background: url("data:image/svg+xml,%3Csvg width='14' height='9' viewBox='0 0 14 9' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.2384999,1.9384769 1.646703,0.5166019 7.0002189,5.8193359 12.353735,0.5166019 13.761938,1.9384769 7.0002189,8.635742Z' fill='%23222330'/%3E%3C/svg%3E") no-repeat center;
+  background-size: contain;
+}
+.splitbutton.open .splitbutton__toggle::before {
+  transform: translate(50%, -50%) rotate(180deg);
+}
+[dir="rtl"] .splitbutton.open .splitbutton__toggle::before {
+  transform: translate(50%, -50%) rotate(-180deg);
+}
+
+.splitbutton__toggle--with-title.button {
+  padding-right: calc(var(--button--horizontal-padding) * 2);
+}
+
+.js .splitbutton__operation-list li {
+  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
+}
+
+.js .splitbutton__operation-list-item {
+  position: relative;
+  display: block;
+  box-sizing: border-box;
+  width: 100%;
+  margin: 0;
+  padding: calc(1rem - 1px);
+  text-align: left;
+  text-decoration: none;
+  color: #545560;
+  border-right: 1px solid var(--color-lightgray);
+  border-left: 1px solid var(--color-lightgray);
+  border-radius: 2px;
+  background: #fff;
+  font-size: 1rem;
+  font-weight: normal;
+  line-height: 1rem;
+  -webkit-font-smoothing: antialiased;
+}
+.js .splitbutton__operation-list-item:focus {
+  z-index: 5;
+}
+.js .splitbutton__operation-list-item:not(:focus) {
+  box-shadow: none;
+}
+.js .splitbutton__operation-list-item:hover {
+  color: #222330;
+  background: #f3f4f9;
+}
+.js li:first-child .splitbutton__operation-list-item {
+  border-top: 1px solid var(--color-lightgray);
+}
+.js li:last-child .splitbutton__operation-list-item {
+  border-bottom: 1px solid var(--color-lightgray);
+}
+
+/* Variants */
+
+.no-touchevents .splitbutton--small .splitbutton__toggle::before,
+.no-touchevents .splitbutton--extrasmall .splitbutton__toggle::before {
+  right: var(--button--small--horizontal-padding);
+  width: 0.75rem;
+}
+
+.js .splitbutton__toggle--with-title.button--small {
+  padding-right: calc(var(--button--small--horizontal-padding) * 2 + 2px);
+}
+
+.no-touchevents .splitbutton--extrasmall .splitbutton__toggle::before {
+  right: var(--button--extrasmall--horizontal-padding);
+}
+
+.js .splitbutton__toggle--with-title.button--extrasmall {
+  padding-right: calc(var(--button--extrasmall--horizontal-padding) * 2 + 4px);
+}
+
+.js .splitbutton--primary .splitbutton__toggle::before,
+.js .splitbutton--danger .splitbutton__toggle::before {
+  background: url("data:image/svg+xml,%3Csvg width='14' height='9' fill='%23FF00FF' viewBox='0 0 14 9' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0.2384999,1.9384769 1.646703,0.5166019 7.0002189,5.8193359 12.353735,0.5166019 13.761938,1.9384769 7.0002189,8.635742Z' fill='white'/%3E%3C/svg%3E") no-repeat center;
+}
+
+.no-touchevents .splitbutton--small .splitbutton__operation-list-item {
+  padding-top: calc(0.625rem - 1px);
+  padding-bottom: calc(0.625rem - 1px);
+  font-size: var(--font-size-xs);
+  line-height: 0.75rem;
+}
+
+.no-touchevents .splitbutton--extrasmall .splitbutton__operation-list-item {
+  padding-top: calc(0.375rem - 1px);
+  padding-bottom: calc(0.375rem - 1px);
+  font-size: var(--font-size-xs);
+  line-height: 0.75rem;
+}
diff --git a/core/themes/seven/css/components/splitbutton.css b/core/themes/seven/css/components/splitbutton.css
new file mode 100644
index 0000000000..f0298e0e6a
--- /dev/null
+++ b/core/themes/seven/css/components/splitbutton.css
@@ -0,0 +1,148 @@
+.splitbutton * {
+  box-sizing: border-box;
+}
+
+.js .splitbutton__main .button:focus {
+  box-shadow: 0 0 0.5em 0.1em hsla(203, 100%, 60%, 0.7);
+}
+
+.js .splitbutton__toggle:focus {
+  z-index: 5;
+}
+
+.js .splitbutton__toggle {
+  position: relative;
+}
+
+.js .splitbutton__toggle--no-title {
+  padding: 0 14px;
+  border: 1px solid #a6a6a6; /* LTR */
+  border-left-width: 0; /* LTR */
+  border-radius: 0 20em 20em 0;
+}
+[dir="rtl"].js .splitbutton__toggle--no-title {
+  border-right-width: 0;
+  border-left-width: 1px;
+}
+
+.js .splitbutton__toggle--with-title {
+  padding-right: 2.5em; /* LTR */
+}
+[dir="rtl"].js .splitbutton__toggle--with-title {
+  padding-right: 1.5em;
+  padding-left: 2.5em;
+}
+
+.js .splitbutton__toggle::after {
+  position: absolute;
+  top: 52%;
+  right: calc(14px + (0.3333em / 4)); /* LTR */
+  width: 0;
+  height: 0;
+  content: "";
+  transform: translate(50%, -50%) rotate(0);
+  border-width: 0.3333em 0.3333em 0;
+  border-style: solid;
+  border-right-color: transparent;
+  border-bottom-color: transparent;
+  border-left-color: transparent;
+}
+[dir="rtl"].js .splitbutton__toggle::after {
+  right: auto;
+  left: 6.5px;
+}
+
+.js .splitbutton.open .splitbutton__toggle::after {
+  top: 48%;
+  transform: translate(50%, -50%) rotate(180deg);
+}
+
+.js .splitbutton--multiple .splitbutton__toggle--no-title.button,
+[dir="rtl"].js .splitbutton--multiple .splitbutton__main-button.button {
+  border-radius: 0 20em 20em 0; /* LTR */
+}
+
+.js .splitbutton--multiple .splitbutton__main-button.button,
+[dir="rtl"].js .splitbutton--multiple .splitbutton__toggle--no-title.button {
+  border-radius: 20em 0 0 20em; /* LTR */
+}
+
+.js .splitbutton__operation-list {
+  overflow: hidden;
+  padding-top: 2px;
+}
+.js .splitbutton__operation-list li:first-child {
+  border-radius: 0.5em 0.5em 0 0;
+}
+.js .splitbutton__operation-list li:last-child {
+  border-radius: 0 0 0.5em 0.5em;
+}
+
+.js .splitbutton__operation-list-item {
+  width: 100%;
+  margin: 0;
+  padding: 4px 10px;
+  text-align: left;
+  color: #333;
+  border: 1px solid #a6a6a6;
+  border-top-width: 0;
+  border-bottom-width: 0;
+  border-radius: 0;
+  background: #fff;
+  font-size: 0.875rem;
+  font-weight: normal;
+}
+
+.js .splitbutton__operation-list-item:hover,
+.js .splitbutton__operation-list-item:focus {
+  text-decoration: none;
+  background: #aae0fe;
+  box-shadow: none;
+  text-shadow: 0.25px 0 0.1px, -0.25px 0 0.1px;
+}
+
+.js .splitbutton__operation-list li:first-child .splitbutton__operation-list-item {
+  border-top-width: 1px;
+  border-radius: 0.5em 0.5em 0 0;
+}
+
+.js .splitbutton__operation-list li:last-child .splitbutton__operation-list-item {
+  border-bottom-width: 1px;
+  border-radius: 0 0 0.5em 0.5em;
+}
+
+/* Variants */
+
+.js .splitbutton__toggle--with-title.button--small {
+  padding-right: 2em;
+}
+
+.js .splitbutton--multiple .splitbutton__main-button.button--danger {
+  border-radius: 20em 0 0 20em;
+}
+
+.js .splitbutton .button--danger {
+  padding: 4px 1.5em;
+  text-decoration: none;
+  border: 1px solid #a6a6a6;
+  border-radius: 20em;
+}
+
+.js .splitbutton .splitbutton__toggle--no-title.button--danger {
+  padding: 0 14px;
+  border-left-width: 0; /* LTR */
+  border-radius: 0 20em 20em 0;
+}
+[dir="rtl"].js .splitbutton .splitbutton__toggle--no-title.button--danger {
+  border-right-width: 0;
+  border-left-width: 1px;
+}
+
+.js .splitbutton__toggle--with-title.button--danger {
+  padding-right: 2.5em;
+}
+
+.splitbutton--primary .splitbutton__toggle {
+  border-color: #1e5c90;
+  background-color: #0071b8;
+}
diff --git a/core/themes/seven/seven.info.yml b/core/themes/seven/seven.info.yml
index 44f08011a4..66e546e3cf 100644
--- a/core/themes/seven/seven.info.yml
+++ b/core/themes/seven/seven.info.yml
@@ -62,8 +62,8 @@ libraries-extend:
     - seven/filter
   filter/drupal.filter:
     - seven/filter
-  media/media_embed_ckeditor_theme:
-    - seven/classy.media_embed_ckeditor_theme
+  core/drupal.splitbutton:
+    - seven/splitbutton
   media_library/view:
     - seven/media_library
   media_library/widget:
diff --git a/core/themes/seven/seven.libraries.yml b/core/themes/seven/seven.libraries.yml
index 060c000296..ddfed2079e 100644
--- a/core/themes/seven/seven.libraries.yml
+++ b/core/themes/seven/seven.libraries.yml
@@ -181,6 +181,12 @@ filter:
     component:
       css/theme/filter.admin.css: {}
 
+splitbutton:
+  version: VERSION
+  css:
+    component:
+      css/components/splitbutton.css: {}
+
 classy.book-navigation:
   version: VERSION
   css:
diff --git a/core/themes/stable/css/core/splitbutton/splitbutton.css b/core/themes/stable/css/core/splitbutton/splitbutton.css
new file mode 100644
index 0000000000..bb976aa577
--- /dev/null
+++ b/core/themes/stable/css/core/splitbutton/splitbutton.css
@@ -0,0 +1,41 @@
+.splitbutton {
+  box-sizing: border-box;
+}
+
+.splitbutton__operation-list {
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.splitbutton__main {
+  position: relative;
+  display: inline-flex;
+  font-size: 0.889rem;
+}
+
+.splitbutton__main .button {
+  margin: 0;
+}
+
+.js .splitbutton__operation-list {
+  display: none;
+}
+.js .splitbutton--enabled.open .splitbutton__operation-list {
+  z-index: 4;
+  display: block;
+}
+
+.splitbutton__operation-list-item {
+  display: block;
+  padding: 0;
+  border: none;
+  background: #fff;
+}
+.splitbutton__operation-list-item:hover {
+  text-decoration: none;
+}
+
+html:not(.js) .splitbutton__toggle {
+  display: none;
+}
diff --git a/core/themes/stable/stable.info.yml b/core/themes/stable/stable.info.yml
index 511d884446..3cdcf5e74b 100644
--- a/core/themes/stable/stable.info.yml
+++ b/core/themes/stable/stable.info.yml
@@ -84,6 +84,10 @@ libraries-override:
     css:
       component:
         misc/dropbutton/dropbutton.css: css/core/dropbutton/dropbutton.css
+  core/drupal.splitbutton:
+    css:
+      component:
+        misc/splitbutton/splitbutton.css: css/core/splitbutton/splitbutton.css
   core/drupal.vertical-tabs:
     css:
       component:
diff --git a/core/themes/stable/templates/content/operation-list.html.twig b/core/themes/stable/templates/content/operation-list.html.twig
new file mode 100644
index 0000000000..681aebdfb6
--- /dev/null
+++ b/core/themes/stable/templates/content/operation-list.html.twig
@@ -0,0 +1,19 @@
+{#
+/**
+ * @file
+ * Theme override for an operation list.
+ *
+ * Available variables:
+ * - attributes: Attributes for the <ul> tag.
+ * - items: items to output. Can be a link, button or submit element.
+ * - list_item_attributes: Attributes for each <li>.
+ *
+ * @see template_preprocess_operation_list()
+ */
+#}{%- if items -%}
+    <ul {{ attributes }}>
+        {%- for item in items -%}
+            <li {{ list_item_attributes }}>{{ item }}</li>
+        {%- endfor -%}
+    </ul>
+{%- endif -%}
diff --git a/core/themes/stable9/css/core/splitbutton/splitbutton.css b/core/themes/stable9/css/core/splitbutton/splitbutton.css
new file mode 100644
index 0000000000..bb976aa577
--- /dev/null
+++ b/core/themes/stable9/css/core/splitbutton/splitbutton.css
@@ -0,0 +1,41 @@
+.splitbutton {
+  box-sizing: border-box;
+}
+
+.splitbutton__operation-list {
+  margin: 0;
+  padding: 0;
+  list-style: none;
+}
+
+.splitbutton__main {
+  position: relative;
+  display: inline-flex;
+  font-size: 0.889rem;
+}
+
+.splitbutton__main .button {
+  margin: 0;
+}
+
+.js .splitbutton__operation-list {
+  display: none;
+}
+.js .splitbutton--enabled.open .splitbutton__operation-list {
+  z-index: 4;
+  display: block;
+}
+
+.splitbutton__operation-list-item {
+  display: block;
+  padding: 0;
+  border: none;
+  background: #fff;
+}
+.splitbutton__operation-list-item:hover {
+  text-decoration: none;
+}
+
+html:not(.js) .splitbutton__toggle {
+  display: none;
+}
diff --git a/core/themes/stable9/stable9.info.yml b/core/themes/stable9/stable9.info.yml
index a07fdd7d20..5e19ac6bf4 100644
--- a/core/themes/stable9/stable9.info.yml
+++ b/core/themes/stable9/stable9.info.yml
@@ -84,6 +84,10 @@ libraries-override:
     css:
       component:
         misc/dropbutton/dropbutton.css: css/core/dropbutton/dropbutton.css
+  core/drupal.splitbutton:
+    css:
+      component:
+        misc/splitbutton/splitbutton.css: css/core/splitbutton/splitbutton.css
   core/drupal.vertical-tabs:
     css:
       component:
diff --git a/core/themes/stable9/templates/content/operation-list.html.twig b/core/themes/stable9/templates/content/operation-list.html.twig
new file mode 100644
index 0000000000..681aebdfb6
--- /dev/null
+++ b/core/themes/stable9/templates/content/operation-list.html.twig
@@ -0,0 +1,19 @@
+{#
+/**
+ * @file
+ * Theme override for an operation list.
+ *
+ * Available variables:
+ * - attributes: Attributes for the <ul> tag.
+ * - items: items to output. Can be a link, button or submit element.
+ * - list_item_attributes: Attributes for each <li>.
+ *
+ * @see template_preprocess_operation_list()
+ */
+#}{%- if items -%}
+    <ul {{ attributes }}>
+        {%- for item in items -%}
+            <li {{ list_item_attributes }}>{{ item }}</li>
+        {%- endfor -%}
+    </ul>
+{%- endif -%}
