diff --git a/core/.eslintrc.json b/core/.eslintrc.json
index 6a6916b1a2c0aef4d1f49b21c18bf21c423f067b..7b1ed46abeca6b8ae943e070edf3cd77e47a8b29 100644
--- a/core/.eslintrc.json
+++ b/core/.eslintrc.json
@@ -21,6 +21,7 @@
     "_": true,
     "Cookies": true,
     "Backbone": true,
+    "htmx": true,
     "loadjs": true,
     "Shepherd": true,
     "Sortable": true,
diff --git a/core/core.libraries.yml b/core/core.libraries.yml
index 5ec851e898e7652d7f8ab474dfaf6ef3b138f4c4..48eb3b17a2309a17d532210ed39cc121dd86d74a 100644
--- a/core/core.libraries.yml
+++ b/core/core.libraries.yml
@@ -614,6 +614,17 @@ drupal.form:
     - core/drupal.debounce
     - core/once
 
+drupal.htmx:
+  version: VERSION
+  js:
+    misc/htmx/htmx-assets.js: {}
+    misc/htmx/htmx-behaviors.js: {}
+  dependencies:
+    - core/htmx
+    - core/drupal
+    - core/drupalSettings
+    - core/loadjs
+
 drupal.machine-name:
   version: VERSION
   js:
diff --git a/core/core.services.yml b/core/core.services.yml
index 4372e6512c3e34c73f1db7c5174306690d134bd0..ce288b667a3a163a832cea23f52aaf927fcf34f4 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -1400,6 +1400,14 @@ services:
   html_response.subscriber:
     class: Drupal\Core\EventSubscriber\HtmlResponseSubscriber
     arguments: ['@html_response.attachments_processor']
+  htmx_response.attachments_processor:
+    class: Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor
+    parent: html_response.attachments_processor
+  htmx.response_subscriber:
+    class: Drupal\Core\EventSubscriber\HtmxResponseSubscriber
+    arguments: [ '@request_stack', !service_closure '@htmx_response.attachments_processor' ]
+    tags:
+      - { name: event_subscriber }
   finish_response_subscriber:
     class: Drupal\Core\EventSubscriber\FinishResponseSubscriber
     arguments: ['@language_manager', '@config.factory', '@page_cache_request_policy', '@page_cache_response_policy', '@cache_contexts_manager', '@datetime.time', '%http.response.debug_cacheability_headers%']
diff --git a/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php
new file mode 100644
index 0000000000000000000000000000000000000000..2ec01b9e5dff23289bb3c832abd16b14779c757a
--- /dev/null
+++ b/core/lib/Drupal/Core/EventSubscriber/HtmxResponseSubscriber.php
@@ -0,0 +1,79 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\EventSubscriber;
+
+use Drupal\Component\Utility\Html;
+use Drupal\Core\Render\AttachmentsResponseProcessorInterface;
+use Drupal\Core\Render\HtmlResponse;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\HttpFoundation\RequestStack;
+use Symfony\Component\HttpKernel\Event\RequestEvent;
+use Symfony\Component\HttpKernel\Event\ResponseEvent;
+use Symfony\Component\HttpKernel\KernelEvents;
+
+/**
+ * Adds assets on responses to HTMX requests.
+ *
+ * @see \Drupal\Core\Render\Hypermedia\HtmxResponseAttachmentsProcessor
+ */
+class HtmxResponseSubscriber implements EventSubscriberInterface {
+
+  /**
+   * Constructs a HtmxResponseSubscriber object.
+   */
+  public function __construct(
+    protected readonly RequestStack $requestStack,
+    protected \Closure $attachmentsProcessor,
+  ) {}
+
+  /**
+   * Add assemble and attachments add HTMX attributes.
+   *
+   * @see \Drupal\Core\EventSubscriber\HtmlResponseSubscriber::onRespond
+   */
+  public function onRespond(ResponseEvent $event): void {
+    $response = $event->getResponse();
+    $requestHeaders = $this->requestStack->getCurrentRequest()->headers;
+    if (!($response instanceof HtmlResponse && $requestHeaders->has('HX-Request'))) {
+      // Only operate on HTML responses from an HTMX request.
+      return;
+    }
+    $processedResponse = $this->getAttachementsProcessor()->processAttachments($response);
+    if (!($processedResponse instanceof HtmlResponse)) {
+      throw new \TypeError("ResponseEvent::setResponse() requires an HtmlResponse object");
+    }
+    $event->setResponse($processedResponse);
+  }
+
+  protected function getAttachementsProcessor(): AttachmentsResponseProcessorInterface {
+    return ($this->attachmentsProcessor)();
+  }
+
+  /**
+   * Sets \Drupal\Component\Utility\Html::$isAjax to TRUE.
+   *
+   * @param \Symfony\Component\HttpKernel\Event\RequestEvent $event
+   *   The request event, which contains the current request.
+   */
+  public function onRequest(RequestEvent $event) {
+    $requestHeaders = $event->getRequest()->headers;
+    // Pass to the Html class that the current request is an Ajax request.
+    if ($requestHeaders->has('HX-Request')) {
+      Html::setIsAjax(TRUE);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents(): array {
+    return [
+      // Our method needs to run before the HtmlResponseSubscriber(weight 0).
+      KernelEvents::RESPONSE => ['onRespond', 100],
+      KernelEvents::REQUEST => ['onRequest', 50],
+    ];
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php b/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php
new file mode 100644
index 0000000000000000000000000000000000000000..8493c1c1b367179367d54068b7e87aa29c2b6a49
--- /dev/null
+++ b/core/lib/Drupal/Core/Render/Hypermedia/HtmxResponseAttachmentsProcessor.php
@@ -0,0 +1,95 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Render\Hypermedia;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\UrlHelper;
+use Drupal\Core\Asset\AttachedAssetsInterface;
+use Drupal\Core\Render\AttachmentsInterface;
+use Drupal\Core\Render\HtmlResponse;
+use Drupal\Core\Render\HtmlResponseAttachmentsProcessor;
+
+/**
+ * Prepares attachment for HTMX powered responses.
+ *
+ * Extends the HTML response processor encode attachment data.
+ *
+ * @see \Drupal\Core\EventSubscriber\HtmxResponseSubscriber
+ * @see \Drupal\Core\Render\HtmlResponseAttachmentsProcessor::processAttachments
+ * @see core/misc/htmx-behaviors.js
+ */
+class HtmxResponseAttachmentsProcessor extends HtmlResponseAttachmentsProcessor {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function processAssetLibraries(AttachedAssetsInterface $assets, array $placeholders) {
+    $request = $this->requestStack->getCurrentRequest();
+    if ($request->headers->has('HX-Page-State')) {
+      $uncompressed = UrlHelper::uncompressQueryParameter($request->headers->get('HX-Page-State'));
+      $libraries = explode(',', $uncompressed);
+      $assets->setAlreadyLoadedLibraries($libraries);
+    }
+    $settings = [];
+    $variables = [];
+    $maintenance_mode = defined('MAINTENANCE_MODE') || \Drupal::state()->get('system.maintenance_mode');
+
+    // Print styles - if present.
+    if (isset($placeholders['styles'])) {
+      // Optimize CSS if necessary, but only during normal site operation.
+      $optimize_css = !$maintenance_mode && $this->config->get('css.preprocess');
+      $variables['styles'] = $this->cssCollectionRenderer->render($this->assetResolver->getCssAssets($assets, $optimize_css, $this->languageManager->getCurrentLanguage()));
+    }
+
+    // Copy and adjust parent::processAssetLibraries to adjust and
+    // remove drupalSettings in line with
+    // AjaxResponseAttachmentsProcessor::buildAttachmentsCommands
+    // Print scripts - if any are present.
+    if (isset($placeholders['scripts']) || isset($placeholders['scripts_bottom'])) {
+      // Optimize JS if necessary, but only during normal site operation.
+      $optimize_js = !$maintenance_mode && $this->config->get('js.preprocess');
+      [$js_assets_header, $js_assets_footer] = $this->assetResolver
+        ->getJsAssets($assets, $optimize_js, $this->languageManager->getCurrentLanguage());
+      if (isset($js_assets_header['drupalSettings'])) {
+        $settings = $js_assets_header['drupalSettings']['data'];
+        unset($js_assets_header['drupalSettings']);
+      }
+      if (isset($js_assets_footer['drupalSettings'])) {
+        $settings = $js_assets_footer['drupalSettings']['data'];
+        unset($js_assets_footer['drupalSettings']);
+      }
+      if (empty($settings)) {
+        $settings = $assets->getSettings();
+      }
+      $variables['scripts'] = $this->jsCollectionRenderer->render($js_assets_header);
+      $variables['scripts_bottom'] = $this->jsCollectionRenderer->render($js_assets_footer);
+      unset($settings['path']);
+      $variables['settings'] = $settings;
+    }
+    /*
+     * @todo Update to use `#htmx` render array property when available.
+     */
+    return [
+      'styles' => [],
+      'scripts' => [],
+      'scripts_bottom' => [
+        '#type' => 'container',
+        '#attributes' => [
+          'data-hx-swap-oob' => 'beforeend:body',
+        ],
+        'libraries' => [
+          '#type' => 'html_tag',
+          '#tag' => 'script',
+          '#attributes' => [
+            'type' => 'application/json',
+            'data-drupal-selector' => 'drupal-htmx-assets',
+          ],
+          '#value' => Json::encode($variables),
+        ],
+      ],
+    ];
+  }
+
+}
diff --git a/core/misc/htmx/htmx-assets.js b/core/misc/htmx/htmx-assets.js
new file mode 100644
index 0000000000000000000000000000000000000000..5829a4a682fb4493b76720ce6a7964b87e3ece3d
--- /dev/null
+++ b/core/misc/htmx/htmx-assets.js
@@ -0,0 +1,182 @@
+/**
+ * @file
+ * Adds needed assets to the current page.
+ */
+
+// JavaScript should be made compatible with libraries other than jQuery by
+// wrapping it in an anonymous closure.
+(function (Drupal, drupalSettings, once, loadjs, htmx) {
+  /**
+   * Helper function to merge two objects recursively.
+   *
+   * @param current
+   *   The object to receive the merged values.
+   * @param sources
+   *   The objects to merge into current.
+   *
+   * @return object
+   *   The merged object.
+   *
+   * @see https://youmightnotneedjquery.com/#deep_extend
+   */
+  function mergeSettings(current, ...sources) {
+    if (!current) {
+      return {};
+    }
+
+    sources
+      .filter((obj) => Boolean(obj))
+      .forEach((obj) => {
+        Object.entries(obj).forEach(([key, value]) => {
+          switch (Object.prototype.toString.call(value)) {
+            case '[object Object]':
+              current[key] = current[key] || {};
+              current[key] = mergeSettings(current[key], value);
+              break;
+
+            case '[object Array]':
+              current[key] = mergeSettings(new Array(value.length), value);
+              break;
+
+            default:
+              current[key] = value;
+          }
+        });
+      });
+
+    return current;
+  }
+
+  /**
+   * Function to process and send the current ajax page state with each request.
+   *
+   * @param configRequestEvent
+   *   HTMX event for request configuration.
+   */
+  function htmxDrupalData(configRequestEvent) {
+    const url = new URL(configRequestEvent.detail.path, document.location.href);
+    const origin = document.location.origin;
+    const sameHost = origin === url.origin;
+    if (sameHost) {
+      // We only need to add this data for htmx requests back to the site.
+      configRequestEvent.detail.headers['HX-Page-State'] =
+        drupalSettings.ajaxPageState.libraries;
+      // HTMX sets these header values using #id values.
+      // Swap in drupal data selectors as #id values are altered to be unique.
+      configRequestEvent.detail.headers['HX-Target'] =
+        configRequestEvent.detail.target.dataset.drupalSelector;
+      configRequestEvent.detail.headers['HX-Trigger'] =
+        configRequestEvent.detail.elt.dataset.drupalSelector;
+    }
+  }
+
+  /**
+   * Function to obtain, process, and attach new assets.
+   *
+   * @param {event} oobSwapEvent
+   *   The event fired by HTMX.
+   */
+  function htmxDrupalAssetProcessor(oobSwapEvent) {
+    // Find any inserted assets.
+    const assetsTag = oobSwapEvent.detail.target.querySelector(
+      'script[data-drupal-selector="drupal-htmx-assets"]',
+    );
+    if (!(assetsTag instanceof HTMLElement)) {
+      return;
+    }
+    // Parse assets and initialize container variables.
+    const assets = JSON.parse(assetsTag.textContent);
+    let cssItems = [];
+    let scriptItems = [];
+    let scriptBottomItems = [];
+    let paths = [];
+    if (assets.constructor.name === 'Object') {
+      // Variable assets should have properties 'styles', 'scripts,
+      // 'scripts_bottom', 'settings'. See
+      // HtmxResponseAttachmentsProcessor::processAssetLibraries
+      cssItems = new Map(
+        assets.styles.map((item) => [`css!${item['#attributes'].href}`, item]),
+      );
+      scriptItems = new Map(
+        assets.scripts.map((item) => [item['#attributes'].src, item]),
+      );
+      scriptBottomItems = new Map(
+        assets.scripts_bottom.map((item) => [item['#attributes'].src, item]),
+      );
+      mergeSettings(drupalSettings, assets.settings);
+    }
+    // Load CSS.
+    if (cssItems instanceof Map && cssItems.size > 0) {
+      // Fetch in parallel but load in sequence.
+      cssItems.forEach((value, key) => paths.push(key));
+      loadjs(paths, {
+        async: false,
+        before(path, linkElement) {
+          const item = cssItems.get(path);
+          Object.entries(item['#attributes']).forEach(
+            ([attributeKey, attributeValue]) => {
+              linkElement.setAttribute(attributeKey, attributeValue);
+            },
+          );
+        },
+      });
+    }
+    /* By default, loadjs appends the script to the head. When scripts
+     * are loaded via HTMX, their location has no impact on
+     * functionality. But, since Drupal loaded scripts can choose
+     * their parent element, we provide that option here for the sake of
+     * consistency.
+     */
+    if (scriptItems instanceof Map && scriptItems.size > 0) {
+      paths = [];
+      scriptItems.forEach((value, key) => paths.push(key));
+      // Load head JS.
+      // Fetch in parallel but load in sequence.
+      loadjs(paths, {
+        async: false,
+        before(path, scriptElement) {
+          const item = scriptItems.get(path);
+          Object.entries(item['#attributes']).forEach(
+            ([attributeKey, attributeValue]) => {
+              scriptElement.setAttribute(attributeKey, attributeValue);
+            },
+          );
+        },
+      });
+    }
+    if (scriptBottomItems instanceof Map && scriptBottomItems.size > 0) {
+      paths = [];
+      scriptBottomItems.forEach((value, key) => paths.push(key));
+      // Fetch in parallel but load in sequence.
+      loadjs(paths, {
+        async: false,
+        before(path, scriptElement) {
+          const item = scriptBottomItems.get(path);
+          Object.entries(item['#attributes']).forEach(
+            ([attributeKey, attributeValue]) => {
+              scriptElement.setAttribute(attributeKey, attributeValue);
+            },
+          );
+          document.body.appendChild(scriptElement);
+          // Return false to bypass loadjs' default DOM insertion
+          // mechanism.
+          return false;
+        },
+      });
+    }
+    // Any assets are now processed.  Remove the found asset tag.
+    htmx.remove(assetsTag);
+  }
+
+  /* Initialize listeners. */
+
+  Drupal.behaviors.htmxAssets = {
+    attach: () => {
+      if (!once('htmxAssets', 'html').length) {
+        return;
+      }
+      window.addEventListener('htmx:configRequest', htmxDrupalData);
+      window.addEventListener('htmx:oobAfterSwap', htmxDrupalAssetProcessor);
+    },
+  };
+})(Drupal, drupalSettings, once, loadjs, htmx);
diff --git a/core/misc/htmx/htmx-behaviors.js b/core/misc/htmx/htmx-behaviors.js
new file mode 100644
index 0000000000000000000000000000000000000000..90a44426736d37e447e780b59d8eb23ad8cce4ff
--- /dev/null
+++ b/core/misc/htmx/htmx-behaviors.js
@@ -0,0 +1,32 @@
+/**
+ * @file
+ * Connect Drupal.behaviors to htmx inserted content.
+ */
+(function (Drupal, htmx, drupalSettings) {
+
+
+  // @todo replace with a custom event that will trigger once all the assets
+  // have been loaded.
+  htmx.on('htmx:load', (event) => {
+    Drupal.attachBehaviors(
+      // The attachBehaviors method searches within the context.
+      // We need to go up one level so that the loaded content is processed
+      // completely.
+      event.detail.elt?.parentElement,
+      drupalSettings,
+    );
+  });
+
+  // When htmx removes elements from the DOM, make sure they're detached first.
+  htmx.on('htmx:beforeSwap', (event) => {
+    Drupal.detachBehaviors(event.detail.elt, drupalSettings, 'unload');
+  });
+
+  /**
+   * @todo move that to ajax.js this is not necessary since htmx initializes the markup it adds to the page automatically.
+   * Also send new markup through htmx processing.
+   */
+  Drupal.behaviors.htmxProcess = {
+    attach: htmx.process,
+  };
+})(Drupal, htmx, drupalSettings);
diff --git a/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php
new file mode 100644
index 0000000000000000000000000000000000000000..b100d8adbea53f246c50b691c41f2320f6c9d6cf
--- /dev/null
+++ b/core/modules/system/tests/modules/test_htmx/src/Controller/HtmxTestAttachmentsController.php
@@ -0,0 +1,78 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\test_htmx\Controller;
+
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Url;
+
+/**
+ * Returns responses for HTMX Test Attachments routes.
+ */
+final class HtmxTestAttachmentsController extends ControllerBase {
+
+  /**
+   * Builds the response.
+   *
+   * @return mixed[]
+   *   A render array.
+   */
+  public function page(): array {
+    $url = Url::fromRoute('test_htmx.attachments.replace');
+    $build['replace'] = [
+      '#type' => 'button',
+      '#attributes' => [
+        'name' => 'replace',
+        'data-hx-get' => $url->toString(),
+        'data-hx-select' => 'div.dropbutton-wrapper',
+        'data-hx-target' => '[data-drupal-htmx-target]',
+      ],
+      '#value' => 'Click this',
+      '#context' => [
+        'url' => $url,
+      ],
+    ];
+
+    $build['content'] = [
+      '#type' => 'container',
+      '#attached' => [
+        'library' => [
+          'core/drupal.htmx',
+        ],
+      ],
+      '#attributes' => [
+        'data-drupal-htmx-target' => TRUE,
+        'class' => ['htmx-test-container'],
+      ],
+    ];
+
+    return $build;
+  }
+
+  /**
+   * Builds the HTMX response.
+   *
+   * @return mixed[]
+   *   A render array.
+   */
+  public function replace(): array {
+    $build['content'] = [
+      '#type' => 'dropbutton',
+      '#dropbutton_type' => 'small',
+      '#links' => [
+        'simple_form' => [
+          'title' => $this->t('Sample link 1'),
+          'url' => Url::fromRoute('system.timezone'),
+        ],
+        'demo' => [
+          'title' => $this->t('Sample link 2'),
+          'url' => Url::fromRoute('<front>'),
+        ],
+      ],
+    ];
+
+    return $build;
+  }
+
+}
diff --git a/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml b/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..713f8a551a5baa2809c4cd4758990afc0113952c
--- /dev/null
+++ b/core/modules/system/tests/modules/test_htmx/test_htmx.info.yml
@@ -0,0 +1,5 @@
+name: 'HTMX Test Fixtures'
+type: module
+description: 'Test fixtures for HTMX integration'
+package: Testing
+core_version_requirement: ^11
diff --git a/core/modules/system/tests/modules/test_htmx/test_htmx.routing.yml b/core/modules/system/tests/modules/test_htmx/test_htmx.routing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b6a82293bffc4df7f25cdd6d796c8215c88c6b9b
--- /dev/null
+++ b/core/modules/system/tests/modules/test_htmx/test_htmx.routing.yml
@@ -0,0 +1,17 @@
+test_htmx.attachments.page:
+  path: '/htmx-test-attachments/page'
+  defaults:
+    _title: 'Page'
+    _controller: '\Drupal\test_htmx\Controller\HtmxTestAttachmentsController::page'
+  requirements:
+    _permission: 'access content'
+
+test_htmx.attachments.replace:
+  path: '/htmx-test-attachments/replace'
+  defaults:
+    _title: 'Dropbutton'
+    _controller: '\Drupal\test_htmx\Controller\HtmxTestAttachmentsController::replace'
+  requirements:
+    _permission: 'access content'
+  options:
+    _htmx_route: 'true'
diff --git a/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js
new file mode 100644
index 0000000000000000000000000000000000000000..8af529d826e137f3a89e5f59760f2fdff1a748f9
--- /dev/null
+++ b/core/tests/Drupal/Nightwatch/Tests/htmx/htmxAssetLoadTest.js
@@ -0,0 +1,42 @@
+// The javascript that creates dropbuttons is not present on the /page at
+// initial load.  If the once data property is added then the JS was loaded
+// and triggered on the inserted content.
+// @see \Drupal\test_htmx\Controller\HtmxTestAttachmentsController
+// @see core/modules/system/tests/modules/test_htmx/js/reveal-merged-settings.js
+module.exports = {
+  '@tags': ['core', 'htmx'],
+  before(browser) {
+    browser.drupalInstall({
+      setupFile: 'core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php',
+      installProfile: 'minimal',
+    });
+  },
+  afterEach(browser) {
+    browser.drupalLogAndEnd({ onlyOnError: true });
+  },
+  after(browser) {
+    browser.drupalUninstall();
+  },
+
+  'Asset Load': (browser) => {
+    // Load the route htmx will request and confirm the markup we will be looking
+    // for is present in the source markup.
+    browser
+      .drupalRelativeURL('/htmx-test-attachments/replace')
+      .waitForElementVisible('body', 1000)
+      .assert.elementPresent('script[src*="dropbutton.js"]');
+    // Now load the page with the htmx enhanced button and verify the absence
+    // of the markup to be inserted. Click the button
+    // and check for inserted javascript and markup.
+    browser
+      .drupalRelativeURL('/htmx-test-attachments/page')
+      .waitForElementVisible('body', 1000)
+      .assert.not.elementPresent('script[src*="dropbutton.js"]')
+      .waitForElementVisible('[name="replace"]', 1000)
+      .pause(1000)
+      .click('[name="replace"]')
+      .waitForElementVisible('div.dropbutton-wrapper', 60000)
+      .waitForElementVisible('div[data-once="dropbutton"]', 60000)
+      .assert.elementPresent('script[src*="dropbutton.js"]');
+  },
+};
diff --git a/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php b/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php
new file mode 100644
index 0000000000000000000000000000000000000000..03bb3fbdb113060d23eb3bcbad0fd3bcc67581fd
--- /dev/null
+++ b/core/tests/Drupal/TestSite/HtmxAssetLoadTestSetup.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\TestSite;
+
+use Drupal\Core\Extension\ModuleInstallerInterface;
+use Drupal\Core\Extension\ThemeInstallerInterface;
+
+/**
+ * Setup file used by tests/src/Nightwatch/Tests/htmxAssetLoadTest.js.
+ *
+ * @see \Drupal\Tests\Scripts\TestSiteApplicationTest
+ */
+class HtmxAssetLoadTestSetup implements TestSetupInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function setup(): void {
+    // Install Olivero and set it as the default theme.
+    $theme_installer = \Drupal::service('theme_installer');
+    assert($theme_installer instanceof ThemeInstallerInterface);
+    $theme_installer->install(['olivero'], TRUE);
+    $system_theme_config = \Drupal::configFactory()->getEditable('system.theme');
+    $system_theme_config->set('default', 'olivero')->save();
+
+    // Install required modules.
+    $module_installer = \Drupal::service('module_installer');
+    assert($module_installer instanceof ModuleInstallerInterface);
+    $module_installer->install(['test_htmx']);
+  }
+
+}