diff --git a/assets/css/contextual/contextual.dropdown.css b/assets/css/contextual/contextual.dropdown.css
new file mode 100644
index 0000000000000000000000000000000000000000..abc940ef355c8398eb8fe12bbe2309cb9ebc6e1f
--- /dev/null
+++ b/assets/css/contextual/contextual.dropdown.css
@@ -0,0 +1,3 @@
+.contextual .dropdown-menu {
+  --bs-dropdown-min-width: auto;
+}
diff --git a/assets/css/contextual/contextual.theme.css b/assets/css/contextual/contextual.theme.css
new file mode 100644
index 0000000000000000000000000000000000000000..582556a4267f989a21fe596a7d6f9968707ae84f
--- /dev/null
+++ b/assets/css/contextual/contextual.theme.css
@@ -0,0 +1,4 @@
+.contextual.open .trigger {
+  border-bottom-color: #ccc !important;
+  border-radius: 13px !important;
+}
diff --git a/assets/js/contextual/contextual.js b/assets/js/contextual/contextual.js
new file mode 100644
index 0000000000000000000000000000000000000000..491036118450fc54329a44f672dbf295b65c780f
--- /dev/null
+++ b/assets/js/contextual/contextual.js
@@ -0,0 +1,17 @@
+(($, Drupal) => {
+  /**
+   * Theme function for a contextual trigger.
+   *
+   * @return {string}
+   *   A string representing a DOM fragment.
+   */
+  Drupal.theme.contextualTrigger = () => {
+    return (
+      '<button class="trigger focusable visually-hidden dropdown-toggle"' +
+      'type="button"' +
+      'data-bs-toggle="dropdown"' +
+      'aria-expanded="false"' +
+      '></button>'
+    );
+  };
+})(jQuery, Drupal);
diff --git a/src/Element/ElementPreRenderContextualLinksPlaceholder.php b/src/Element/ElementPreRenderContextualLinksPlaceholder.php
new file mode 100644
index 0000000000000000000000000000000000000000..12452371d7257ad1dcb34d01c9a8d5ea036bc7a8
--- /dev/null
+++ b/src/Element/ElementPreRenderContextualLinksPlaceholder.php
@@ -0,0 +1,65 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\ui_suite_bootstrap\Element;
+
+use Drupal\Component\Render\FormattableMarkup;
+use Drupal\Component\Render\MarkupInterface;
+use Drupal\Component\Utility\Html;
+use Drupal\Core\Security\TrustedCallbackInterface;
+use Drupal\Core\Template\Attribute;
+
+/**
+ * Element Prerender methods for contextual_links_placeholder.
+ */
+class ElementPreRenderContextualLinksPlaceholder implements TrustedCallbackInterface {
+
+  /**
+   * Add classes for dropdown styling.
+   */
+  public static function preRenderContextualLinksPlaceholder(array $element): array {
+    if (!isset($element['#markup']) || !($element['#markup'] instanceof MarkupInterface)) {
+      return $element;
+    }
+
+    $placeholder = (string) $element['#markup'];
+    $attributes = static::extractAttributes($placeholder);
+    $attributes['class'][] = 'dropdown';
+    $attributes['class'][] = 'position-absolute';
+
+    $attribute = new Attribute($attributes);
+    $element['#markup'] = new FormattableMarkup('<div@attributes></div>', ['@attributes' => $attribute]);
+    return $element;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function trustedCallbacks(): array {
+    return ['preRenderContextualLinksPlaceholder'];
+  }
+
+  /**
+   * Extract attributes.
+   *
+   * @param string $html
+   *   The HTML to parse. Expected to be one div.
+   *
+   * @return array
+   *   The array of attributes.
+   */
+  protected static function extractAttributes(string $html): array {
+    $attributes = [];
+    // Extract existing attributes.
+    /** @var \DOMElement $div */
+    foreach (Html::load($html)->getElementsByTagName('div') as $div) {
+      /** @var \DOMAttr $attr */
+      foreach ($div->attributes as $attr) {
+        $attributes[$attr->nodeName] = $attr->nodeValue;
+      }
+    }
+    return $attributes;
+  }
+
+}
diff --git a/src/HookHandler/ElementInfoAlter.php b/src/HookHandler/ElementInfoAlter.php
index afee3c14cc4e6b317c52f1c34d7015b73b417d55..1964d1ff987a6ef0af4aaad6410f3a2e44100e9a 100644
--- a/src/HookHandler/ElementInfoAlter.php
+++ b/src/HookHandler/ElementInfoAlter.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
 
 namespace Drupal\ui_suite_bootstrap\HookHandler;
 
+use Drupal\ui_suite_bootstrap\Element\ElementPreRenderContextualLinksPlaceholder;
 use Drupal\ui_suite_bootstrap\Element\ElementPreRenderDropbutton;
 use Drupal\ui_suite_bootstrap\Element\ElementPreRenderLayoutBuilder;
 use Drupal\ui_suite_bootstrap\Element\ElementPreRenderLink;
@@ -190,6 +191,14 @@ class ElementInfoAlter {
       ];
     }
 
+    // Contextual link placeholder.
+    if (isset($info['contextual_links_placeholder'])) {
+      $info['contextual_links_placeholder']['#pre_render'][] = [
+        ElementPreRenderContextualLinksPlaceholder::class,
+        'preRenderContextualLinksPlaceholder',
+      ];
+    }
+
     // Dropbutton.
     if (isset($info['dropbutton'])) {
       // Remove Core pre_render to remove wrapper and classes.
diff --git a/src/HookHandler/PreprocessLinksContextual.php b/src/HookHandler/PreprocessLinksContextual.php
new file mode 100644
index 0000000000000000000000000000000000000000..7dc222423d5d52ba5d8ee32ce9f0a066ecf402b6
--- /dev/null
+++ b/src/HookHandler/PreprocessLinksContextual.php
@@ -0,0 +1,12 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\ui_suite_bootstrap\HookHandler;
+
+/**
+ * Ensure links structure fits into list group structure.
+ */
+class PreprocessLinksContextual extends PreprocessLinksMediaLibraryMenu {
+
+}
diff --git a/templates/system/links--contextual.html.twig b/templates/system/links--contextual.html.twig
new file mode 100644
index 0000000000000000000000000000000000000000..93bf40017f41ea231b2c34bab5b63fdccf024854
--- /dev/null
+++ b/templates/system/links--contextual.html.twig
@@ -0,0 +1,86 @@
+{#
+/**
+ * @file
+ * Default theme implementation for a set of links.
+ *
+ * Available variables:
+ * - attributes: Attributes for the UL containing the list of links.
+ * - links: Links to be output.
+ *   Each link will have the following elements:
+ *   - link: (optional) A render array that returns a link. See
+ *     template_preprocess_links() for details how it is generated.
+ *   - text: The link text.
+ *   - attributes: HTML attributes for the list item element.
+ *   - text_attributes: (optional) HTML attributes for the span element if no
+ *     'url' was supplied.
+ * - heading: (optional) A heading to precede the links.
+ *   - text: The heading text.
+ *   - level: The heading level (e.g. 'h2', 'h3').
+ *   - attributes: (optional) A keyed list of attributes for the heading.
+ *   If the heading is a string, it will be used as the text of the heading and
+ *   the level will default to 'h2'.
+ *
+ *   Headings should be used on navigation menus and any list of links that
+ *   consistently appears on multiple pages. To make the heading invisible use
+ *   the 'visually-hidden' CSS class. Do not use 'display:none', which
+ *   removes it from screen readers and assistive technology. Headings allow
+ *   screen reader and keyboard only users to navigate to or skip the links.
+ *   See http://juicystudio.com/article/screen-readers-display-none.php and
+ *   http://www.w3.org/TR/WCAG-TECHS/H42.html for more information.
+ *
+ * @see template_preprocess_links()
+ *
+ * @ingroup themeable
+ */
+#}
+{% if links -%}
+  {%- if heading -%}
+    {%- if heading.level -%}
+      <{{ heading.level }}{{ heading.attributes }}>{{ heading.text }}</{{ heading.level }}>
+    {%- else -%}
+      <h2{{ heading.attributes }}>{{ heading.text }}</h2>
+    {%- endif -%}
+  {%- endif -%}
+
+  {% if preprocessed_items -%}
+    {#
+    Can't use the dropdown component directly because it handles the dropdown
+    button which is handled by JS in contextual links.
+    #}
+    <ul{{ attributes.addClass('dropdown-menu').removeClass('contextual-links') }}>
+      {% for item in preprocessed_items %}
+        {% set item_attributes = create_attribute(item.attributes|default({})) %}
+        <li{{ item_attributes }}>
+          {% set link_attributes = create_attribute(item.link_attributes|default({})) %}
+          {% if item.title and item.url %}
+            <a{{ link_attributes.setAttribute('href', item.url).addClass('dropdown-item') }}>{{ item.title }}</a>
+          {% elseif (not item.title and not item.url) or link_attributes.hasClass('dropdown-divider') %}
+            <hr{{ link_attributes.addClass('dropdown-divider') }}>
+          {% elseif item.title and not item.url %}
+            {% if link_attributes.hasClass('dropdown-header') %}
+              <h{{ heading_level }}{{ link_attributes }}>{{ item.title }}</h{{ heading_level }}>
+            {% elseif link_attributes.hasClass('dropdown-item') %}
+              <button{{ link_attributes.setAttribute('type', 'button') }}>{{ item.title }}</button>
+            {% else %}
+              <span{{ link_attributes.addClass('dropdown-item-text') }}>{{ item.title }}</span>
+            {% endif %}
+          {% endif %}
+        </li>
+      {% endfor %}
+    </ul>
+  {%- else -%}
+    <ul{{ attributes }}>
+      {%- for item in links -%}
+        <li{{ item.attributes }}>
+          {%- if item.link -%}
+            {{ item.link }}
+          {%- elseif item.text_attributes -%}
+            <span{{ item.text_attributes }}>{{ item.text }}</span>
+          {%- else -%}
+            {{ item.text }}
+          {%- endif -%}
+        </li>
+      {%- endfor -%}
+    </ul>
+  {%- endif -%}
+{%- endif %}
diff --git a/ui_suite_bootstrap.info.yml b/ui_suite_bootstrap.info.yml
index 8bbf6fe85a9dd46bb5a758ef21882e3a09ccce78..0ccc69f2b93086ff22ea01f29043e212815be66d 100644
--- a/ui_suite_bootstrap.info.yml
+++ b/ui_suite_bootstrap.info.yml
@@ -117,6 +117,8 @@ libraries-extend:
     - ui_suite_bootstrap/drupal.progress
   core/drupal.tabledrag:
     - ui_suite_bootstrap/drupal.tabledrag
+  contextual/drupal.contextual-links:
+    - ui_suite_bootstrap/drupal.contextual-links
   layout_builder/drupal.layout_builder:
     - ui_suite_bootstrap/drupal.layout_builder
   media_library/view:
diff --git a/ui_suite_bootstrap.libraries.yml b/ui_suite_bootstrap.libraries.yml
index 6d27daca7baa75482420c418edb0c5ff9ef96387..6367d016f182dea9a6ab969296ab0071bd894481 100644
--- a/ui_suite_bootstrap.libraries.yml
+++ b/ui_suite_bootstrap.libraries.yml
@@ -46,6 +46,17 @@ drupal.checkbox:
   dependencies:
     - core/drupal
 
+drupal.contextual-links:
+  js:
+    assets/js/contextual/contextual.js: {}
+  css:
+    theme:
+      assets/css/contextual/contextual.dropdown.css: {}
+      assets/css/contextual/contextual.theme.css: {}
+  dependencies:
+    - core/drupal
+    - core/jquery
+
 drupal.dialog:
   js:
     assets/js/misc/dialog/dialog.js: {}
diff --git a/ui_suite_bootstrap.theme b/ui_suite_bootstrap.theme
index 4f36bcb3b2d6d07140c2a1d664f8a0e2ca6c29b4..2536d359534fc63477ddc14f006cade3d7839833 100644
--- a/ui_suite_bootstrap.theme
+++ b/ui_suite_bootstrap.theme
@@ -22,6 +22,7 @@ use Drupal\ui_suite_bootstrap\HookHandler\PreprocessFileLink;
 use Drupal\ui_suite_bootstrap\HookHandler\PreprocessFilterTips;
 use Drupal\ui_suite_bootstrap\HookHandler\PreprocessFormElement;
 use Drupal\ui_suite_bootstrap\HookHandler\PreprocessInput;
+use Drupal\ui_suite_bootstrap\HookHandler\PreprocessLinksContextual;
 use Drupal\ui_suite_bootstrap\HookHandler\PreprocessLinksDropbutton;
 use Drupal\ui_suite_bootstrap\HookHandler\PreprocessLinksLayoutBuilderLinks;
 use Drupal\ui_suite_bootstrap\HookHandler\PreprocessLinksMediaLibraryMenu;
@@ -191,6 +192,16 @@ function ui_suite_bootstrap_preprocess_input(array &$variables): void {
   $instance->preprocess($variables);
 }
 
+/**
+ * Implements hook_preprocess_HOOK() for 'links__contextual'.
+ */
+function ui_suite_bootstrap_preprocess_links__contextual(array &$variables): void {
+  /** @var \Drupal\ui_suite_bootstrap\HookHandler\PreprocessLinksContextual $instance */
+  $instance = \Drupal::service('class_resolver')
+    ->getInstanceFromDefinition(PreprocessLinksContextual::class);
+  $instance->preprocess($variables);
+}
+
 /**
  * Implements hook_preprocess_HOOK() for 'links__dropbutton'.
  */