diff --git a/core/core.libraries.yml b/core/core.libraries.yml
index 6bccc054bc..c1a7be62d3 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..84a351d258 100644
--- a/core/includes/theme.inc
+++ b/core/includes/theme.inc
@@ -1175,6 +1175,58 @@ function template_preprocess_item_list(&$variables) {
   }
 }
 
+/**
+ * Sends the splitbutton item list override through the item list preprocessor.
+ *
+ * @param array $variables
+ *   The same structure as the array sent to template_preprocess_item_list().
+ */
+function template_preprocess_item_list__splitbutton(array &$variables) {
+  foreach ($variables['items'] as $item) {
+    // Since template overrides can't truly be implemented in modules, it is
+    // possible that the item_list__splitbutton wil not invoke
+    // template_preprocess_item_list due to the active theme not having a
+    // template for it. To get around this, there is a check for the `#type`
+    // property on items. If this property is present, it means the item_list
+    // preprocessor was not invoked, and it should be called here.
+    if (!empty($item['#type'])) {
+      template_preprocess_item_list($variables);
+      break;
+    }
+  }
+}
+
+/**
+ * Prepares variables for splitbutton templates.
+ *
+ * @param array $variables
+ *   An associative array containing:
+ *   - main_element: A render array of either a link, button, or submit element.
+ *     This is the element that is always visible in a splitbutton.
+ *   - variants: An array of strings defined in #splitbutton_type, used for
+ *     adding type-specific classes to elements within the splitbutton.
+ *   - attributes: The attributes added to the splitbutton container.
+ *   - toggle_attributes: The attributes added to the toggle button.
+ *   - splitbutton_item_list: an array of render elements that will populate the
+ *     list items.
+ *   - title: This is a string that, when present, instructs splitbutton to
+ *     function like a dropdown. This becomes the label of the main element, and
+ *     this main element is what toggles the item list.
+ *   - exclude_toggle: Boolean that defaults to FALSE. When TRUE, this will
+ *     prevent a dedicated toggle button from rendering. This is for elements
+ *     that extend splitbutton that don't necessarily want the toggle button.
+ */
+function template_preprocess_splitbutton(array &$variables) {
+  $variables['toggle_attributes'] = new Attribute($variables['toggle_attributes']);
+
+  // If the main element is present, ensure there is an attributes array with a
+  // class property. This facilitates the addition of classes to the main
+  // element within templates.
+  if (!empty($variables['main_element']) && !isset($variables['main_element']['#attributes']['class'])) {
+    $variables['main_element']['#attributes']['class'] = [];
+  }
+}
+
 /**
  * Prepares variables for container templates.
  *
@@ -2042,6 +2094,18 @@ function drupal_common_theme() {
     'item_list' => [
       'variables' => ['items' => [], 'title' => '', 'list_type' => 'ul', 'wrapper_attributes' => [], 'attributes' => [], 'empty' => NULL, 'context' => []],
     ],
+    'splitbutton' => [
+      'variables' => [
+        'splitbutton_multiple' => TRUE,
+        'main_element' => NULL,
+        'variants' => [],
+        'attributes' => [],
+        'toggle_attributes' => [],
+        'splitbutton_item_list' => [],
+        'title' => NULL,
+        'exclude_toggle' => FALSE,
+      ],
+    ],
     'feed_icon' => [
       'variables' => ['url' => NULL, 'title' => NULL],
     ],
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..9ef91adbf1
--- /dev/null
+++ b/core/lib/Drupal/Core/Render/Element/Splitbutton.php
@@ -0,0 +1,241 @@
+<?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.
+ *
+ * Splitbutton's implementation is based on the W3C accessible menu button
+ * design pattern: https://www.w3.org/TR/wai-aria-practices/#menubutton.
+ *
+ * Properties:
+ * - #splitbutton_items: Items that will be themed as a splitbutton_item_list.
+ *   By default, the items can be of the following types: submit, link and
+ *   button. All other elements will be filtered out. Elements extending this
+ *   class can change the items that are filtered by overriding filterItems().
+ * - #splitbutton_type: A string or an array or strings defining a type of
+ *   dropbutton variant for styling purposes. This is used in some themes to add
+ *   the class `splitbutton--#splitbutton_type` to the splitbutton wrapper and
+ *   `button--#splitbutton_type` to the main 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.
+ * - #exclude_toggle: Defaults to FALSE. Largely used by render elements
+ *   extending splitbutton. When TRUE,  no toggle button is added even if the
+ *   configuration would typically result in its addition. For these uses, it
+ *   should be confirmed that there is still an element with the
+ *   `data-drupal-splitbutton-trigger` attribute, as it is necessary for
+ *   splitbutton's JavaScript.
+ * - #hover: If this is TRUE and #title is not empty or false, the splitbutton
+ *   can be opened on hover.
+ *
+ * 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.
+ *
+ * Usage Example:
+ * @code
+ * $form['actions']['splitbutton_actions'] = [
+ *   '#type' => 'splitbutton',
+ *   '#splitbutton_type' => 'small',
+ *   '#splitbutton_items' => [
+ *     'added_link' => [
+ *       '#type' => 'link',
+ *       '#title' => $this->t('Simple Form'),
+ *       '#url' => Url::fromRoute('route.for.the_link'),
+ *     ],
+ *     'added_button' => [
+ *       '#type' => 'button',
+ *       '#value' => $this->t('Added Button'),
+ *     ],
+ *     'another_added_button' => [
+ *       '#type' => 'submit',
+ *       '#value' => $this->t('Another Added Button'),
+ *     ],
+ *   ],
+ * ];
+ * @endcode
+ *
+ * @RenderElement("splitbutton")
+ */
+class Splitbutton extends FormElement {
+
+  use StringTranslationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getInfo() {
+    $class = get_class($this);
+    return [
+      '#pre_render' => [
+        [$class, 'preRenderSplitbutton'],
+      ],
+      '#theme_wrappers' => ['splitbutton'],
+      '#attached' => [
+        'library' => 'core/drupal.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['#variants'] = [];
+
+    if (!empty($element['#splitbutton_type'])) {
+      // If #splitbutton_type exists and it is a string, place it in an array.
+      $element['#variants'] = 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);
+      $element['#variants'][] = $element['#dropbutton_type'];
+    }
+
+    $trigger_id = Html::getUniqueId('splitbutton');
+    $element['#trigger_id'] = $trigger_id;
+
+    $items = static::collectItems($element);
+    static::buildToggleAttributes($element);
+
+    // If the #title property is not present, splitbutton takes the first item
+    // from the $items array and makes that the main "button". A dedicated
+    // toggle button is also provided in these instances.
+    if (!isset($element['#title'])) {
+      $first_item = array_shift($items);
+      $element['#main_element'] = $first_item;
+      $element['#toggle_attributes']['#attributes']['aria-label'] = t('List additional actions');
+    }
+
+    // If additional items are present, place them in a splitbutton list.
+    if (count($items)) {
+      static::buildItemList($element, $items);
+      $element['#splitbutton_multiple'] = TRUE;
+      $element['#attributes']['data-drupal-splitbutton-multiple'] = '';
+      if (!empty($element['#hover']) && !empty($element['#title'])) {
+        $element['#attributes']['data-drupal-splitbutton-hover'] = '';
+      }
+    }
+    else {
+      $element['#splitbutton_multiple'] = FALSE;
+      $element['#attributes']['data-drupal-splitbutton-single'] = '';
+    }
+
+    return $element;
+  }
+
+  /**
+   * Collects items that will be added to the splitbutton.
+   *
+   * @param array $element
+   *   The render element.
+   *
+   * @return array
+   *   An array of splitbutton list items.
+   */
+  public static function collectItems(array $element) {
+    $items = $element['#splitbutton_items'] ?? [];
+
+    // The #links property was used by dropbuttons. To facilitate an easier
+    // switch from dropbutton to splitbutton, items in a #links array are
+    // converted to `link` render elements with a #type, and added to the list
+    // of splitbutton elements.
+    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'];
+    }
+
+    static::filterItems($items);
+    return $items;
+  }
+
+  /**
+   * Adds attributes used for the toggle button.
+   *
+   * @param array $element
+   *   The render element.
+   */
+  public static function buildToggleAttributes(array &$element) {
+    $trigger_id = $element['#trigger_id'];
+    $element['#toggle_attributes'] = [
+      'type' => 'button',
+      'role' => 'button',
+      'aria-haspopup' => 'true',
+      'aria-controls' => "$trigger_id-menu",
+      'aria-expanded' => 'false',
+      'id' => "$trigger_id-trigger",
+      'data-drupal-splitbutton-trigger' => $trigger_id,
+    ];
+  }
+
+  /**
+   * Adds list items to the splitbutton.
+   *
+   * @param array $element
+   *   The render element.
+   * @param array $items
+   *   An array of elements to be added.
+   */
+  public static function buildItemList(array &$element, array $items) {
+    $trigger_id = $element['#trigger_id'];
+
+    foreach ($items as &$item) {
+      $item['#attributes']['role'] = 'menuitem';
+      $item['#attributes']['tabindex'] = '-1';
+      $item['#wrapper_attributes']['role'] = 'none';
+    }
+
+
+    $element['#splitbutton_item_list'] = [
+      '#items' => $items,
+      '#theme' => 'item_list__splitbutton',
+      '#attributes' => [
+        'data-drupal-splitbutton-target' => $trigger_id,
+        'role' => 'menu',
+        'aria-labelledby' => "$trigger_id-trigger",
+        'id' => "$trigger_id-menu",
+        'data-drupal-splitbutton-item-list' => '',
+      ],
+    ];
+  }
+
+  /**
+   * Checks for unsupported element types in a splitbutton item list.
+   *
+   * @param array $items
+   *   The splitbutton list items.
+   */
+  public static function filterItems(array $items) {
+    $allowed_types = ['submit', 'button', 'link'];
+    foreach ($items as $item) {
+      if (!isset($item['#type']) || !in_array($item['#type'], $allowed_types)) {
+        throw new \LogicException('Splitbutton item is either missing #type, or #type is not submit, button or link.');
+      }
+    }
+  }
+
+}
diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt
index e736c74219..aa4ae60d15 100644
--- a/core/misc/cspell/dictionary.txt
+++ b/core/misc/cspell/dictionary.txt
@@ -163,6 +163,7 @@ beforeclose
 beforecreate
 beforeend
 behat
+beihang
 bergmann
 berne
 bgblue
@@ -559,6 +560,7 @@ entityreference
 entitytype
 entityviewedit
 entrypoint
+ercim
 eridani
 errmode
 errored
@@ -861,6 +863,7 @@ justifyright
 justinrainbow
 kakec
 kangarookitten
+keio
 kerneltest
 kernighan
 keyframes
@@ -1239,7 +1242,9 @@ overridetest
 overwritable
 pageable
 pagecache
+pagedown
 pagetop
+pageup
 pageviews
 pagina
 pangram
@@ -1595,7 +1600,10 @@ spdx
 specialchar
 specialchars
 spiffiness
+splitbutton
 splitbuttons
+splitbutton's
+splittextfield
 spreadsheetml
 sqlpassword
 sqlusername
diff --git a/core/misc/splitbutton/splitbutton-init.es6.js b/core/misc/splitbutton/splitbutton-init.es6.js
new file mode 100644
index 0000000000..bbf4a6756c
--- /dev/null
+++ b/core/misc/splitbutton/splitbutton-init.es6.js
@@ -0,0 +1,27 @@
+/**
+ * @file
+ * Splitbutton initialization.
+ */
+
+(($, Drupal) => {
+  /**
+   * Process elements with the [data-drupal-splitbutton-multiple] attribute.
+   *
+   * @type {Drupal~behavior}
+   *
+   * @prop {Drupal~behaviorAttach} attach
+   *   Attaches splitButton behaviors.
+   */
+  Drupal.behaviors.SplitButton = {
+    attach(context) {
+      const $splitbuttons = $(context)
+        .find('[data-drupal-splitbutton-multiple]')
+        .once('splitbutton');
+      $splitbuttons.map((index, splitbutton) =>
+        Drupal.splitbuttons.push(new Drupal.SplitButton(splitbutton)),
+      );
+    },
+  };
+
+  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..d7f6cfcd84
--- /dev/null
+++ b/core/misc/splitbutton/splitbutton-init.js
@@ -0,0 +1,18 @@
+/**
+* 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('[data-drupal-splitbutton-multiple]').once('splitbutton');
+      $splitbuttons.map(function (index, splitbutton) {
+        return Drupal.splitbuttons.push(new Drupal.SplitButton(splitbutton));
+      });
+    }
+  };
+  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..bd97df677a
--- /dev/null
+++ b/core/misc/splitbutton/splitbutton.css
@@ -0,0 +1,51 @@
+[data-drupal-splitbutton-main] {
+  position: relative;
+  display: inline-flex;
+}
+.js [data-drupal-splitbutton-item-list] {
+  display: none;
+  list-style: none;
+}
+[data-drupal-splitbutton-open] [data-drupal-splitbutton-item-list] {
+  z-index: 4;
+  display: block;
+}
+
+[data-drupal-splitbutton-trigger] {
+  position: relative;
+}
+
+[data-drupal-splitbutton-trigger]::after {
+  position: absolute;
+  top: 50%;
+  right: 6px; /* 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"] [data-drupal-splitbutton-trigger]::after {
+  right: auto;
+  left: 6px;
+}
+[data-drupal-splitbutton-open] [data-drupal-splitbutton-trigger]::after {
+  top: 48%;
+  transform: translate(50%, -50%) rotate(180deg);
+}
+
+[data-drupal-splitbutton-toggle-with-title] {
+  padding-right: 18px; /* LTR */
+}
+[dir="rtl"] [data-drupal-splitbutton-toggle-with-title] {
+  padding-right: 6px;
+  padding-left: 18px; /* LTR */
+}
+
+html:not(.js) [data-drupal-splitbutton-trigger] {
+  display: none;
+}
diff --git a/core/misc/splitbutton/splitbutton.es6.js b/core/misc/splitbutton/splitbutton.es6.js
new file mode 100644
index 0000000000..9042fc1e64
--- /dev/null
+++ b/core/misc/splitbutton/splitbutton.es6.js
@@ -0,0 +1,401 @@
+/**
+ * @file
+ * Splitbutton feature.
+ */
+
+((Drupal, Popper) => {
+  /**
+   * Constructs a splitbutton UI element, based on the W3C menu button pattern.
+   *
+   * This software includes material derived from :
+   * - https://www.w3.org/TR/2016/WD-wai-aria-practices-1.1-20161214/examples/menu-button/menu-button-1/js/Menubutton.js
+   * - https://www.w3.org/TR/2016/WD-wai-aria-practices-1.1-20161214/examples/menu-button/menu-button-1/js/MenuItemAction.js
+   * - https://www.w3.org/TR/2016/WD-wai-aria-practices-1.1-20161214/examples/menu-button/menu-button-1/js/PopupMenuAction.js
+   * Copyright © 2020 W3C® (MIT, ERCIM, Keio, Beihang).
+   *
+   * This work is distributed under the W3C® Software License (link below) in the hope
+   * that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
+   * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+   *
+   * This content is licensed according to the W3C Software License that is viewable at:
+   * https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
+   *
+   * The code was adapted for use in Drupal, to use modern ES6 syntax, and to
+   * meet Drupal's JavaScript code standards.
+   *
+   * @param {HTMLElement} splitbutton
+   *   Markup that includes menu items such as links or submit inputs, and a
+   *   button that toggles their visibility.
+   *
+   * @return {Drupal.SplitButton}
+   *   Class representing a splitbutton UI element.
+   */
+  Drupal.SplitButton = class {
+    constructor(splitbutton) {
+      splitbutton.setAttribute('data-drupal-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-drupal-splitbutton-trigger]',
+      );
+      this.menu = splitbutton.querySelector('[data-drupal-splitbutton-target]');
+      this.triggerContainer = splitbutton.querySelector(
+        '[data-drupal-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));
+      if (this.splitbutton.hasAttribute('data-drupal-splitbutton-hover')) {
+        this.splitbutton.addEventListener('mouseenter', () => this.open());
+      }
+    }
+
+    /**
+     * Populate instance variables that facilitate keyboard navigation.
+     */
+    initMenuItems() {
+      // If this.menuItems is empty, the initialization hasn't occurred yet.
+      if (this.menuItems.length === 0) {
+        const itemTags =
+          this.menu.getAttribute('data-drupal-splitbutton-item-tags') ||
+          'a, input, button';
+        Array.prototype.slice
+          .call(this.menu.querySelectorAll(itemTags))
+          .forEach((item, index) => {
+            // Add attribute to each item to identify its focus order.
+            item.setAttribute('data-drupal-splitbutton-item', index);
+            item.classList.add('splitbutton__operation-list-item');
+            this.menuItems.push(item);
+
+            const itemText =
+              item.tagName === 'A' || item.tagName === 'LI'
+                ? 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.hasAttribute(
+        'data-drupal-splitbutton-open',
+      )
+        ? 'close'
+        : 'open';
+      this[state](e.detail === 0);
+    }
+
+    /**
+     * 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.hasAttribute('data-drupal-splitbutton-open');
+      const expanded = show ? 'true' : 'false';
+      if (show) {
+        this.splitbutton.setAttribute('data-drupal-splitbutton-open', '');
+      } else {
+        this.splitbutton.removeAttribute('data-drupal-splitbutton-open');
+      }
+      this.trigger.setAttribute('aria-expanded', expanded);
+    }
+
+    /**
+     * Opens splitbutton menu.
+     */
+    open(focusFirst = true) {
+      this.initMenuItems();
+      this.toggle(true);
+      if (!this.hasOwnProperty('popper')) {
+        this.initPopper();
+      } else {
+        this.popper.forceUpdate();
+      }
+
+      if (focusFirst) {
+        // 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, to prevent unwanted closings due to
+      // the pointer briefly straying from the target while moving to a new item
+      // within the splitbutton.
+      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-drupal-splitbutton-item') === null)
+      ) {
+        return;
+      }
+
+      switch (e.keyCode) {
+        case this.keyCode.ESC:
+          this.focusTrigger();
+          this.close();
+          break;
+
+        case this.keyCode.UP:
+          if (this.splitbutton.hasAttribute('data-drupal-splitbutton-open')) {
+            this.focusPrev(e);
+          } else {
+            this.open();
+            this.focusLast();
+          }
+          break;
+
+        case this.keyCode.DOWN:
+          if (this.splitbutton.hasAttribute('data-drupal-splitbutton-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.hasAttribute('data-drupal-splitbutton-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-drupal-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-drupal-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-drupal-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..645be5dac1
--- /dev/null
+++ b/core/misc/splitbutton/splitbutton.js
@@ -0,0 +1,319 @@
+/**
+* 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.setAttribute('data-drupal-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-drupal-splitbutton-trigger]');
+      this.menu = splitbutton.querySelector('[data-drupal-splitbutton-target]');
+      this.triggerContainer = splitbutton.querySelector('[data-drupal-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);
+      });
+
+      if (this.splitbutton.hasAttribute('data-drupal-splitbutton-hover')) {
+        this.splitbutton.addEventListener('mouseenter', function () {
+          return _this.open();
+        });
+      }
+    }
+
+    _createClass(_class, [{
+      key: "initMenuItems",
+      value: function initMenuItems() {
+        var _this2 = this;
+
+        if (this.menuItems.length === 0) {
+          var itemTags = this.menu.getAttribute('data-drupal-splitbutton-item-tags') || 'a, input, button';
+          Array.prototype.slice.call(this.menu.querySelectorAll(itemTags)).forEach(function (item, index) {
+            item.setAttribute('data-drupal-splitbutton-item', index);
+            item.classList.add('splitbutton__operation-list-item');
+
+            _this2.menuItems.push(item);
+
+            var itemText = item.tagName === 'A' || item.tagName === 'LI' ? 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.hasAttribute('data-drupal-splitbutton-open') ? 'close' : 'open';
+        this[state](e.detail === 0);
+      }
+    }, {
+      key: "toggle",
+      value: function toggle(show) {
+        var isBool = typeof show === 'boolean';
+        show = isBool ? show : !this.splitbutton.hasAttribute('data-drupal-splitbutton-open');
+        var expanded = show ? 'true' : 'false';
+
+        if (show) {
+          this.splitbutton.setAttribute('data-drupal-splitbutton-open', '');
+        } else {
+          this.splitbutton.removeAttribute('data-drupal-splitbutton-open');
+        }
+
+        this.trigger.setAttribute('aria-expanded', expanded);
+      }
+    }, {
+      key: "open",
+      value: function open() {
+        var _this3 = this;
+
+        var focusFirst = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : true;
+        this.initMenuItems();
+        this.toggle(true);
+
+        if (!this.hasOwnProperty('popper')) {
+          this.initPopper();
+        } else {
+          this.popper.forceUpdate();
+        }
+
+        if (focusFirst) {
+          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-drupal-splitbutton-item') === null) {
+          return;
+        }
+
+        switch (e.keyCode) {
+          case this.keyCode.ESC:
+            this.focusTrigger();
+            this.close();
+            break;
+
+          case this.keyCode.UP:
+            if (this.splitbutton.hasAttribute('data-drupal-splitbutton-open')) {
+              this.focusPrev(e);
+            } else {
+              this.open();
+              this.focusLast();
+            }
+
+            break;
+
+          case this.keyCode.DOWN:
+            if (this.splitbutton.hasAttribute('data-drupal-splitbutton-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.hasAttribute('data-drupal-splitbutton-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-drupal-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-drupal-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-drupal-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/system.module b/core/modules/system/system.module
index 064afafbce..7b507a3262 100644
--- a/core/modules/system/system.module
+++ b/core/modules/system/system.module
@@ -242,6 +242,17 @@ function system_theme() {
     'off_canvas_page_wrapper' => [
       'variables' => ['children' => NULL],
     ],
+    'item_list__splitbutton' => [
+      'variables' => [
+        'items' => [],
+        'title' => '',
+        'list_type' => 'ul',
+        'wrapper_attributes' => [],
+        'attributes' => [],
+        'empty' => NULL,
+        'context' => [],
+      ],
+    ],
   ]);
 }
 
diff --git a/core/modules/system/templates/item-list--splitbutton.html.twig b/core/modules/system/templates/item-list--splitbutton.html.twig
new file mode 100644
index 0000000000..9820125200
--- /dev/null
+++ b/core/modules/system/templates/item-list--splitbutton.html.twig
@@ -0,0 +1,22 @@
+{#
+/**
+ * @file
+ * Default theme implementation for an item list used by splitbuttons.
+ *
+ * Available variables:
+ * - items: A list of items. Each item contains:
+ *   - attributes: HTML attributes to be applied to each list item.
+ *   - value: The content of the list element.
+ * - list_type: The tag for list element ("ul" or "ol").
+ * - attributes: HTML attributes to be applied to the list.
+ *
+ * @ingroup themeable
+ */
+#}
+{% if items %}
+    <{{ list_type }} {{ attributes }}>
+        {%- for item in items -%}
+            <li{{ item.attributes }}>{{ item.value }}</li>
+        {%- endfor -%}
+    </{{ list_type }}>
+{%- endif %}
diff --git a/core/modules/system/templates/splitbutton.html.twig b/core/modules/system/templates/splitbutton.html.twig
new file mode 100644
index 0000000000..2d116aa629
--- /dev/null
+++ b/core/modules/system/templates/splitbutton.html.twig
@@ -0,0 +1,39 @@
+{#
+/**
+ * @file
+ * Default theme implementation for a splitbutton.
+ *
+ * Available variables:
+ * - main_element: A render array of either a link, button, or submit element.
+ *   This is the element that is always visible in a splitbutton.
+ * - variants: An array of strings defined in #splitbutton_type, used for
+ *   adding type-specific classes to elements within the splitbutton.
+ * - attributes: HTML attributes added to the splitbutton container.
+ * - toggle_attributes: HTML attributes added to the toggle button.
+ * - splitbutton_item_list: an array of render elements that will populate the
+ *   list items.
+ * - title: This is a string that, when present, instructs splitbutton to
+ *   function like a dropdown. This becomes the label of the main element, and
+ *   this main element is what toggles the item list.
+ * - exclude_toggle: Boolean that defaults to FALSE. When TRUE, this will
+ *   prevent a dedicated toggle button from rendering. This is for elements
+ *   that extend splitbutton that don't necessarily want the toggle button.
+ *
+ * @see template_preprocess_splitbutton()
+ *
+ * @ingroup themeable
+ */
+#}
+{% set toggle_with_title = main_element is empty and title is not empty %}
+
+<div {{ attributes }}>
+  <div data-drupal-splitbutton-main>
+    {{ main_element }}
+    {% if splitbutton_multiple and exclude_toggle == FALSE %}
+        <button {{ toggle_attributes }} {{ toggle_with_title ? 'data-drupal-splitbutton-toggle-with-title' }}>
+          {{ title }}
+        </button>
+    {% endif %}
+  </div>
+  {{ splitbutton_item_list }}
+</div>
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..04526444ef
--- /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'
diff --git a/core/modules/system/tests/modules/splitbutton_test/src/Element/SplitTextField.php b/core/modules/system/tests/modules/splitbutton_test/src/Element/SplitTextField.php
new file mode 100644
index 0000000000..bfa75a0068
--- /dev/null
+++ b/core/modules/system/tests/modules/splitbutton_test/src/Element/SplitTextField.php
@@ -0,0 +1,111 @@
+<?php
+
+namespace Drupal\splitbutton_test\Element;
+
+use Drupal\Core\Render\Element\Splitbutton;
+
+/**
+ * Used for testing a render element extending Splitbutton.
+ *
+ * This element isn't particularly useful, but something like this could be the
+ * foundation of an autocomplete widget, as it has a text input as the main
+ * element and the items become visible on input.
+ *
+ * @RenderElement("split_textfield")
+ */
+class SplitTextField extends Splitbutton {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getInfo() {
+    $class = get_class($this);
+    return [
+      '#input' => TRUE,
+      '#pre_render' => [
+        [$class, 'preRenderSplittextfield'],
+      ],
+      '#theme_wrappers' => ['splitbutton'],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function filterItems(array $items) {
+    // Splitbutton defaults to allowing link, submit and button elements as
+    // splitbutton items. This is overridden here, and instead allows textfield
+    // and item elements.
+    $allowed_types = ['textfield', 'item'];
+    foreach ($items as $item) {
+      if (!isset($item['#type']) || !in_array($item['#type'], $allowed_types)) {
+        throw new \LogicException('Splitbutton item is either missing #type, or #type is not submit, button or link.');
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function collectItems(array $element) {
+    $trigger_id = $element['#trigger_id'];
+    // Make the first item (which becomes the main item) a textfield.
+    $items = [
+      [
+        '#type' => 'textfield',
+        '#title' => $element['#input_title'],
+        '#attributes' => [
+          'data-drupal-splitbutton-trigger' => $trigger_id,
+          'role' => 'combobox',
+          'aria-owns' => "$trigger_id-menu",
+        ],
+      ],
+    ];
+    // Add the items that will be in the toggleable list.
+    foreach ($element['#items'] as $item) {
+      $items[] = [
+        '#markup' => $item,
+      ];
+    }
+
+    return $items;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function buildItemList(&$element, $items) {
+    $trigger_id = $element['#trigger_id'];
+
+    // The navigable items in Splitbutton default are <a>, <input> or <button>
+    // elements within each list item. For SplitTextField, the list items
+    // themselves need to be navigable as they have no child elements.
+    foreach ($items as &$item) {
+      $item['#wrapper_attributes']['role'] = 'option';
+      $item['#wrapper_attributes']['tabindex'] = 0;
+    }
+
+    $element['#splitbutton_item_list'] = [
+      '#items' => $items,
+      '#theme' => 'item_list',
+      '#attributes' => [
+        'data-drupal-splitbutton-target' => $trigger_id,
+        'data-drupal-splitbutton-item-tags' => 'li',
+        'aria-labelledby' => "$trigger_id-trigger",
+        'role' => 'listbox',
+        'id' => "$trigger_id-menu",
+      ],
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function preRenderSplittextfield(array $element) {
+    $element['#input_title'] = $element['#title'];
+    unset($element['#title']);
+    $element['#exclude_toggle'] = TRUE;
+    return parent::preRenderSplitbutton($element);
+  }
+
+}
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..1bb1210b88
--- /dev/null
+++ b/core/modules/system/tests/modules/splitbutton_test/src/Form/SplitButtonTestForm.php
@@ -0,0 +1,293 @@
+<?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()]),
+        ],
+      ],
+    ];
+
+    $form['extends_splitbutton'] = [
+      '#type' => 'container',
+    ];
+    $form['extends_splitbutton']['title'] = [
+      '#type' => 'item',
+      '#name' => 'extends_splitbutton',
+      '#title' => $this->t('Element extending splitbutton'),
+    ];
+    $form['text_input_splitbutton'] = [
+      '#type' => 'split_textfield',
+      '#items' => [
+        $this->t('First Item'),
+        $this->t('Second Item'),
+        $this->t('Third Item'),
+      ],
+      '#title' => $this->t('Text input Splitbutton'),
+    ];
+
+    $form['title'] = [
+      '#type' => 'textfield',
+      '#title' => $this->t('Subject'),
+      '#size' => 60,
+      '#maxlength' => 128,
+      '#pattern' => 'some-prefix-[a-z]+',
+      '#required' => TRUE,
+    ];
+
+    $form['hover_splitbutton'] = [
+      '#type' => 'splitbutton',
+      '#splitbutton_items' => $links_plus_button,
+      '#title' => $this->t('Splitbutton that opens on hover'),
+      '#hover' => TRUE,
+    ];
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+  }
+
+}
diff --git a/core/profiles/demo_umami/themes/umami/templates/components/form/item-list--splitbutton.html.twig b/core/profiles/demo_umami/themes/umami/templates/components/form/item-list--splitbutton.html.twig
new file mode 100644
index 0000000000..235e88d8b7
--- /dev/null
+++ b/core/profiles/demo_umami/themes/umami/templates/components/form/item-list--splitbutton.html.twig
@@ -0,0 +1,20 @@
+{#
+/**
+ * @file
+ * Theme override for an item list used by splitbuttons.
+ *
+ * Available variables:
+ * - items: A list of items. Each item contains:
+ *   - attributes: HTML attributes to be applied to each list item.
+ *   - value: The content of the list element.
+ * - list_type: The tag for list element ("ul" or "ol").
+ * - attributes: HTML attributes to be applied to the list.
+ */
+#}
+{% if items %}
+    <{{ list_type }}{{ attributes.addClass('splitbutton__operation-list') }}>
+        {%- for item in items -%}
+            <li{{ item.attributes }}>{{ item.value }}</li>
+        {%- endfor -%}
+    </{{ list_type }}>
+{%- endif %}
diff --git a/core/profiles/demo_umami/themes/umami/templates/components/form/splitbutton.html.twig b/core/profiles/demo_umami/themes/umami/templates/components/form/splitbutton.html.twig
new file mode 100644
index 0000000000..bddee00fb9
--- /dev/null
+++ b/core/profiles/demo_umami/themes/umami/templates/components/form/splitbutton.html.twig
@@ -0,0 +1,76 @@
+{#
+/**
+ * @file
+ * Theme override for a splitbutton.
+ *
+ * Available variables:
+ * - main_element: A render array of either a link, button, or submit element.
+ *   This is the element that is always visible in a splitbutton.
+ * - variants: An array of strings defined in #splitbutton_type, used for
+ *   adding type-specific classes to elements within the splitbutton.
+ * - attributes: HTML attributes added to the splitbutton container.
+ * - toggle_attributes: HTML attributes added to the toggle button.
+ * - splitbutton_item_list: an array of render elements that will populate the
+ *   list items.
+ * - title: This is a string that, when present, instructs splitbutton to
+ *   function like a dropdown. This becomes the label of the main element, and
+ *   this main element is what toggles the item list.
+ * - exclude_toggle: Boolean that defaults to FALSE. When TRUE, this will
+ *   prevent a dedicated toggle button from rendering. This is for elements
+ *   that extend splitbutton that don't necessarily want the toggle button.
+ *
+ * @see template_preprocess_splitbutton()
+ */
+#}
+{%
+  set container_classes = [
+    'splitbutton',
+    splitbutton_multiple == true ? 'splitbutton--multiple' : 'splitbutton--single',
+  ]
+%}
+{%
+  set toggle_button_classes = [
+    'button',
+    'splitbutton__toggle',
+    title ? 'splitbutton__toggle--with-title' : 'splitbutton__toggle--no-title',
+  ]
+%}
+{%
+  set main_element_classes = [
+    'splitbutton__main-button',
+    'button',
+  ]
+%}
+{# Add modifier classes based on the splitbutton variant types .#}
+{% for variant in variants %}
+  {% set container_classes = container_classes|merge(['splitbutton--' ~ variant]) %}
+  {% set toggle_button_classes = toggle_button_classes|merge(['button--' ~ variant]) %}
+  {% set main_element_classes = main_element_classes|merge(['button--' ~ variant]) %}
+{% endfor %}
+
+{% if main_element %}
+  {% set main_element_classes = main_element_classes|merge(['splitbutton__main-button--' ~ main_element['#type']]) %}
+  {#
+      Since main_element can be any number of different render element types,
+      splitbutton-specific classes must be added here instead of in their own
+      template.
+  #}
+  {% set main_element = main_element|merge({
+    '#attributes': main_element['#attributes']|merge({
+      'class': main_element['#attributes']['class']|merge(main_element_classes),
+    })}) %}
+{% endif %}
+
+{% set toggle_with_title = main_element is empty and title is not empty %}
+
+<div{{ attributes.addClass(container_classes) }}>
+  <div class="splitbutton__main" data-drupal-splitbutton-main>
+    {{ main_element }}
+    {% if splitbutton_multiple and exclude_toggle == FALSE %}
+        <button {{ toggle_attributes.addClass(toggle_button_classes) }} {{ toggle_with_title ? 'data-drupal-splitbutton-toggle-with-title' }}>
+          {{ title }}
+        </button>
+    {% endif %}
+  </div>
+  {{ splitbutton_item_list }}
+</div>
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..996ce8faac
--- /dev/null
+++ b/core/tests/Drupal/FunctionalJavascriptTests/Core/Render/Element/SplitButtonTest.php
@@ -0,0 +1,330 @@
+<?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', 'system'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * Helper array for testing keyboard navigation.
+   *
+   * @var array
+   *  Key is the key name, value is the ascii value.
+   */
+  protected $keys = [
+    'tab' => 9,
+    'esc' => 27,
+    'pageup' => 33,
+    'pagedown' => 34,
+    'up' => 38,
+    'down' => 40,
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $user = $this->createUser(['access content']);
+    $this->drupalLogin($user);
+    $this->drupalGet('/splitbuttons');
+  }
+
+  /**
+   * General splitbutton tests.
+   *
+   * @dataProvider providerTestSplitbuttons
+   */
+  public function testSplitbuttons($theme_name) {
+    if (!empty($theme_name)) {
+      $this->container->get('theme_installer')->install([$theme_name]);
+      $this->config('system.theme')->set('default', $theme_name)->save();
+      $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' => '[data-drupal-splitbutton-trigger]',
+        '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, "Element $splitbutton_selector");
+        $toggle = $splitbutton->find('css', '[data-drupal-splitbutton-trigger]');
+        $this->assertNotNull($toggle);
+        $this->assertFalse($splitbutton->hasAttribute('data-drupal-splitbutton-open'));
+        $toggle->press();
+        $open_splitbutton = $assert_session->waitForElement('css', $splitbutton_selector . '[data-drupal-splitbutton-open]');
+        $this->assertNotNull($open_splitbutton);
+        $operation_list = $assert_session->waitForElementVisible('css', "$splitbutton_selector [data-drupal-splitbutton-item-list]");
+        $this->assertNotNull($operation_list, "$splitbutton_selector [data-drupal-splitbutton-item-list]");
+        $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([data-drupal-splitbutton-open])");
+        $this->assertNotNull($closed_splitbutton);
+
+        if (!empty($theme_name)) {
+          // Confirm expected classes are added to the primary button.
+          $primary_button_selector = $scenario_data['primary_selector'];
+          $assert_session->elementExists('css', "$splitbutton_selector $primary_button_selector");
+
+          // Confirm splitbutton type classes are added to toggle.
+          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 are correctly added to splitbuttons with multiple types.
+    // The conditional is present because these classes are not added in Stark.
+    if (!empty($theme_name)) {
+      // 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"] [data-drupal-splitbutton-trigger].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"] [data-drupal-splitbutton-trigger].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->hasAttribute('data-drupal-splitbutton-multiple'));
+      $this->assertFalse($splitbutton->hasAttribute('data-drupal-splitbutton-enabled'));
+      $this->assertTrue($splitbutton->hasAttribute('data-drupal-splitbutton-single'));
+      $this->assertNull($splitbutton->find('css', '[data-drupal-splitbutton-item-list]'));
+    }
+  }
+
+  /**
+   * Data provider for testSplitbuttons().
+   *
+   * @return string[]
+   *   An array of themes to install for the test.
+   */
+  public function providerTestSplitbuttons() {
+    return [
+      'stark' => [''],
+      'claro' => ['claro'],
+      'seven' => ['seven'],
+      'bartik' => ['bartik'],
+    ];
+  }
+
+  /**
+   * 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();
+    $this->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->hasAttribute('data-drupal-splitbutton-open'));
+    $toggle = $splitbutton->find('css', '[data-drupal-splitbutton-trigger]');
+    $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"] [data-drupal-splitbutton-item-list]'));
+    $this->assertTrue($splitbutton->hasAttribute('data-drupal-splitbutton-open'));
+    $menu_items = $splitbutton->findAll('css', '[data-drupal-splitbutton-item]');
+    $this->assertCount(5, $menu_items);
+
+    // Use down key to select first item in the menu.
+    $toggle->keyDown($this->keys['down']);
+    $toggle->keyUp($this->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' => $this->keys['down'],
+        'expected_destination' => 1,
+      ],
+      [
+        'key' => $this->keys['down'],
+        'expected_destination' => 2,
+      ],
+      [
+        'key' => $this->keys['down'],
+        'expected_destination' => 3,
+      ],
+      [
+        'key' => $this->keys['up'],
+        'expected_destination' => 2,
+      ],
+      [
+        'key' => $this->keys['pagedown'],
+        'expected_destination' => 4,
+      ],
+      [
+        'key' => $this->keys['up'],
+        'expected_destination' => 3,
+      ],
+      [
+        'key' => $this->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($this->keys['esc']);
+    $menu_items[array_search($focused_element_text, $menu_item_values)]->keyUp($this->keys['esc']);
+    $this->assertNotNull($assert_session->waitForElement('css', '[data-splitbutton-test-id="splitbutton_link_first-default"]:not([data-drupal-splitbutton-open])'));
+    $this->assertJsCondition('document.querySelector("#splitbutton-trigger") === document.activeElement');
+
+    // Reopen the menu.
+    $toggle->press();
+    $this->assertNotNull($assert_session->waitForElementVisible('css', '[data-splitbutton-test-id="splitbutton_link_first-default"] [data-drupal-splitbutton-item-list]'));
+    $this->assertTrue($splitbutton->hasAttribute('data-drupal-splitbutton-open'));
+
+    // Navigate into the menu.
+    $toggle->keyDown($this->keys['down']);
+    $toggle->keyUp($this->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($this->keys['tab']);
+    $menu_items[array_search($focused_element_text, $menu_item_values)]->keyUp($this->keys['tab']);
+    $this->assertNotNull($assert_session->waitForElement('css', '[data-splitbutton-test-id="splitbutton_link_first-default"]:not([data-drupal-splitbutton-open])'));
+    $this->assertJsCondition('document.querySelector("#splitbutton-trigger") === document.activeElement');
+  }
+
+  /**
+   * Test a custom element extending splitbutton.
+   *
+   * In this case, this custom element confirms that the primary element of
+   * splitbutton can be a text input, and that is possible to customize classes
+   * and aria attributes to reflect the element's functionality.
+   *
+   * The element being tested uses a text input as the primary element. When a
+   * character is typed into the field, the splitbutton list will open and the
+   * the item matching the first letter typed will be focused.
+   */
+  public function testElementExtendingSplitbutton() {
+    $assert_session = $this->assertSession();
+    $custom_splitbutton = $assert_session->elementExists('css', '[data-drupal-selector="edit-text-input-splitbutton"]');
+    $input = $assert_session->elementExists('css', '[data-drupal-selector="edit-text-input-splitbutton"] input');
+
+    // Assert that it is possible to override the input role.
+    $this->assertEquals('combobox', $input->getAttribute('role'));
+
+    // Confirm that the existing key listener will work on a text input. In this
+    // case, the down arrow should open the item list.
+    $input->keyDown($this->keys['down']);
+    $focused_element_text = $this->getSession()->evaluateScript('document.activeElement.innerText');
+    $this->assertEquals('First Item', $focused_element_text);
+
+    $splitbutton_items = $custom_splitbutton->findAll('css', 'li[role="option"]');
+    $this->assertCount(3, $splitbutton_items);
+    $assert_session->elementExists('css', '[data-drupal-selector="edit-text-input-splitbutton"] ul[role="listbox"]');
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Component/Render/SplitbuttonDeprecationTest.php b/core/tests/Drupal/KernelTests/Component/Render/SplitbuttonDeprecationTest.php
new file mode 100644
index 0000000000..64de2aaf7f
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Component/Render/SplitbuttonDeprecationTest.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Drupal\KernelTests\Component\Render;
+
+use Drupal\Core\Url;
+use Drupal\Core\Render\Element\Splitbutton;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * Provides a test that checks for deprecated splitbutton properties.
+ *
+ * @group Render
+ * @group legacy
+ */
+class SplitbuttonDeprecationTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['splitbutton_test'];
+
+  /**
+   * Check for deprecated splitbutton properties.
+   *
+   * @expectedDeprecation The #dropbutton_type property in splitbutton is deprecated in drupal:9.1.0 and is removed from drupal:10.0.0.
+   * @expectedDeprecation The #links property in splitbutton is deprecated in drupal:9.1.0 and is removed from drupal:10.0.0.
+   */
+  public function testDeprecation() {
+    $element_with_deprecated_properties = [
+      '#type' => 'splitbutton',
+      '#dropbutton_type' => 'small',
+      '#links' => [
+        'one' => [
+          'title' => 'one',
+          'url' => Url::fromRoute('splitbutton.test_link_1'),
+        ],
+        'two' => [
+          'title' => 'two',
+          'url' => Url::fromRoute('splitbutton.test_link_2'),
+        ],
+      ],
+    ];
+
+    $plugin_definition = [
+      'id' => 'splitbutton',
+      'class' => 'Drupal\Core\Render\Element\Splitbutton',
+      'provider' => 'core',
+    ];
+
+    $splitbutton_element = new Splitbutton([], 'splitbutton', $plugin_definition);
+    $splitbutton = $splitbutton_element::preRenderSplitbutton($element_with_deprecated_properties);
+
+    $expected_array_keys = [
+      '#type',
+      '#dropbutton_type',
+      '#links',
+      '#variants',
+      '#trigger_id',
+      '#toggle_attributes',
+      '#main_element',
+      '#splitbutton_item_list',
+      '#splitbutton_multiple',
+      '#attributes',
+    ];
+
+    $this->assertEquals($expected_array_keys, array_keys($splitbutton));
+  }
+
+}
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..e22bd776bd
--- /dev/null
+++ b/core/themes/bartik/css/components/splitbutton.css
@@ -0,0 +1,119 @@
+.splitbutton * {
+  box-sizing: border-box;
+}
+
+.splitbutton *:focus {
+  outline: none;
+  box-shadow: 0 0 0 2px #0071b3;
+}
+
+.splitbutton__main {
+  font-size: 0.889rem;
+}
+
+.splitbutton__main .button {
+  margin: 0;
+}
+
+.js .splitbutton__toggle {
+  position: relative;
+  margin: 0;
+  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;
+}
+[data-drupal-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: 2px 0 0;
+  list-style: none;
+}
+
+.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 {
+  display: block;
+  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/bartik/templates/item-list--splitbutton.html.twig b/core/themes/bartik/templates/item-list--splitbutton.html.twig
new file mode 100644
index 0000000000..235e88d8b7
--- /dev/null
+++ b/core/themes/bartik/templates/item-list--splitbutton.html.twig
@@ -0,0 +1,20 @@
+{#
+/**
+ * @file
+ * Theme override for an item list used by splitbuttons.
+ *
+ * Available variables:
+ * - items: A list of items. Each item contains:
+ *   - attributes: HTML attributes to be applied to each list item.
+ *   - value: The content of the list element.
+ * - list_type: The tag for list element ("ul" or "ol").
+ * - attributes: HTML attributes to be applied to the list.
+ */
+#}
+{% if items %}
+    <{{ list_type }}{{ attributes.addClass('splitbutton__operation-list') }}>
+        {%- for item in items -%}
+            <li{{ item.attributes }}>{{ item.value }}</li>
+        {%- endfor -%}
+    </{{ list_type }}>
+{%- endif %}
diff --git a/core/themes/bartik/templates/splitbutton.html.twig b/core/themes/bartik/templates/splitbutton.html.twig
new file mode 100644
index 0000000000..bddee00fb9
--- /dev/null
+++ b/core/themes/bartik/templates/splitbutton.html.twig
@@ -0,0 +1,76 @@
+{#
+/**
+ * @file
+ * Theme override for a splitbutton.
+ *
+ * Available variables:
+ * - main_element: A render array of either a link, button, or submit element.
+ *   This is the element that is always visible in a splitbutton.
+ * - variants: An array of strings defined in #splitbutton_type, used for
+ *   adding type-specific classes to elements within the splitbutton.
+ * - attributes: HTML attributes added to the splitbutton container.
+ * - toggle_attributes: HTML attributes added to the toggle button.
+ * - splitbutton_item_list: an array of render elements that will populate the
+ *   list items.
+ * - title: This is a string that, when present, instructs splitbutton to
+ *   function like a dropdown. This becomes the label of the main element, and
+ *   this main element is what toggles the item list.
+ * - exclude_toggle: Boolean that defaults to FALSE. When TRUE, this will
+ *   prevent a dedicated toggle button from rendering. This is for elements
+ *   that extend splitbutton that don't necessarily want the toggle button.
+ *
+ * @see template_preprocess_splitbutton()
+ */
+#}
+{%
+  set container_classes = [
+    'splitbutton',
+    splitbutton_multiple == true ? 'splitbutton--multiple' : 'splitbutton--single',
+  ]
+%}
+{%
+  set toggle_button_classes = [
+    'button',
+    'splitbutton__toggle',
+    title ? 'splitbutton__toggle--with-title' : 'splitbutton__toggle--no-title',
+  ]
+%}
+{%
+  set main_element_classes = [
+    'splitbutton__main-button',
+    'button',
+  ]
+%}
+{# Add modifier classes based on the splitbutton variant types .#}
+{% for variant in variants %}
+  {% set container_classes = container_classes|merge(['splitbutton--' ~ variant]) %}
+  {% set toggle_button_classes = toggle_button_classes|merge(['button--' ~ variant]) %}
+  {% set main_element_classes = main_element_classes|merge(['button--' ~ variant]) %}
+{% endfor %}
+
+{% if main_element %}
+  {% set main_element_classes = main_element_classes|merge(['splitbutton__main-button--' ~ main_element['#type']]) %}
+  {#
+      Since main_element can be any number of different render element types,
+      splitbutton-specific classes must be added here instead of in their own
+      template.
+  #}
+  {% set main_element = main_element|merge({
+    '#attributes': main_element['#attributes']|merge({
+      'class': main_element['#attributes']['class']|merge(main_element_classes),
+    })}) %}
+{% endif %}
+
+{% set toggle_with_title = main_element is empty and title is not empty %}
+
+<div{{ attributes.addClass(container_classes) }}>
+  <div class="splitbutton__main" data-drupal-splitbutton-main>
+    {{ main_element }}
+    {% if splitbutton_multiple and exclude_toggle == FALSE %}
+        <button {{ toggle_attributes.addClass(toggle_button_classes) }} {{ toggle_with_title ? 'data-drupal-splitbutton-toggle-with-title' }}>
+          {{ title }}
+        </button>
+    {% endif %}
+  </div>
+  {{ splitbutton_item_list }}
+</div>
diff --git a/core/themes/claro/claro.info.yml b/core/themes/claro/claro.info.yml
index 99397ee0fc..73330bc699 100644
--- a/core/themes/claro/claro.info.yml
+++ b/core/themes/claro/claro.info.yml
@@ -98,6 +98,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 d4fbca8419..a7feef6a10 100644
--- a/core/themes/claro/claro.libraries.yml
+++ b/core/themes/claro/claro.libraries.yml
@@ -273,6 +273,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/css/base/variables.pcss.css b/core/themes/claro/css/base/variables.pcss.css
index c0987bb801..446eb73dd6 100644
--- a/core/themes/claro/css/base/variables.pcss.css
+++ b/core/themes/claro/css/base/variables.pcss.css
@@ -146,6 +146,38 @@
   --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);
+  --dropbutton-spacing-size: var(--space-m);
+  --dropbutton-font-size: var(--font-size-base);
+  --dropbutton-line-height: var(--space-m);
+  --dropbutton-toggle-size: 3rem;
+  --dropbutton-border-size: 1px;
+  --dropbutton-item-padding: calc(var(--dropbutton-spacing-size) - var(--dropbutton-border-size));
+  --dropbutton-toggle-size-spacing: var(--dropbutton-border-size);
+  --dropbutton-border-radius-size: 2px;
+  --dropbutton-small-spacing-size: 0.625rem;
+  --dropbutton-small-font-size: var(--font-size-xs);
+  --dropbutton-small-line-height: 0.75rem;
+  --dropbutton-small-toggle-size: calc((2 * var(--dropbutton-small-spacing-size)) + var(--dropbutton-small-line-height));
+  --dropbutton-extrasmall-spacing-size: 0.375rem;
+  --dropbutton-extrasmall-font-size: var(--font-size-xs);
+  --dropbutton-extrasmall-line-height: 0.75rem;
+  --dropbutton-extrasmall-toggle-size: calc((2 * var(--dropbutton-extrasmall-spacing-size)) + var(--dropbutton-extrasmall-line-height));
+  --splitbutton-item-border-size: var(--dropbutton-border-size);
+  --splitbutton-item-border-size--focus: 3px;
+  --splitbutton-item-font-size: var(--font-size-base);
+  --splitbutton-item-line-height: var(--font-size-base);
+  --splitbutton-item-padding: var(--dropbutton-item-padding);
+  --splitbutton-item-padding-vertical--small: var(--dropbutton-small-spacing-size);
+  --splitbutton-item-padding-vertical--extrasmall: var(--dropbutton-extrasmall-spacing-size);
+  --splitbutton-item-padding--focus: calc(var(--splitbutton-item-padding) - var(--splitbutton-item-border-size--focus) + var(--splitbutton-item-border-size));
+  --splitbutton-item-padding-vertical--focus--small: calc(var(--splitbutton-item-padding-vertical--small) - var(--splitbutton-item-border-size--focus) + var(--splitbutton-item-border-size));
+  --splitbutton-item-padding-vertical--focus--extrasmall: calc(var(--splitbutton-item-padding-vertical--extrasmall) - var(--splitbutton-item-border-size--focus) + var(--splitbutton-item-border-size));
   /**
    * jQuery.UI dropdown.
    */
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/dropbutton.css b/core/themes/claro/css/components/dropbutton.css
index 18678113b7..4d35b53940 100644
--- a/core/themes/claro/css/components/dropbutton.css
+++ b/core/themes/claro/css/components/dropbutton.css
@@ -61,14 +61,6 @@
    */
 }
 
-:root {
-  /**
-  * Dropbutton
-  */
-  /* Variant variables: small. */
-  /* Variant variables: extra small. */
-}
-
 .dropbutton-wrapper {
   display: inline-flex;
   border-radius: 2px;
diff --git a/core/themes/claro/css/components/dropbutton.pcss.css b/core/themes/claro/css/components/dropbutton.pcss.css
index 2da904bd59..6329320d4a 100644
--- a/core/themes/claro/css/components/dropbutton.pcss.css
+++ b/core/themes/claro/css/components/dropbutton.pcss.css
@@ -9,29 +9,6 @@
 
 @import "../base/variables.pcss.css";
 
-:root {
-  /**
-  * Dropbutton
-  */
-  --dropbutton-spacing-size: var(--space-m);
-  --dropbutton-font-size: var(--font-size-base);
-  --dropbutton-line-height: var(--space-m);
-  --dropbutton-toggle-size: 3rem;
-  --dropbutton-border-size: 1px;
-  --dropbutton-toggle-size-spacing: var(--dropbutton-border-size);
-  --dropbutton-border-radius-size: 2px;
-  /* Variant variables: small. */
-  --dropbutton-small-spacing-size: 0.625rem;
-  --dropbutton-small-font-size: var(--font-size-xs);
-  --dropbutton-small-line-height: 0.75rem;
-  --dropbutton-small-toggle-size: calc((2 * var(--dropbutton-small-spacing-size)) + var(--dropbutton-small-line-height));
-  /* Variant variables: extra small. */
-  --dropbutton-extrasmall-spacing-size: 0.375rem;
-  --dropbutton-extrasmall-font-size: var(--font-size-xs);
-  --dropbutton-extrasmall-line-height: 0.75rem;
-  --dropbutton-extrasmall-toggle-size: calc((2 * var(--dropbutton-extrasmall-spacing-size)) + var(--dropbutton-extrasmall-line-height));
-}
-
 .dropbutton-wrapper {
   display: inline-flex;
   border-radius: var(--button-border-radius-size);
@@ -339,7 +316,7 @@
 .dropbutton__item:first-of-type ~ .dropbutton__item > a,
 .dropbutton__item:first-of-type ~ .dropbutton__item > .button {
   position: relative;
-  padding: calc(var(--dropbutton-spacing-size) - var(--dropbutton-border-size));
+  padding: var(--dropbutton-item-padding);
   text-decoration: none;
   color: var(--color-davysgray);
   border: var(--dropbutton-border-size) solid transparent !important; /* 1 */
diff --git a/core/themes/claro/css/components/splitbutton.css b/core/themes/claro/css/components/splitbutton.css
new file mode 100644
index 0000000000..d37f40383f
--- /dev/null
+++ b/core/themes/claro/css/components/splitbutton.css
@@ -0,0 +1,222 @@
+/*
+ * DO NOT EDIT THIS FILE.
+ * See the following change record for more information,
+ * https://www.drupal.org/node/3084859
+ * @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 gray 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. */
+  /**
+   * jQuery.UI dialog.
+   */
+  /**
+   * 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::after {
+  position: absolute;
+  top: 50%;
+  right: calc(1.5rem - 1px);
+  width: 0.875rem;
+  height: 0.5625rem;
+  content: "";
+  transform: translate(50%, -50%) rotate(0);
+  border: none;
+  background: url("data:image/svg+xml,%3csvg width='14' height='9' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M.238 1.938L1.647.517 7 5.819 12.354.517l1.408 1.421L7 8.636z' fill='%23222330'/%3e%3c/svg%3e") no-repeat center;
+  background-size: contain;
+}
+
+[data-drupal-splitbutton-open] .splitbutton__toggle::after {
+  transform: translate(50%, -50%) rotate(180deg);
+}
+
+[dir="rtl"] [data-drupal-splitbutton-open] .splitbutton__toggle::after {
+  transform: translate(50%, -50%) rotate(-180deg);
+}
+
+.splitbutton__toggle--with-title.button {
+  padding-right: calc(3rem - 2px);
+}
+
+.js .splitbutton__operation-list {
+  list-style: none;
+}
+
+.js .splitbutton__operation-list li:not(:focus) {
+  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;
+  white-space: nowrap;
+  text-decoration: none;
+  color: #545560;
+  /*
+   * Styling for borders require !important due to .button having border styles
+   * set to !important in button.pcss.css.
+   */
+  border: 1px solid #d4d4d8 !important;
+  border-top-color: transparent !important;
+  border-bottom-color: transparent !important;
+  border-radius: 2px;
+  background: #fff;
+  box-shadow: none;
+  font-size: 1rem;
+  font-weight: normal;
+  line-height: 1rem;
+  -webkit-font-smoothing: antialiased;
+}
+
+.js .splitbutton__operation-list-item:focus {
+  z-index: 5;
+  padding: calc(1rem - 3px);
+  /*
+   * Styling for borders require !important due to .button having border styles
+   * set to !important in button.pcss.css.
+   */
+  border: 3px solid #26a769 !important;
+  outline: 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::after,
+.no-touchevents .splitbutton--extrasmall .splitbutton__toggle::after {
+  right: calc(1rem - 1px);
+  width: 0.75rem;
+}
+
+.js .splitbutton__toggle--with-title.button--small {
+  padding-right: 2rem;
+}
+
+.no-touchevents .splitbutton--extrasmall .splitbutton__toggle::after {
+  right: calc(0.75rem - 1px);
+}
+
+.js .splitbutton__toggle--with-title.button--extrasmall {
+  padding-right: calc(1.5rem + 2px);
+}
+
+.js .splitbutton--primary .splitbutton__toggle::after,
+.js .splitbutton--danger .splitbutton__toggle::after {
+  background: url("data:image/svg+xml,%3csvg width='14' height='9' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M.238 1.938L1.647.517 7 5.819 12.354.517l1.408 1.421L7 8.636z' fill='%23ffffff'/%3e%3c/svg%3e") no-repeat center;
+}
+
+.js .splitbutton--small .splitbutton__operation-list-item,
+.js .splitbutton--extrasmall .splitbutton__operation-list-item {
+  padding-top: 0.625rem;
+  padding-bottom: 0.625rem;
+  font-size: 0.79rem;
+  line-height: 0.79rem;
+}
+
+.no-touchevents .splitbutton--extrasmall .splitbutton__operation-list-item {
+  padding-top: 0.375rem;
+  padding-bottom: 0.375rem;
+}
+
+.js .splitbutton--small .splitbutton__operation-list-item:focus,
+.js .splitbutton--extrasmall .splitbutton__operation-list-item:focus {
+  padding-top: calc(0.625rem - 2px);
+  padding-bottom: calc(0.625rem - 2px);
+}
+
+.no-touchevents .splitbutton--extrasmall .splitbutton__operation-list-item:focus {
+  padding-top: calc(0.375rem - 2px);
+  padding-bottom: calc(0.375rem - 2px);
+}
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..3e0c4689ef
--- /dev/null
+++ b/core/themes/claro/css/components/splitbutton.pcss.css
@@ -0,0 +1,162 @@
+@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::after {
+  position: absolute;
+  top: 50%;
+  right: var(--button--horizontal-padding);
+  width: 0.875rem;
+  height: 0.5625rem;
+  content: "";
+  transform: translate(50%, -50%) rotate(0);
+  border: none;
+  background: url(../../images/icons/222330/chevron-down.svg) no-repeat center;
+  background-size: contain;
+}
+[data-drupal-splitbutton-open] .splitbutton__toggle::after {
+  transform: translate(50%, -50%) rotate(180deg);
+}
+[dir="rtl"] [data-drupal-splitbutton-open] .splitbutton__toggle::after {
+  transform: translate(50%, -50%) rotate(-180deg);
+}
+
+.splitbutton__toggle--with-title.button {
+  padding-right: calc(var(--button--horizontal-padding) * 2);
+}
+
+.js .splitbutton__operation-list {
+  list-style: none;
+}
+
+.js .splitbutton__operation-list li:not(:focus) {
+  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: var(--splitbutton-item-padding);
+  text-align: left;
+  white-space: nowrap;
+  text-decoration: none;
+  color: #545560;
+  /*
+   * Styling for borders require !important due to .button having border styles
+   * set to !important in button.pcss.css.
+   */
+  border: var(--splitbutton-item-border-size) solid var(--color-lightgray) !important;
+  border-top-color: transparent !important;
+  border-bottom-color: transparent !important;
+  border-radius: 2px;
+  background: #fff;
+  box-shadow: none;
+  font-size: var(--splitbutton-item-font-size);
+  font-weight: normal;
+  line-height: var(--splitbutton-item-line-height);
+  -webkit-font-smoothing: antialiased;
+}
+.js .splitbutton__operation-list-item:focus {
+  z-index: 5;
+  padding: var(--splitbutton-item-padding--focus);
+  /*
+   * Styling for borders require !important due to .button having border styles
+   * set to !important in button.pcss.css.
+   */
+  border: var(--splitbutton-item-border-size--focus) solid var(--color-focus) !important;
+  outline: 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::after,
+.no-touchevents .splitbutton--extrasmall .splitbutton__toggle::after {
+  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::after {
+  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::after,
+.js .splitbutton--danger .splitbutton__toggle::after {
+  background: url(../../images/icons/ffffff/chevron-down.svg) no-repeat center;
+}
+
+.js .splitbutton--small .splitbutton__operation-list-item,
+.js .splitbutton--extrasmall .splitbutton__operation-list-item {
+  padding-top: var(--splitbutton-item-padding-vertical--small);
+  padding-bottom: var(--splitbutton-item-padding-vertical--small);
+  font-size: var(--font-size-xs);
+  line-height: var(--font-size-xs);
+}
+
+.no-touchevents .splitbutton--extrasmall .splitbutton__operation-list-item {
+  padding-top: var(--splitbutton-item-padding-vertical--extrasmall);
+  padding-bottom: var(--splitbutton-item-padding-vertical--extrasmall);
+}
+
+.js .splitbutton--small .splitbutton__operation-list-item:focus,
+.js .splitbutton--extrasmall .splitbutton__operation-list-item:focus {
+  padding-top: var(--splitbutton-item-padding-vertical--focus--small);
+  padding-bottom: var(--splitbutton-item-padding-vertical--focus--small);
+}
+
+.no-touchevents .splitbutton--extrasmall .splitbutton__operation-list-item:focus {
+  padding-top: var(--splitbutton-item-padding-vertical--focus--extrasmall);
+  padding-bottom: var(--splitbutton-item-padding-vertical--focus--extrasmall);
+}
diff --git a/core/themes/claro/images/icons/ffffff/chevron-down.svg b/core/themes/claro/images/icons/ffffff/chevron-down.svg
new file mode 100644
index 0000000000..b4ddb8e8ff
--- /dev/null
+++ b/core/themes/claro/images/icons/ffffff/chevron-down.svg
@@ -0,0 +1 @@
+<svg width="14" height="9" xmlns="http://www.w3.org/2000/svg"><path d="M.238 1.938L1.647.517 7 5.819 12.354.517l1.408 1.421L7 8.636z" fill="#ffffff"/></svg>
\ No newline at end of file
diff --git a/core/themes/claro/templates/form/item-list--splitbutton.html.twig b/core/themes/claro/templates/form/item-list--splitbutton.html.twig
new file mode 100644
index 0000000000..235e88d8b7
--- /dev/null
+++ b/core/themes/claro/templates/form/item-list--splitbutton.html.twig
@@ -0,0 +1,20 @@
+{#
+/**
+ * @file
+ * Theme override for an item list used by splitbuttons.
+ *
+ * Available variables:
+ * - items: A list of items. Each item contains:
+ *   - attributes: HTML attributes to be applied to each list item.
+ *   - value: The content of the list element.
+ * - list_type: The tag for list element ("ul" or "ol").
+ * - attributes: HTML attributes to be applied to the list.
+ */
+#}
+{% if items %}
+    <{{ list_type }}{{ attributes.addClass('splitbutton__operation-list') }}>
+        {%- for item in items -%}
+            <li{{ item.attributes }}>{{ item.value }}</li>
+        {%- endfor -%}
+    </{{ list_type }}>
+{%- endif %}
diff --git a/core/themes/claro/templates/form/splitbutton.html.twig b/core/themes/claro/templates/form/splitbutton.html.twig
new file mode 100644
index 0000000000..bddee00fb9
--- /dev/null
+++ b/core/themes/claro/templates/form/splitbutton.html.twig
@@ -0,0 +1,76 @@
+{#
+/**
+ * @file
+ * Theme override for a splitbutton.
+ *
+ * Available variables:
+ * - main_element: A render array of either a link, button, or submit element.
+ *   This is the element that is always visible in a splitbutton.
+ * - variants: An array of strings defined in #splitbutton_type, used for
+ *   adding type-specific classes to elements within the splitbutton.
+ * - attributes: HTML attributes added to the splitbutton container.
+ * - toggle_attributes: HTML attributes added to the toggle button.
+ * - splitbutton_item_list: an array of render elements that will populate the
+ *   list items.
+ * - title: This is a string that, when present, instructs splitbutton to
+ *   function like a dropdown. This becomes the label of the main element, and
+ *   this main element is what toggles the item list.
+ * - exclude_toggle: Boolean that defaults to FALSE. When TRUE, this will
+ *   prevent a dedicated toggle button from rendering. This is for elements
+ *   that extend splitbutton that don't necessarily want the toggle button.
+ *
+ * @see template_preprocess_splitbutton()
+ */
+#}
+{%
+  set container_classes = [
+    'splitbutton',
+    splitbutton_multiple == true ? 'splitbutton--multiple' : 'splitbutton--single',
+  ]
+%}
+{%
+  set toggle_button_classes = [
+    'button',
+    'splitbutton__toggle',
+    title ? 'splitbutton__toggle--with-title' : 'splitbutton__toggle--no-title',
+  ]
+%}
+{%
+  set main_element_classes = [
+    'splitbutton__main-button',
+    'button',
+  ]
+%}
+{# Add modifier classes based on the splitbutton variant types .#}
+{% for variant in variants %}
+  {% set container_classes = container_classes|merge(['splitbutton--' ~ variant]) %}
+  {% set toggle_button_classes = toggle_button_classes|merge(['button--' ~ variant]) %}
+  {% set main_element_classes = main_element_classes|merge(['button--' ~ variant]) %}
+{% endfor %}
+
+{% if main_element %}
+  {% set main_element_classes = main_element_classes|merge(['splitbutton__main-button--' ~ main_element['#type']]) %}
+  {#
+      Since main_element can be any number of different render element types,
+      splitbutton-specific classes must be added here instead of in their own
+      template.
+  #}
+  {% set main_element = main_element|merge({
+    '#attributes': main_element['#attributes']|merge({
+      'class': main_element['#attributes']['class']|merge(main_element_classes),
+    })}) %}
+{% endif %}
+
+{% set toggle_with_title = main_element is empty and title is not empty %}
+
+<div{{ attributes.addClass(container_classes) }}>
+  <div class="splitbutton__main" data-drupal-splitbutton-main>
+    {{ main_element }}
+    {% if splitbutton_multiple and exclude_toggle == FALSE %}
+        <button {{ toggle_attributes.addClass(toggle_button_classes) }} {{ toggle_with_title ? 'data-drupal-splitbutton-toggle-with-title' }}>
+          {{ title }}
+        </button>
+    {% endif %}
+  </div>
+  {{ splitbutton_item_list }}
+</div>
diff --git a/core/themes/seven/css/components/splitbutton.css b/core/themes/seven/css/components/splitbutton.css
new file mode 100644
index 0000000000..8c8f5283f3
--- /dev/null
+++ b/core/themes/seven/css/components/splitbutton.css
@@ -0,0 +1,152 @@
+.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;
+  margin: 0;
+}
+
+.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 [data-drupal-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;
+  margin: 0;
+  padding: 2px 0 0 0;
+  list-style: none;
+}
+.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 {
+  display: block;
+  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.1px 0 0.1px, -0.1px 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/seven/templates/item-list--splitbutton.html.twig b/core/themes/seven/templates/item-list--splitbutton.html.twig
new file mode 100644
index 0000000000..235e88d8b7
--- /dev/null
+++ b/core/themes/seven/templates/item-list--splitbutton.html.twig
@@ -0,0 +1,20 @@
+{#
+/**
+ * @file
+ * Theme override for an item list used by splitbuttons.
+ *
+ * Available variables:
+ * - items: A list of items. Each item contains:
+ *   - attributes: HTML attributes to be applied to each list item.
+ *   - value: The content of the list element.
+ * - list_type: The tag for list element ("ul" or "ol").
+ * - attributes: HTML attributes to be applied to the list.
+ */
+#}
+{% if items %}
+    <{{ list_type }}{{ attributes.addClass('splitbutton__operation-list') }}>
+        {%- for item in items -%}
+            <li{{ item.attributes }}>{{ item.value }}</li>
+        {%- endfor -%}
+    </{{ list_type }}>
+{%- endif %}
diff --git a/core/themes/seven/templates/splitbutton.html.twig b/core/themes/seven/templates/splitbutton.html.twig
new file mode 100644
index 0000000000..bddee00fb9
--- /dev/null
+++ b/core/themes/seven/templates/splitbutton.html.twig
@@ -0,0 +1,76 @@
+{#
+/**
+ * @file
+ * Theme override for a splitbutton.
+ *
+ * Available variables:
+ * - main_element: A render array of either a link, button, or submit element.
+ *   This is the element that is always visible in a splitbutton.
+ * - variants: An array of strings defined in #splitbutton_type, used for
+ *   adding type-specific classes to elements within the splitbutton.
+ * - attributes: HTML attributes added to the splitbutton container.
+ * - toggle_attributes: HTML attributes added to the toggle button.
+ * - splitbutton_item_list: an array of render elements that will populate the
+ *   list items.
+ * - title: This is a string that, when present, instructs splitbutton to
+ *   function like a dropdown. This becomes the label of the main element, and
+ *   this main element is what toggles the item list.
+ * - exclude_toggle: Boolean that defaults to FALSE. When TRUE, this will
+ *   prevent a dedicated toggle button from rendering. This is for elements
+ *   that extend splitbutton that don't necessarily want the toggle button.
+ *
+ * @see template_preprocess_splitbutton()
+ */
+#}
+{%
+  set container_classes = [
+    'splitbutton',
+    splitbutton_multiple == true ? 'splitbutton--multiple' : 'splitbutton--single',
+  ]
+%}
+{%
+  set toggle_button_classes = [
+    'button',
+    'splitbutton__toggle',
+    title ? 'splitbutton__toggle--with-title' : 'splitbutton__toggle--no-title',
+  ]
+%}
+{%
+  set main_element_classes = [
+    'splitbutton__main-button',
+    'button',
+  ]
+%}
+{# Add modifier classes based on the splitbutton variant types .#}
+{% for variant in variants %}
+  {% set container_classes = container_classes|merge(['splitbutton--' ~ variant]) %}
+  {% set toggle_button_classes = toggle_button_classes|merge(['button--' ~ variant]) %}
+  {% set main_element_classes = main_element_classes|merge(['button--' ~ variant]) %}
+{% endfor %}
+
+{% if main_element %}
+  {% set main_element_classes = main_element_classes|merge(['splitbutton__main-button--' ~ main_element['#type']]) %}
+  {#
+      Since main_element can be any number of different render element types,
+      splitbutton-specific classes must be added here instead of in their own
+      template.
+  #}
+  {% set main_element = main_element|merge({
+    '#attributes': main_element['#attributes']|merge({
+      'class': main_element['#attributes']['class']|merge(main_element_classes),
+    })}) %}
+{% endif %}
+
+{% set toggle_with_title = main_element is empty and title is not empty %}
+
+<div{{ attributes.addClass(container_classes) }}>
+  <div class="splitbutton__main" data-drupal-splitbutton-main>
+    {{ main_element }}
+    {% if splitbutton_multiple and exclude_toggle == FALSE %}
+        <button {{ toggle_attributes.addClass(toggle_button_classes) }} {{ toggle_with_title ? 'data-drupal-splitbutton-toggle-with-title' }}>
+          {{ title }}
+        </button>
+    {% endif %}
+  </div>
+  {{ splitbutton_item_list }}
+</div>
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..f12706d860
--- /dev/null
+++ b/core/themes/stable/css/core/splitbutton/splitbutton.css
@@ -0,0 +1,42 @@
+[data-drupal-splitbutton-main] {
+  position: relative;
+  display: inline-flex;
+}
+.js [data-drupal-splitbutton-item-list] {
+  display: none;
+}
+[data-drupal-splitbutton-open] [data-drupal-splitbutton-item-list] {
+  z-index: 4;
+  display: block;
+}
+
+[data-drupal-splitbutton-trigger] {
+  position: relative;
+}
+
+[data-drupal-splitbutton-trigger]::after {
+  position: absolute;
+  top: 50%;
+  right: 6px; /* 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"] [data-drupal-splitbutton-trigger]::after {
+  right: auto;
+  left: 6px;
+}
+[data-drupal-splitbutton-open] [data-drupal-splitbutton-trigger]::after {
+  top: 48%;
+  transform: translate(50%, -50%) rotate(180deg);
+}
+
+html:not(.js) [data-drupal-splitbutton-trigger] {
+  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/dataset/item-list--splitbutton.html.twig b/core/themes/stable/templates/dataset/item-list--splitbutton.html.twig
new file mode 100644
index 0000000000..4d3a96dbe9
--- /dev/null
+++ b/core/themes/stable/templates/dataset/item-list--splitbutton.html.twig
@@ -0,0 +1,20 @@
+{#
+/**
+ * @file
+ * Theme override for an item list used by splitbuttons.
+ *
+ * Available variables:
+ * - items: A list of items. Each item contains:
+ *   - attributes: HTML attributes to be applied to each list item.
+ *   - value: The content of the list element.
+ * - list_type: The tag for list element ("ul" or "ol").
+ * - attributes: HTML attributes to be applied to the list.
+ */
+#}
+{% if items %}
+    <{{ list_type }} {{ attributes }}>
+        {%- for item in items -%}
+            <li{{ item.attributes }}>{{ item.value }}</li>
+        {%- endfor -%}
+    </{{ list_type }}>
+{%- endif %}
diff --git a/core/themes/stable/templates/misc/splitbutton.html.twig b/core/themes/stable/templates/misc/splitbutton.html.twig
new file mode 100644
index 0000000000..1b1bcc51bf
--- /dev/null
+++ b/core/themes/stable/templates/misc/splitbutton.html.twig
@@ -0,0 +1,37 @@
+{#
+/**
+ * @file
+ * Theme override for a splitbutton.
+ *
+ * Available variables:
+ * - main_element: A render array of either a link, button, or submit element.
+ *   This is the element that is always visible in a splitbutton.
+ * - variants: An array of strings defined in #splitbutton_type, used for
+ *   adding type-specific classes to elements within the splitbutton.
+ * - attributes: HTML attributes added to the splitbutton container.
+ * - toggle_attributes: HTML attributes added to the toggle button.
+ * - splitbutton_item_list: an array of render elements that will populate the
+ *   list items.
+ * - title: This is a string that, when present, instructs splitbutton to
+ *   function like a dropdown. This becomes the label of the main element, and
+ *   this main element is what toggles the item list.
+ * - exclude_toggle: Boolean that defaults to FALSE. When TRUE, this will
+ *   prevent a dedicated toggle button from rendering. This is for elements
+ *   that extend splitbutton that don't necessarily want the toggle button.
+ *
+ * @see template_preprocess_splitbutton()
+ */
+#}
+{% set toggle_with_title = main_element is empty and title is not empty %}
+
+<div {{ attributes }}>
+  <div data-drupal-splitbutton-main>
+    {{ main_element }}
+    {% if splitbutton_multiple and exclude_toggle == FALSE %}
+        <button {{ toggle_attributes }} {{ toggle_with_title ? 'data-drupal-splitbutton-toggle-with-title' }}>
+          {{ title }}
+        </button>
+    {% endif %}
+  </div>
+  {{ splitbutton_item_list }}
+</div>
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..f12706d860
--- /dev/null
+++ b/core/themes/stable9/css/core/splitbutton/splitbutton.css
@@ -0,0 +1,42 @@
+[data-drupal-splitbutton-main] {
+  position: relative;
+  display: inline-flex;
+}
+.js [data-drupal-splitbutton-item-list] {
+  display: none;
+}
+[data-drupal-splitbutton-open] [data-drupal-splitbutton-item-list] {
+  z-index: 4;
+  display: block;
+}
+
+[data-drupal-splitbutton-trigger] {
+  position: relative;
+}
+
+[data-drupal-splitbutton-trigger]::after {
+  position: absolute;
+  top: 50%;
+  right: 6px; /* 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"] [data-drupal-splitbutton-trigger]::after {
+  right: auto;
+  left: 6px;
+}
+[data-drupal-splitbutton-open] [data-drupal-splitbutton-trigger]::after {
+  top: 48%;
+  transform: translate(50%, -50%) rotate(180deg);
+}
+
+html:not(.js) [data-drupal-splitbutton-trigger] {
+  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/dataset/item-list--splitbutton.html.twig b/core/themes/stable9/templates/dataset/item-list--splitbutton.html.twig
new file mode 100644
index 0000000000..4d3a96dbe9
--- /dev/null
+++ b/core/themes/stable9/templates/dataset/item-list--splitbutton.html.twig
@@ -0,0 +1,20 @@
+{#
+/**
+ * @file
+ * Theme override for an item list used by splitbuttons.
+ *
+ * Available variables:
+ * - items: A list of items. Each item contains:
+ *   - attributes: HTML attributes to be applied to each list item.
+ *   - value: The content of the list element.
+ * - list_type: The tag for list element ("ul" or "ol").
+ * - attributes: HTML attributes to be applied to the list.
+ */
+#}
+{% if items %}
+    <{{ list_type }} {{ attributes }}>
+        {%- for item in items -%}
+            <li{{ item.attributes }}>{{ item.value }}</li>
+        {%- endfor -%}
+    </{{ list_type }}>
+{%- endif %}
diff --git a/core/themes/stable9/templates/misc/splitbutton.html.twig b/core/themes/stable9/templates/misc/splitbutton.html.twig
new file mode 100644
index 0000000000..1b1bcc51bf
--- /dev/null
+++ b/core/themes/stable9/templates/misc/splitbutton.html.twig
@@ -0,0 +1,37 @@
+{#
+/**
+ * @file
+ * Theme override for a splitbutton.
+ *
+ * Available variables:
+ * - main_element: A render array of either a link, button, or submit element.
+ *   This is the element that is always visible in a splitbutton.
+ * - variants: An array of strings defined in #splitbutton_type, used for
+ *   adding type-specific classes to elements within the splitbutton.
+ * - attributes: HTML attributes added to the splitbutton container.
+ * - toggle_attributes: HTML attributes added to the toggle button.
+ * - splitbutton_item_list: an array of render elements that will populate the
+ *   list items.
+ * - title: This is a string that, when present, instructs splitbutton to
+ *   function like a dropdown. This becomes the label of the main element, and
+ *   this main element is what toggles the item list.
+ * - exclude_toggle: Boolean that defaults to FALSE. When TRUE, this will
+ *   prevent a dedicated toggle button from rendering. This is for elements
+ *   that extend splitbutton that don't necessarily want the toggle button.
+ *
+ * @see template_preprocess_splitbutton()
+ */
+#}
+{% set toggle_with_title = main_element is empty and title is not empty %}
+
+<div {{ attributes }}>
+  <div data-drupal-splitbutton-main>
+    {{ main_element }}
+    {% if splitbutton_multiple and exclude_toggle == FALSE %}
+        <button {{ toggle_attributes }} {{ toggle_with_title ? 'data-drupal-splitbutton-toggle-with-title' }}>
+          {{ title }}
+        </button>
+    {% endif %}
+  </div>
+  {{ splitbutton_item_list }}
+</div>
