diff --git a/core/core.services.yml b/core/core.services.yml
index 0ecb95d3cb2723437a49c1517ab5d0e4a8477851..6d26ba9ce8aaf0110d7806988aa56b3d152f895b 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -1886,3 +1886,6 @@ services:
     tags:
       - { name: twig.loader, priority: 5 }
   Drupal\Core\EventSubscriber\CsrfExceptionSubscriber: ~
+  Drupal\Core\Asset\ImportMapsManagerInterface:
+    class: Drupal\Core\Asset\ImportMapsManager
+    autowire: true
diff --git a/core/lib/Drupal/Core/Asset/ImportMapsManager.php b/core/lib/Drupal/Core/Asset/ImportMapsManager.php
new file mode 100644
index 0000000000000000000000000000000000000000..4588eca12a38c264dc5afcb732297ca3982edcc5
--- /dev/null
+++ b/core/lib/Drupal/Core/Asset/ImportMapsManager.php
@@ -0,0 +1,321 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Asset;
+
+use Drupal\Component\FileCache\FileCacheFactory;
+use Drupal\Component\FileCache\FileCacheInterface;
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Component\Utility\UrlHelper;
+use Drupal\Core\Cache\CacheBackendInterface;
+use Drupal\Core\Cache\CacheCollector;
+use Drupal\Core\Extension\ExtensionPathResolver;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Extension\ThemeExtensionList;
+use Drupal\Core\Extension\ThemeHandlerInterface;
+use Drupal\Core\File\FileUrlGeneratorInterface;
+use Drupal\Core\Lock\LockBackendInterface;
+use Drupal\Core\Logger\LoggerChannelInterface;
+use Drupal\Core\Serialization\Yaml;
+use Drupal\Core\Theme\ThemeManagerInterface;
+use Symfony\Component\DependencyInjection\Attribute\Autowire;
+
+/**
+ * Defines a plugin manager for import maps.
+ *
+ * To declare an import map, add a file named MODULE_NAME.importmaps.yml to the
+ * root of your module. The file should contain a top level key 'imports'. Each
+ * entry under 'imports' represents an entry in the import map.
+ *
+ * @code
+ * imports:
+ *   my-library:
+ *     path: js/dist/my-library.js
+ *   another-library:
+ *     path: js/dist/another-library.js
+ * @endcode
+ *
+ * JavaScript code that wishes to consume your imports in an ES module.
+ *
+ * @code
+ * // Note the naked import here, no path - simply 'my-library'.
+ * import myLibrary from 'my-library';
+ * @endcode
+ *
+ * If making use of a bundler to build the consuming code, you will need to
+ * configure the build step to mark these imports as external. For example with
+ * Vite.
+ *
+ * @code
+ * const viteConfig = {
+ *   // ...
+ *   build: {
+ *     rollupOptions: {
+ *       external: ["my-library", "another-library"],
+ *   },
+ *   // ...
+ * }
+ * @endcode
+ *
+ * This will make sure that the naked import is retained in the built code.
+ * Similar options exist for other front-end bundlers.
+ *
+ * Consuming code can make use of the attributes key in libraries.yml to set
+ * type="module" on the script tag.
+ *
+ * @code
+ *   cards:
+ *    css:
+ *     component:
+ *       css/card.css: {}
+ *    js:
+ *      // This script will be rendered as <script type="module"> and hence be
+ *     // treated as an ES module.
+ *     js/dist/card.js: { minified: true, attributes: { type: module } }}
+ * @endcode
+ *
+ * If you need to load two different versions of a module in an import map you
+ * can also add a top-level 'scopes' key to the MODULE_NAME.importmaps.yml file
+ *
+ * @code
+ *  imports:
+ *    my-library:
+ *      path: js/dist/my-library-v2.js
+ *  scopes:
+ *    js/v1:
+ *      my-library:
+ *       path: js/dist/my-library-v1.js
+ * @endcode
+ *
+ * In this example, consuming code from inside js/v1 (relative to the module
+ * declaring the importmaps.yml file) will resolve 'my-library' to
+ * js/dist/my-library-v1.js. All other consuming code that imports from
+ * 'my-library' will resolve to js/dist/my-library-v2.js.
+ *
+ * The MODULE_NAME.importmaps.yml file must contain at least one of the 'scopes'
+ * and 'imports' top-level keys.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap
+ * @see https://rollupjs.org/configuration-options/#external
+ * @see https://webpack.js.org/configuration/externals/#externals
+ * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules
+ */
+final class ImportMapsManager extends CacheCollector implements ImportMapsManagerInterface {
+
+  protected const MODULE_IMPORT_MAPS = '__modules';
+
+  /**
+   * File cache.
+   *
+   * @var \Drupal\Component\FileCache\FileCacheInterface
+   */
+  protected readonly FileCacheInterface $fileCache;
+
+  /**
+   * Constructs an ImportMapsManager object.
+   */
+  public function __construct(
+    #[Autowire('@cache.discovery')]
+    CacheBackendInterface $cache,
+    #[Autowire('@lock')]
+    LockBackendInterface $lock,
+    #[Autowire('%app.root%')]
+    protected readonly string $rootPath,
+    protected readonly ModuleHandlerInterface $moduleHandler,
+    protected readonly ThemeHandlerInterface $themeHandler,
+    protected readonly ExtensionPathResolver $extensionPathResolver,
+    protected readonly ThemeExtensionList $themeExtensionList,
+    #[Autowire('@logger.channel.default')]
+    protected readonly LoggerChannelInterface $loggerChannel,
+    protected readonly FileUrlGeneratorInterface $fileUrlGenerator,
+    protected readonly ThemeManagerInterface $themeManager,
+  ) {
+    parent::__construct('importmaps', $cache, $lock, ['importmaps']);
+    $this->fileCache = FileCacheFactory::get('importmaps');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function resolveCacheMiss($key): array {
+    $this->storage[$key] = $this->buildImportMaps($key);
+    $this->persist($key);
+
+    return $this->storage[$key];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getImportMapForTheme(string $theme): array {
+    // The returned map combines import maps for the installed modules and those
+    // from the given theme (and any of its base themes).
+    return NestedArray::mergeDeep($this->get(self::MODULE_IMPORT_MAPS), $this->get($theme));
+  }
+
+  /**
+   * Build import maps for the given key.
+   *
+   * @param string $key
+   *   Either self::MODULE_IMPORT_MAPS or a theme machine name.
+   *
+   * @return array
+   *   Built import maps, with any paths resolved.
+   */
+  protected function buildImportMaps(string $key): array {
+    $import_maps = [];
+    if ($key === self::MODULE_IMPORT_MAPS) {
+      // We need to build for modules.
+      foreach ($this->moduleHandler->getModuleList() as $extension => $info) {
+        $import_maps = NestedArray::mergeDeep($import_maps, $this->buildImportMapsForExtensionNameAndPath($extension, $info->getPath()));
+      }
+      return $import_maps;
+    }
+    // Build import maps for the given theme.
+    $base_theme_import_maps = \array_reduce($this->collectBaseThemes($key), fn (array $carry, string $base_theme) => NestedArray::mergeDeep($carry, $this->get($base_theme)), []);
+    $theme = $this->themeHandler->getTheme($key);
+    $import_maps = NestedArray::mergeDeep($base_theme_import_maps, $this->buildImportMapsForExtensionNameAndPath($key, $theme->getPath()));
+    return $import_maps;
+  }
+
+  /**
+   * Collects base themes for a given theme.
+   *
+   * @param string $theme
+   *   Theme machine name.
+   *
+   * @return array
+   *   Array of base themes.
+   */
+  protected function collectBaseThemes(string $theme): array {
+    $base_themes = [];
+    $info = $this->themeExtensionList->getExtensionInfo($theme);
+    if (\array_key_exists('base theme', $info)) {
+      $base_themes[] = $info['base theme'];
+      $base_themes = \array_merge($base_themes, $this->collectBaseThemes($info['base theme']));
+    }
+    return $base_themes;
+  }
+
+  /**
+   * Builds import maps for given extension name and path.
+   *
+   * @param string $extension
+   *   Extension machine name.
+   * @param string $directory
+   *   Directory of the extension.
+   *
+   * @return array
+   *   Import maps.
+   *
+   * @see \Drupal\Core\Asset\ImportMapsManagerInterface::getImportMapForTheme()
+   */
+  protected function buildImportMapsForExtensionNameAndPath(string $extension, string $directory): array {
+    $import_maps_file = $this->rootPath . '/' . $directory . '/' . $extension . '.importmaps.yml';
+    if (!\file_exists($import_maps_file)) {
+      return [];
+    }
+    $import_maps = $this->fileCache->get($import_maps_file);
+    if ($import_maps === NULL) {
+      $file_contents = \file_get_contents($import_maps_file);
+      if ($file_contents === FALSE) {
+        $this->loggerChannel->error('Error reading import maps file @file', ['@file' => $import_maps_file]);
+        return [];
+      }
+      $import_maps = Yaml::decode($file_contents) ?? [];
+      $this->fileCache->set($import_maps_file, $import_maps);
+    }
+
+    if (!\array_key_exists('imports', $import_maps) && !\array_key_exists('scopes', $import_maps)) {
+      $this->loggerChannel->warning('Import maps file missing both scopes and imports keys: @file', ['@file' => $import_maps_file]);
+      return [];
+    }
+
+    // Remove any irrelevant keys.
+    $import_maps = \array_intersect_key($import_maps, \array_flip([
+      'scopes',
+      'imports',
+    ]));
+    if (\array_key_exists('scopes', $import_maps)) {
+      $resolved_scopes = [];
+      foreach ($import_maps['scopes'] as $scope => $entries) {
+        $resolved_scope = \rtrim($this->resolvePath($scope, $directory, FALSE), '/') . '/';
+        foreach ($entries as $name => $entry) {
+          if (!\array_key_exists('path', $entry) || !is_string($entry['path'])) {
+            $this->loggerChannel->warning('Missing or invalid path entry for @scope/@import entry in @file', [
+              '@file' => $import_maps_file,
+              '@import' => $name,
+              '@scope' => $scope,
+            ]);
+            continue;
+          }
+          $resolved_scopes[$resolved_scope][$name] = $this->resolvePath($entry['path'], $directory);
+        }
+      }
+      $import_maps['scopes'] = $resolved_scopes;
+    }
+    if (\array_key_exists('imports', $import_maps)) {
+      $resolved_imports = [];
+      foreach ($import_maps['imports'] as $name => $entry) {
+        if (!\array_key_exists('path', $entry) || !is_string($entry['path'])) {
+          $this->loggerChannel->warning('Missing or invalid path entry for @import entry in @file', [
+            '@file' => $import_maps_file,
+            '@import' => $name,
+          ]);
+          continue;
+        }
+        $resolved_imports[$name] = $this->resolvePath($entry['path'], $directory);
+      }
+      $import_maps['imports'] = $resolved_imports;
+    }
+
+    $import_maps = \array_filter($import_maps);
+
+    // Allow modules and themes to alter import maps.
+    $this->moduleHandler->alter('importmaps', $import_maps, $extension);
+    $this->themeManager->alter('importmaps', $import_maps, $extension);
+
+    return $import_maps;
+  }
+
+  /**
+   * Resolves the path entry in an import or scope entry.
+   *
+   * The path may be:
+   * - a fully formed URL, e.g. http://example.com/some.js - in this case we
+   *   return the path as is. An external URL is not allowed as a key a scope
+   *   entry.
+   * - an absolute path, e.g. /absolute/file.js - in this case we return the
+   *   path as is.
+   * - a relative path e.g. js/file.js - this is relative to the module or theme
+   *   that declared the import map.
+   *
+   * @param string $path
+   *   Path to resolve.
+   * @param string $directory
+   *   Extension directory.
+   * @param bool $allow_external
+   *   TRUE if external URLS are allowed.
+   *
+   * @return string
+   *   The resolved path.
+   */
+  protected function resolvePath(string $path, string $directory, bool $allow_external = TRUE): string {
+    $scheme = \parse_url($path, PHP_URL_SCHEME);
+    if ($scheme !== FALSE && $scheme !== 'NULL' && \in_array($scheme, UrlHelper::getAllowedProtocols(), TRUE)) {
+      // External URL.
+      if (!$allow_external) {
+        throw new \LogicException(\sprintf('Scope keys cannot be external URLS - invalid entry %s in import map in %s', $path, $path));
+      }
+      return $path;
+    }
+    if (\substr($path, 0, 1) === '/') {
+      // Absolute path.
+      return $this->fileUrlGenerator->generateString($path);
+    }
+    // Relative to module.
+    return $this->fileUrlGenerator->generateString($directory . '/' . $path);
+  }
+
+}
diff --git a/core/lib/Drupal/Core/Asset/ImportMapsManagerInterface.php b/core/lib/Drupal/Core/Asset/ImportMapsManagerInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..c082c51a7974eb6fc7cf483a6d65e7a990a79c05
--- /dev/null
+++ b/core/lib/Drupal/Core/Asset/ImportMapsManagerInterface.php
@@ -0,0 +1,26 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\Core\Asset;
+
+use Drupal\Core\Cache\CacheCollectorInterface;
+use Drupal\Core\DestructableInterface;
+
+/**
+ * Defines an interface for import maps manager.
+ */
+interface ImportMapsManagerInterface extends CacheCollectorInterface, DestructableInterface {
+
+  /**
+   * Gets import maps for given theme.
+   *
+   * @param string $theme
+   *   Theme machine name.
+   *
+   * @return array
+   *   Array of import maps with keys 'imports' and 'scopes'.
+   */
+  public function getImportMapForTheme(string $theme): array;
+
+}
diff --git a/core/lib/Drupal/Core/Render/theme.api.php b/core/lib/Drupal/Core/Render/theme.api.php
index fa447c20063018f00c6622743ea56abe9ff26d97..904bfb0f3e9189566f90060af00217209d8d2e55 100644
--- a/core/lib/Drupal/Core/Render/theme.api.php
+++ b/core/lib/Drupal/Core/Render/theme.api.php
@@ -1362,6 +1362,44 @@ function hook_template_preprocess_default_variables_alter(&$variables) {
   $variables['is_admin'] = \Drupal::currentUser()->hasPermission('access administration pages');
 }
 
+/**
+ * Alter importmaps.
+ *
+ * Allows modules and themes to change importmaps.
+ *
+ * @param array $import_maps
+ *   Array of importmaps information. Will contain two top level keys 'imports'
+ *   and 'scopes'. Entries under 'imports' is a resolved URL to the file to
+ *   import and are keyed by the import names. Each entry under 'scopes' has
+ *   the same shape as 'imports' i.e. an array keyed by import identifiers with
+ *   each entry a resolved URL to the file to import. Entries under 'scopes' are
+ *   keyed by the path to consuming modules under which the scope rule applies.
+ *   @code
+ *   imports:
+ *     identifier1: '/absolute/url/to/import1.js'
+ *     identifier2: '/absolute/url/to/import2.js'
+ *   scopes:
+ *     /when/loading/code/is/here:
+ *       identifier1: '/has/a/different/path.js'
+ *   @endcode
+ * @param string $extension
+ *   The module or theme machine name that provided the import maps being
+ *   altered.
+ *
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap
+ * @see \Drupal\Core\Asset\ImportMapsManagerInterface
+ */
+function hook_importmaps_alter(array &$import_maps, string $extension): void {
+  if ($extension !== 'react') {
+    return;
+  }
+  // Swap out the path to react-dom.
+  if (\array_key_exists('react-dom', $import_maps['imports'])) {
+    // @cspell:ignore esmodule
+    $import_maps['imports']['react-dom'] = \Drupal::service(\Drupal\Core\File\FileUrlGeneratorInterface::class)->generateString('/my/react-dom-esmodule.js');
+  }
+}
+
 /**
  * @} End of "addtogroup hooks".
  */
diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt
index 13cbc4e61866667e97a2d49e095ec12aa644d0b3..a6c9dfaa1ec3bb51945f4dbf28f5fafffc504844 100644
--- a/core/misc/cspell/dictionary.txt
+++ b/core/misc/cspell/dictionary.txt
@@ -272,6 +272,8 @@ idekey
 iframeupload
 imagecache
 imagetextalternative
+importmap
+importmaps
 indexname
 inited
 inlines
diff --git a/core/modules/system/system.module b/core/modules/system/system.module
index 50c90b57721b9c639c188d50cdae08b77fe736a0..b76b5184c0f1f8cd5609ccb84f22c6254ad71916 100644
--- a/core/modules/system/system.module
+++ b/core/modules/system/system.module
@@ -8,8 +8,10 @@
 use Drupal\Component\FileSecurity\FileSecurity;
 use Drupal\Component\Gettext\PoItem;
 use Drupal\Component\Render\PlainTextOutput;
+use Drupal\Component\Serialization\Json;
 use Drupal\Component\Utility\UrlHelper;
 use Drupal\Core\Asset\AttachedAssetsInterface;
+use Drupal\Core\Asset\ImportMapsManagerInterface;
 use Drupal\Core\Block\BlockPluginInterface;
 use Drupal\Core\Cache\Cache;
 use Drupal\Core\Database\Query\AlterableInterface;
@@ -1111,7 +1113,7 @@ function system_theme_registry_alter(array &$theme_registry) {
 /**
  * Implements hook_page_top().
  */
-function system_page_top() {
+function system_page_top(array &$page_top) {
   /** @var \Drupal\Core\Routing\AdminContext $admin_context */
   $admin_context = \Drupal::service('router.admin_context');
   if ($admin_context->isAdminRoute() && \Drupal::currentUser()->hasPermission('administer site configuration')) {
@@ -1147,6 +1149,17 @@ function system_page_top() {
       }
     }
   }
+  $map = \Drupal::service(ImportMapsManagerInterface::class)->getImportMapForTheme(\Drupal::theme()->getActiveTheme()->getName());
+  if (\array_key_exists('imports', $map) && \count($map['imports']) > 0) {
+    $page_top['importmaps'] = [
+      '#type' => 'html_tag',
+      '#tag' => 'script',
+      '#value' => Json::encode($map),
+      '#attributes' => ['type' => 'importmap'],
+      // This varies by route because of the active theme resolution.
+      '#cache' => ['contexts' => ['route']],
+    ];
+  }
 }
 
 /**
diff --git a/core/modules/system/tests/modules/importmaps_test/importmaps_test.importmaps.yml b/core/modules/system/tests/modules/importmaps_test/importmaps_test.importmaps.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8cd3cba5a5aa2285c37a7aff6a8d39dfb5ed2231
--- /dev/null
+++ b/core/modules/system/tests/modules/importmaps_test/importmaps_test.importmaps.yml
@@ -0,0 +1,7 @@
+imports:
+  bar:
+    path: js/bar.js
+scopes:
+  js/scope:
+    bar:
+      path: js/bar-scoped.js
diff --git a/core/modules/system/tests/modules/importmaps_test/importmaps_test.info.yml b/core/modules/system/tests/modules/importmaps_test/importmaps_test.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..614aef0bf4c9c89071c2c82a244e047fc5c85afc
--- /dev/null
+++ b/core/modules/system/tests/modules/importmaps_test/importmaps_test.info.yml
@@ -0,0 +1,4 @@
+name: Import maps test
+description: 'Provides test functionality for import maps'
+type: module
+version: VERSION
diff --git a/core/modules/system/tests/modules/importmaps_test/importmaps_test.libraries.yml b/core/modules/system/tests/modules/importmaps_test/importmaps_test.libraries.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4a9b60fa212d252ba3cf05caea37a0bf31157c2c
--- /dev/null
+++ b/core/modules/system/tests/modules/importmaps_test/importmaps_test.libraries.yml
@@ -0,0 +1,7 @@
+foo:
+  js:
+    js/foo.js: { attributes: { type: module }, minified: true }
+
+foo-scoped:
+  js:
+    js/scope/foo.js: { attributes: { type: module }, minified: true }
diff --git a/core/modules/system/tests/modules/importmaps_test/importmaps_test.module b/core/modules/system/tests/modules/importmaps_test/importmaps_test.module
new file mode 100644
index 0000000000000000000000000000000000000000..e967fb19eec146f1b48d7e3fe4c29136b26bf194
--- /dev/null
+++ b/core/modules/system/tests/modules/importmaps_test/importmaps_test.module
@@ -0,0 +1,17 @@
+<?php
+
+/**
+ * @file
+ * Contains main module functions.
+ */
+
+declare(strict_types=1);
+
+/**
+ * Implements hook_importmaps_alter().
+ */
+function importmaps_test_importmaps_alter(array &$import_maps, string $extension): void {
+  if ($extension === 'importmaps_test_theme') {
+    $import_maps['imports']['foo'] = '/absolute/js/foo.js';
+  }
+}
diff --git a/core/modules/system/tests/modules/importmaps_test/importmaps_test.routing.yml b/core/modules/system/tests/modules/importmaps_test/importmaps_test.routing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..73a64744dc34e45d50f7c45edc44523bbddde79e
--- /dev/null
+++ b/core/modules/system/tests/modules/importmaps_test/importmaps_test.routing.yml
@@ -0,0 +1,7 @@
+importmaps_test.test:
+  path: '/importmaps-test'
+  defaults:
+    _controller: '\Drupal\importmaps_test\TestController'
+    _title: 'Importmaps test'
+  requirements:
+    _access: 'TRUE'
diff --git a/core/modules/system/tests/modules/importmaps_test/js/bar-scoped.js b/core/modules/system/tests/modules/importmaps_test/js/bar-scoped.js
new file mode 100644
index 0000000000000000000000000000000000000000..1deb323e738a556a31317b46dd61a3d019fe3b34
--- /dev/null
+++ b/core/modules/system/tests/modules/importmaps_test/js/bar-scoped.js
@@ -0,0 +1,11 @@
+class Bar {
+  constructor(el) {
+    this.el = el;
+  }
+
+  init() {
+    this.el.textContent = 'Scoped bar';
+  }
+}
+
+export default Bar;
diff --git a/core/modules/system/tests/modules/importmaps_test/js/bar.js b/core/modules/system/tests/modules/importmaps_test/js/bar.js
new file mode 100644
index 0000000000000000000000000000000000000000..60937a20fec8ad8fda0efbd9578dcb1c7260ac26
--- /dev/null
+++ b/core/modules/system/tests/modules/importmaps_test/js/bar.js
@@ -0,0 +1,11 @@
+class Bar {
+  constructor(el) {
+    this.el = el;
+  }
+
+  init() {
+    this.el.textContent = 'Root level bar';
+  }
+}
+
+export default Bar;
diff --git a/core/modules/system/tests/modules/importmaps_test/js/foo.js b/core/modules/system/tests/modules/importmaps_test/js/foo.js
new file mode 100644
index 0000000000000000000000000000000000000000..6340413a415ec0c1b535ba90b43ab266a4a1a257
--- /dev/null
+++ b/core/modules/system/tests/modules/importmaps_test/js/foo.js
@@ -0,0 +1,3 @@
+import Bar from 'bar'; // eslint-disable-line import/no-unresolved
+
+new Bar(document.getElementById('importmaps-test')).init();
diff --git a/core/modules/system/tests/modules/importmaps_test/js/scope/foo.js b/core/modules/system/tests/modules/importmaps_test/js/scope/foo.js
new file mode 100644
index 0000000000000000000000000000000000000000..6340413a415ec0c1b535ba90b43ab266a4a1a257
--- /dev/null
+++ b/core/modules/system/tests/modules/importmaps_test/js/scope/foo.js
@@ -0,0 +1,3 @@
+import Bar from 'bar'; // eslint-disable-line import/no-unresolved
+
+new Bar(document.getElementById('importmaps-test')).init();
diff --git a/core/modules/system/tests/modules/importmaps_test/src/TestController.php b/core/modules/system/tests/modules/importmaps_test/src/TestController.php
new file mode 100644
index 0000000000000000000000000000000000000000..56185c1ee6899de90eea513a58383bb23e3d0347
--- /dev/null
+++ b/core/modules/system/tests/modules/importmaps_test/src/TestController.php
@@ -0,0 +1,29 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\importmaps_test;
+
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Defines a controller for testing importmaps.
+ */
+final class TestController {
+
+  /**
+   * Invoke the controller.
+   */
+  public function __invoke(Request $request): array {
+    return [
+      '#markup' => '<div id="importmaps-test"></div>',
+      '#cache' => [
+        'contexts' => ['url.query_args:scoped'],
+      ],
+      '#attached' => [
+        'library' => ['importmaps_test/foo' . ($request->query->has('scoped') ? '-scoped' : '')],
+      ],
+    ];
+  }
+
+}
diff --git a/core/modules/system/tests/modules/importmaps_test_imports_only/importmaps_test_imports_only.importmaps.yml b/core/modules/system/tests/modules/importmaps_test_imports_only/importmaps_test_imports_only.importmaps.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f6d5b79f56f7a996601a1883490a9597232893f3
--- /dev/null
+++ b/core/modules/system/tests/modules/importmaps_test_imports_only/importmaps_test_imports_only.importmaps.yml
@@ -0,0 +1,3 @@
+imports:
+  whiz:
+    path: js/whiz.js
diff --git a/core/modules/system/tests/modules/importmaps_test_imports_only/importmaps_test_imports_only.info.yml b/core/modules/system/tests/modules/importmaps_test_imports_only/importmaps_test_imports_only.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..bff5f6ff0cb08116fc7712dbfdc98935fb9e1919
--- /dev/null
+++ b/core/modules/system/tests/modules/importmaps_test_imports_only/importmaps_test_imports_only.info.yml
@@ -0,0 +1,4 @@
+name: Import maps test, imports only
+description: ''
+type: module
+version: VERSION
diff --git a/core/modules/system/tests/modules/importmaps_test_scopes_only/importmaps_test_scopes_only.importmaps.yml b/core/modules/system/tests/modules/importmaps_test_scopes_only/importmaps_test_scopes_only.importmaps.yml
new file mode 100644
index 0000000000000000000000000000000000000000..8212c3558e526b3c03956853f37fd701301e14a5
--- /dev/null
+++ b/core/modules/system/tests/modules/importmaps_test_scopes_only/importmaps_test_scopes_only.importmaps.yml
@@ -0,0 +1,7 @@
+scopes:
+  js/bar:
+    whiz:
+      path: js/bar/whiz.js
+  /absolute/js/bar:
+    whiz:
+      path: /absolute/library/whiz.js
diff --git a/core/modules/system/tests/modules/importmaps_test_scopes_only/importmaps_test_scopes_only.info.yml b/core/modules/system/tests/modules/importmaps_test_scopes_only/importmaps_test_scopes_only.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..f1248a907093d27792a9a3b2f782721f39d7296e
--- /dev/null
+++ b/core/modules/system/tests/modules/importmaps_test_scopes_only/importmaps_test_scopes_only.info.yml
@@ -0,0 +1,4 @@
+name: Import maps test, scopes only
+description: ''
+type: module
+version: VERSION
diff --git a/core/tests/Drupal/FunctionalJavascriptTests/ImportMapsTest.php b/core/tests/Drupal/FunctionalJavascriptTests/ImportMapsTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..ad22823d972aca8bfef0a4985bb0eac90bdb6511
--- /dev/null
+++ b/core/tests/Drupal/FunctionalJavascriptTests/ImportMapsTest.php
@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\FunctionalJavascriptTests;
+
+use Drupal\Core\Url;
+
+/**
+ * Tests importmap and scopes.
+ *
+ * @group importmaps
+ */
+final class ImportMapsTest extends WebDriverTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['importmaps_test'];
+
+  /**
+   * Tests importmap functionality.
+   */
+  public function testImportMaps(): void {
+    $url = Url::fromRoute('importmaps_test.test');
+    $this->drupalGet($url);
+    $this->assertSession()->elementExists('css', '#importmaps-test');
+    $this->assertSession()->waitForText('Root level bar');
+    $this->assertSession()->pageTextNotContains('Scoped bar');
+
+    $this->drupalGet($url->setOption('query', ['scoped' => 1]));
+    $this->assertSession()->elementExists('css', '#importmaps-test');
+    $this->assertSession()->waitForText('Scoped bar');
+    $this->assertSession()->pageTextNotContains('Root level bar');
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Asset/ImportMapsManagerTest.php b/core/tests/Drupal/KernelTests/Core/Asset/ImportMapsManagerTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..fe7465365150ddd8ed04525afd27822a3c644827
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Asset/ImportMapsManagerTest.php
@@ -0,0 +1,106 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Drupal\KernelTests\Core\Asset;
+
+use Drupal\Component\Utility\NestedArray;
+use Drupal\Core\Asset\ImportMapsManagerInterface;
+use Drupal\Core\Extension\ExtensionPathResolver;
+use Drupal\Core\Theme\ThemeInitializationInterface;
+use Drupal\Core\Theme\ThemeManagerInterface;
+use Drupal\KernelTests\KernelTestBase;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+/**
+ * Tests ImportMapsManager.
+ *
+ * @group importmaps
+ * @covers \Drupal\Core\Asset\ImportMapsManager
+ */
+final class ImportMapsManagerTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'importmaps_test',
+    'importmaps_test_imports_only',
+    'importmaps_test_scopes_only',
+    'system',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->container->get('theme_installer')->install([
+      'importmaps_test_theme',
+      'importmaps_test_basetheme',
+    ]);
+    /** @var \Drupal\Core\Theme\ThemeInitializationInterface $theme_initializer */
+    $theme_initializer = $this->container->get(ThemeInitializationInterface::class);
+    /** @var \Drupal\Core\Theme\ThemeManagerInterface $theme_manager */
+    $theme_manager = $this->container->get(ThemeManagerInterface::class);
+    $theme_manager->setActiveTheme($theme_initializer->getActiveThemeByName('importmaps_test_theme'));
+  }
+
+  /**
+   * Tests import map discovery.
+   */
+  public function testImportDiscovery(): void {
+    /** @var \Drupal\Core\Asset\ImportMapsManagerInterface $manager */
+    $manager = $this->container->get(ImportMapsManagerInterface::class);
+
+    $extension_path_resolver = $this->container->get(ExtensionPathResolver::class);
+    $import_maps_test_path = $extension_path_resolver->getPath('module', 'importmaps_test');
+    $import_maps_test_imports_only_path = $extension_path_resolver->getPath('module', 'importmaps_test_imports_only');
+    $import_maps_test_scopes_only_path = $extension_path_resolver->getPath('module', 'importmaps_test_scopes_only');
+    $import_maps_test_theme_path = $extension_path_resolver->getPath('theme', 'importmaps_test_theme');
+    $import_maps_test_basetheme_path = $extension_path_resolver->getPath('theme', 'importmaps_test_basetheme');
+
+    $basePath = $this->container->get(RequestStack::class)->getCurrentRequest()->getBasePath();
+
+    $base_import_maps = [
+      'imports' => [
+        'wow' => "{$basePath}/$import_maps_test_basetheme_path/js/wow.js",
+        'absolute' => "{$basePath}/dist/absolute.js",
+        'url' => "https://example.com/url.js",
+        'bar' => "{$basePath}/$import_maps_test_path/js/bar.js",
+        'whiz' => "{$basePath}/$import_maps_test_imports_only_path/js/whiz.js",
+      ],
+      'scopes' => [
+        "{$basePath}/$import_maps_test_path/js/scope/" => [
+          'bar' => "{$basePath}/$import_maps_test_path/js/bar-scoped.js",
+        ],
+        "{$basePath}/$import_maps_test_scopes_only_path/js/bar/" => [
+          'whiz' => "{$basePath}/$import_maps_test_scopes_only_path/js/bar/whiz.js",
+        ],
+        "{$basePath}/absolute/js/bar/" => [
+          'whiz' => "{$basePath}/absolute/library/whiz.js",
+        ],
+      ],
+    ];
+
+    $import_maps = $manager->getImportMapForTheme('importmaps_test_basetheme');
+    self::assertEquals($base_import_maps, $import_maps);
+
+    $theme_imports = NestedArray::mergeDeep($base_import_maps, [
+      'imports' => [
+        'baz' => "{$basePath}/$import_maps_test_theme_path/js/baz.js",
+        // @see \importmaps_test_importmaps_alter()
+        'foo' => "{$basePath}/absolute/js/foo.js",
+      ],
+      'scopes' => [
+        "{$basePath}/$import_maps_test_theme_path/js/whiz/" => [
+          'baz' => "{$basePath}/$import_maps_test_theme_path/js/whiz/baz.js",
+        ],
+      ],
+    ]);
+
+    $import_maps = $manager->getImportMapForTheme('importmaps_test_theme');
+    self::assertEquals($theme_imports, $import_maps);
+  }
+
+}