diff --git a/.eslintrc.js b/.eslintrc.js
index f7d778987d4df0..5d8b57f41c94a5 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -76,7 +76,7 @@ const restrictedSyntax = [
// here. That's why we use \\u002F in the regexes below.
{
selector:
- 'ImportDeclaration[source.value=/^@wordpress\\u002F.+\\u002F/]',
+ 'ImportDeclaration[source.value=/^@wordpress\\u002F.+\\u002F/]:not([source.value=/^@wordpress\\u002F.+\\u002Fbuild-types\\u002F/])',
message: 'Path access on WordPress dependencies is not allowed.',
},
{
diff --git a/docs/manifest.json b/docs/manifest.json
index 4a911fa47669ad..d66a1215940474 100644
--- a/docs/manifest.json
+++ b/docs/manifest.json
@@ -1517,6 +1517,12 @@
"markdown_source": "../packages/blocks/README.md",
"parent": "packages"
},
+ {
+ "title": "@wordpress/boot",
+ "slug": "packages-boot",
+ "markdown_source": "../packages/boot/README.md",
+ "parent": "packages"
+ },
{
"title": "@wordpress/browserslist-config",
"slug": "packages-browserslist-config",
diff --git a/lib/experimental/boot/boot-menu-items.php b/lib/experimental/boot/boot-menu-items.php
new file mode 100644
index 00000000000000..dde0028543ace9
--- /dev/null
+++ b/lib/experimental/boot/boot-menu-items.php
@@ -0,0 +1,49 @@
+ $id,
+ 'label' => $label,
+ 'to' => $to,
+ );
+
+ // Only include parent if it's not empty (matches next-admin approach).
+ if ( ! empty( $parent_id ) ) {
+ $menu_item['parent'] = $parent_id;
+ }
+
+ $gutenberg_boot_menu_items[] = $menu_item;
+}
+
+/**
+ * Get all registered boot menu items.
+ *
+ * @return array Array of registered menu items.
+ */
+function gutenberg_get_boot_menu_items() {
+ global $gutenberg_boot_menu_items;
+ return $gutenberg_boot_menu_items;
+}
diff --git a/lib/experimental/boot/boot.php b/lib/experimental/boot/boot.php
new file mode 100644
index 00000000000000..d36da3364b491f
--- /dev/null
+++ b/lib/experimental/boot/boot.php
@@ -0,0 +1,193 @@
+queue as $script ) {
+ wp_dequeue_script( $script );
+ }
+ foreach ( wp_styles()->queue as $style ) {
+ wp_dequeue_style( $style );
+ }
+
+ // Fire init action for extensions to register routes and menu items.
+ do_action( 'gutenberg_boot_init' );
+
+ // Register some default menu items for demonstration.
+ gutenberg_register_boot_menu_item( 'home', __( 'Home', 'gutenberg' ), '/', '' );
+ gutenberg_register_boot_menu_item( 'about', __( 'About', 'gutenberg' ), '/', '' );
+ gutenberg_register_boot_menu_item( 'settings', __( 'Settings', 'gutenberg' ), '/', '' );
+
+ // Get routes and menu items.
+ $menu_items = gutenberg_get_boot_menu_items();
+
+ // Get boot module asset file for dependencies.
+ $asset_file = gutenberg_dir_path() . 'build/modules/boot/index.min.asset.php';
+ if ( file_exists( $asset_file ) ) {
+ $asset = require $asset_file;
+
+ // This script serves two purposes:
+ // 1. It ensures all the globals that are made available to the modules are loaded.
+ // 2. It initializes the boot module as an inline script.
+ wp_register_script( 'gutenberg-boot-prerequisites', '', $asset['dependencies'], $asset['version'], true );
+
+ // Add inline script to initialize the app.
+ wp_add_inline_script(
+ 'gutenberg-boot-prerequisites',
+ sprintf(
+ 'import("@wordpress/boot").then(mod => mod.init(%s));',
+ wp_json_encode( $menu_items, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES )
+ )
+ );
+
+ // Register prerequisites style by filtering script dependencies to find registered styles.
+ $style_dependencies = array_filter(
+ $asset['dependencies'],
+ function ( $handle ) {
+ return wp_style_is( $handle, 'registered' );
+ }
+ );
+ wp_register_style( 'gutenberg-boot-prerequisites', false, $style_dependencies, $asset['version'] );
+
+ // Dummy script module to ensure dependencies are loaded.
+ wp_register_script_module(
+ 'gutenberg-boot',
+ gutenberg_url( 'lib/experimental/boot/loader.js' ),
+ array(
+ 'import' => 'static',
+ 'id' => '@wordpress/boot',
+ )
+ );
+
+ // Enqueue the boot scripts a,d styles.
+ wp_enqueue_script( 'gutenberg-boot-prerequisites' );
+ wp_enqueue_script_module( 'gutenberg-boot' );
+ wp_enqueue_style( 'gutenberg-boot-prerequisites' );
+ }
+
+ // Output the HTML.
+ ?>
+
+ >
+
+
+
+
+
+
+
+
+
+ print_import_map();
+ print_footer_scripts();
+ wp_script_modules()->print_enqueued_script_modules();
+ wp_script_modules()->print_script_module_preloads();
+ wp_script_modules()->print_script_module_data();
+
+ /**
+ * Prints scripts or data after the default footer scripts.
+ *
+ * @since 2.8.0
+ */
+ do_action( "admin_footer-{$hook_suffix}" ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
+ // END see wp-admin/admin-footer.php.
+ ?>
+
+
+ =12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-router": {
+ "version": "1.133.36",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.133.36.tgz",
+ "integrity": "sha512-pT4d2uEucDQ3SAIQ0pLUw6RUKwkB5pHzpBB6otaoKpO0cAwHkRPi+p59DivuzSANJLHLVEiXyJCCk72EeHMRxA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/history": "1.133.28",
+ "@tanstack/react-store": "^0.8.0",
+ "@tanstack/router-core": "1.133.36",
+ "isbot": "^5.1.22",
+ "tiny-invariant": "^1.3.3",
+ "tiny-warning": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": ">=18.0.0 || >=19.0.0",
+ "react-dom": ">=18.0.0 || >=19.0.0"
+ }
+ },
+ "node_modules/@tanstack/react-store": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.8.0.tgz",
+ "integrity": "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/store": "0.8.0",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@tanstack/router-core": {
+ "version": "1.133.36",
+ "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.133.36.tgz",
+ "integrity": "sha512-VJ9kFduePsxgjhDW+uKxL6ol9ZpJlaUO2EI9Zmq8AA6uhW/LRg+0/365OZOZaVqNqvx2eKt3MZKHLN+29jd5Uw==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/history": "1.133.28",
+ "@tanstack/store": "^0.8.0",
+ "cookie-es": "^2.0.0",
+ "seroval": "^1.3.2",
+ "seroval-plugins": "^1.3.2",
+ "tiny-invariant": "^1.3.3",
+ "tiny-warning": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/store": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.8.0.tgz",
+ "integrity": "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@terrazzo/cli": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@terrazzo/cli/-/cli-0.10.3.tgz",
@@ -16907,6 +16995,10 @@
"resolved": "packages/blocks",
"link": true
},
+ "node_modules/@wordpress/boot": {
+ "resolved": "packages/boot",
+ "link": true
+ },
"node_modules/@wordpress/browserslist-config": {
"resolved": "packages/browserslist-config",
"link": true
@@ -21835,6 +21927,12 @@
"node": ">= 0.6"
}
},
+ "node_modules/cookie-es": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-2.0.0.tgz",
+ "integrity": "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg==",
+ "license": "MIT"
+ },
"node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
@@ -29961,6 +30059,15 @@
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
},
+ "node_modules/isbot": {
+ "version": "5.1.31",
+ "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.31.tgz",
+ "integrity": "sha512-DPgQshehErHAqSCKDb3rNW03pa2wS/v5evvUqtxt6TTnHRqAG8FdzcSSJs9656pK6Y+NT7K9R4acEYXLHYfpUQ==",
+ "license": "Unlicense",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -43787,6 +43894,27 @@
"randombytes": "^2.1.0"
}
},
+ "node_modules/seroval": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
+ "integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/seroval-plugins": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.3.3.tgz",
+ "integrity": "sha512-16OL3NnUBw8JG1jBLUoZJsLnQq0n5Ua6aHalhJK4fMQkz1lqR7Osz1sA30trBtd9VUDc2NgkuRCn8+/pBwqZ+w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "seroval": "^1.0"
+ }
+ },
"node_modules/serve-favicon": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/serve-favicon/-/serve-favicon-2.5.0.tgz",
@@ -46726,8 +46854,13 @@
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
- "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
- "dev": true
+ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="
+ },
+ "node_modules/tiny-warning": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
+ "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
+ "license": "MIT"
},
"node_modules/tinyrainbow": {
"version": "1.2.0",
@@ -47966,12 +48099,12 @@
}
},
"node_modules/use-sync-external-store": {
- "version": "1.2.2",
- "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz",
- "integrity": "sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==",
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
- "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/userhome": {
@@ -52024,6 +52157,39 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
},
+ "packages/boot": {
+ "name": "@wordpress/boot",
+ "version": "0.1.0",
+ "license": "GPL-2.0-or-later",
+ "dependencies": {
+ "@tanstack/history": "^1.133.28",
+ "@tanstack/react-router": "^1.120.5",
+ "@wordpress/admin-ui": "file:../admin-ui",
+ "@wordpress/commands": "file:../commands",
+ "@wordpress/components": "file:../components",
+ "@wordpress/compose": "file:../compose",
+ "@wordpress/core-data": "file:../core-data",
+ "@wordpress/data": "file:../data",
+ "@wordpress/element": "file:../element",
+ "@wordpress/html-entities": "file:../html-entities",
+ "@wordpress/i18n": "file:../i18n",
+ "@wordpress/icons": "file:../icons",
+ "@wordpress/keycodes": "file:../keycodes",
+ "@wordpress/primitives": "file:../primitives",
+ "@wordpress/private-apis": "file:../private-apis",
+ "@wordpress/theme": "file:../theme",
+ "@wordpress/url": "file:../url",
+ "clsx": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=18.12.0",
+ "npm": ">=8.19.2"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ }
+ },
"packages/browserslist-config": {
"name": "@wordpress/browserslist-config",
"version": "6.34.0",
diff --git a/packages/boot/package.json b/packages/boot/package.json
new file mode 100644
index 00000000000000..1142ad6f2809ac
--- /dev/null
+++ b/packages/boot/package.json
@@ -0,0 +1,63 @@
+{
+ "name": "@wordpress/boot",
+ "version": "0.1.0",
+ "description": "Minimal boot package for WordPress admin pages.",
+ "author": "The WordPress Contributors",
+ "license": "GPL-2.0-or-later",
+ "keywords": [
+ "wordpress",
+ "gutenberg",
+ "boot"
+ ],
+ "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/boot/README.md",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/WordPress/gutenberg.git",
+ "directory": "packages/boot"
+ },
+ "bugs": {
+ "url": "https://github.com/WordPress/gutenberg/issues"
+ },
+ "engines": {
+ "node": ">=18.12.0",
+ "npm": ">=8.19.2"
+ },
+ "module": "build-module/index.js",
+ "exports": {
+ ".": {
+ "types": "./build-types/index.d.ts",
+ "import": "./build-module/index.js"
+ },
+ "./package.json": "./package.json"
+ },
+ "react-native": "src/index",
+ "wpScriptModuleExports": "./build-module/index.js",
+ "types": "build-types",
+ "dependencies": {
+ "@tanstack/history": "^1.133.28",
+ "@tanstack/react-router": "^1.120.5",
+ "@wordpress/admin-ui": "file:../admin-ui",
+ "@wordpress/commands": "file:../commands",
+ "@wordpress/components": "file:../components",
+ "@wordpress/compose": "file:../compose",
+ "@wordpress/core-data": "file:../core-data",
+ "@wordpress/data": "file:../data",
+ "@wordpress/element": "file:../element",
+ "@wordpress/html-entities": "file:../html-entities",
+ "@wordpress/i18n": "file:../i18n",
+ "@wordpress/icons": "file:../icons",
+ "@wordpress/keycodes": "file:../keycodes",
+ "@wordpress/primitives": "file:../primitives",
+ "@wordpress/private-apis": "file:../private-apis",
+ "@wordpress/theme": "file:../theme",
+ "@wordpress/url": "file:../url",
+ "clsx": "^2.1.1"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ },
+ "publishConfig": {
+ "access": "public"
+ }
+}
diff --git a/packages/boot/src/components/app/index.tsx b/packages/boot/src/components/app/index.tsx
new file mode 100644
index 00000000000000..e6a54480d78706
--- /dev/null
+++ b/packages/boot/src/components/app/index.tsx
@@ -0,0 +1,35 @@
+/**
+ * WordPress dependencies
+ */
+import { createRoot, StrictMode } from '@wordpress/element';
+import { dispatch } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import Router from './router';
+import { STORE_NAME } from '../../store';
+import type { MenuItem } from '../../store/types';
+
+function App() {
+ return ;
+}
+
+export async function init( menuItems: MenuItem[] ) {
+ // Register menu items
+ menuItems.forEach( ( menuItem ) => {
+ // @ts-ignore
+ dispatch( STORE_NAME ).registerMenuItem( menuItem.id, menuItem );
+ } );
+
+ // Render the app
+ const rootElement = document.getElementById( 'gutenberg-boot-app' );
+ if ( rootElement ) {
+ const root = createRoot( rootElement );
+ root.render(
+
+
+
+ );
+ }
+}
diff --git a/packages/boot/src/components/app/router.tsx b/packages/boot/src/components/app/router.tsx
new file mode 100644
index 00000000000000..f806707f1d7bc5
--- /dev/null
+++ b/packages/boot/src/components/app/router.tsx
@@ -0,0 +1,76 @@
+/**
+ * External dependencies
+ */
+import {
+ createRouter,
+ createRootRoute,
+ createRoute,
+ RouterProvider,
+ createBrowserHistory,
+} from '@tanstack/react-router';
+import { parseHref } from '@tanstack/history';
+
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import Root from '../root';
+import Home from '../home';
+
+// Not found component
+function NotFoundComponent() {
+ return (
+
+
{ __( 'Route not found' ) }
+
+ );
+}
+
+// Create root route
+const rootRoute = createRootRoute( {
+ component: Root,
+} );
+
+// Create home route
+const homeRoute = createRoute( {
+ getParentRoute: () => rootRoute,
+ path: '/',
+ component: Home,
+} );
+
+// Create route tree
+const routeTree = rootRoute.addChildren( [ homeRoute ] );
+
+// Create custom history that parses ?p= query parameter
+function createPathHistory() {
+ return createBrowserHistory( {
+ parseLocation: () => {
+ const url = new URL( window.location.href );
+ const path = url.searchParams.get( 'p' ) || '/';
+ const pathHref = `${ path }${ url.hash }`;
+ return parseHref( pathHref, window.history.state );
+ },
+ createHref: ( href ) => {
+ const searchParams = new URLSearchParams( window.location.search );
+ searchParams.set( 'p', href );
+ return `${ window.location.pathname }?${ searchParams }`;
+ },
+ } );
+}
+
+const history = createPathHistory();
+
+// Create router
+const router = createRouter( {
+ history,
+ routeTree,
+ defaultNotFoundComponent: NotFoundComponent,
+} );
+
+export default function Router() {
+ return ;
+}
diff --git a/packages/boot/src/components/home/index.tsx b/packages/boot/src/components/home/index.tsx
new file mode 100644
index 00000000000000..398c9a24bc2bf5
--- /dev/null
+++ b/packages/boot/src/components/home/index.tsx
@@ -0,0 +1,13 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Page } from '@wordpress/admin-ui';
+
+export default function Home() {
+ return (
+
+ { __( 'Welcome to the minimal boot package!' ) }
+
+ );
+}
diff --git a/packages/boot/src/components/navigation/drilldown-item/index.tsx b/packages/boot/src/components/navigation/drilldown-item/index.tsx
new file mode 100644
index 00000000000000..b86ce410e0842c
--- /dev/null
+++ b/packages/boot/src/components/navigation/drilldown-item/index.tsx
@@ -0,0 +1,88 @@
+/**
+ * External dependencies
+ */
+import clsx from 'clsx';
+import type { ReactNode } from 'react';
+
+/**
+ * WordPress dependencies
+ */
+import {
+ FlexBlock,
+ __experimentalItem as Item,
+ // @ts-ignore
+ __experimentalHStack as HStack,
+ Icon,
+} from '@wordpress/components';
+import { isRTL } from '@wordpress/i18n';
+import { chevronRightSmall, chevronLeftSmall } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import { wrapIcon } from '../items';
+import type { IconType } from '../../../store/types';
+
+interface DrilldownItemProps {
+ /**
+ * Optional CSS class name.
+ */
+ className?: string;
+ /**
+ * Identifier of the navigation item.
+ */
+ id: string;
+ /**
+ * Icon to display with the navigation item.
+ */
+ icon?: IconType;
+ /**
+ * Whether to show placeholder icons for alignment.
+ */
+ shouldShowPlaceholder?: boolean;
+ /**
+ * Content to display inside the navigation item.
+ */
+ children: ReactNode;
+ /**
+ * Function to handle sidebar navigation when the item is clicked.
+ */
+ onNavigate: ( {
+ id,
+ direction,
+ }: {
+ id?: string;
+ direction: 'forward' | 'backward';
+ } ) => void;
+}
+
+export default function DrilldownItem( {
+ className,
+ id,
+ icon,
+ shouldShowPlaceholder = true,
+ children,
+ onNavigate,
+}: DrilldownItemProps ) {
+ const handleClick = ( e: React.MouseEvent ) => {
+ e.preventDefault();
+ onNavigate( { id, direction: 'forward' } );
+ };
+
+ return (
+ -
+
+ { wrapIcon( icon, shouldShowPlaceholder ) }
+ { children }
+
+
+
+ );
+}
diff --git a/packages/boot/src/components/navigation/dropdown-item/index.tsx b/packages/boot/src/components/navigation/dropdown-item/index.tsx
new file mode 100644
index 00000000000000..3e65a564df1139
--- /dev/null
+++ b/packages/boot/src/components/navigation/dropdown-item/index.tsx
@@ -0,0 +1,134 @@
+/**
+ * External dependencies
+ */
+import clsx from 'clsx';
+import type { ReactNode } from 'react';
+
+/**
+ * WordPress dependencies
+ */
+import {
+ FlexBlock,
+ __experimentalItem as Item,
+ // @ts-ignore
+ __experimentalHStack as HStack,
+ Icon,
+ __unstableMotion as motion,
+ __unstableAnimatePresence as AnimatePresence,
+} from '@wordpress/components';
+import { chevronDownSmall } from '@wordpress/icons';
+import { useReducedMotion } from '@wordpress/compose';
+import { useSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { STORE_NAME } from '../../../store';
+import NavigationItem from '../navigation-item';
+import { wrapIcon } from '../items';
+import type { IconType, MenuItem } from '../../../store/types';
+import './style.scss';
+
+const ANIMATION_DURATION = 0.2;
+
+interface DropdownItemProps {
+ /**
+ * Optional CSS class name.
+ */
+ className?: string;
+ /**
+ * Identifier of the parent menu item.
+ */
+ id: string;
+ /**
+ * Icon to display with the dropdown item.
+ */
+ icon?: IconType;
+ /**
+ * Whether to show placeholder icons for alignment.
+ */
+ shouldShowPlaceholder?: boolean;
+ /**
+ * Content to display inside the dropdown item.
+ */
+ children: ReactNode;
+ /**
+ * Whether this dropdown is currently expanded.
+ */
+ isExpanded: boolean;
+ /**
+ * Function to toggle this dropdown's expanded state.
+ */
+ onToggle: () => void;
+}
+
+export default function DropdownItem( {
+ className,
+ id,
+ icon,
+ children,
+ isExpanded,
+ onToggle,
+}: DropdownItemProps ) {
+ const menuItems: MenuItem[] = useSelect(
+ ( select ) =>
+ // @ts-ignore
+ select( STORE_NAME ).getMenuItems(),
+ []
+ );
+ const items = menuItems.filter( ( item ) => item.parent === id );
+ const disableMotion = useReducedMotion();
+ return (
+
+
- {
+ e.preventDefault();
+ e.stopPropagation();
+ onToggle();
+ } }
+ onMouseDown={ ( e ) => e.preventDefault() }
+ >
+
+ { wrapIcon( icon, false ) }
+ { children }
+
+
+
+
+ { isExpanded && (
+
+ { items.map( ( item, index ) => (
+
+ { item.label }
+
+ ) ) }
+
+ ) }
+
+
+ );
+}
diff --git a/packages/boot/src/components/navigation/dropdown-item/style.scss b/packages/boot/src/components/navigation/dropdown-item/style.scss
new file mode 100644
index 00000000000000..49c520f265841a
--- /dev/null
+++ b/packages/boot/src/components/navigation/dropdown-item/style.scss
@@ -0,0 +1,23 @@
+@use "@wordpress/base-styles/variables";
+
+.boot-dropdown-item__children {
+ display: flex;
+ flex-direction: column;
+
+ // In order to avoid the focus ring of each list item from being cut off,
+ // we add padding around the menu items.
+ // At the same time, we use the same value to tweak margins so that
+ // the items still retain the same position and footprint.
+ $padding-to-avoid-cutting-focus-ring: 2px;
+ padding: $padding-to-avoid-cutting-focus-ring;
+ margin-block-start: -$padding-to-avoid-cutting-focus-ring;
+ margin-block-end: $padding-to-avoid-cutting-focus-ring;
+ margin-inline-start:
+ variables.$grid-unit-40 -
+ $padding-to-avoid-cutting-focus-ring;
+ overflow: hidden;
+}
+
+.boot-dropdown-item__chevron.is-up {
+ transform: rotate(180deg);
+}
diff --git a/packages/boot/src/components/navigation/index.tsx b/packages/boot/src/components/navigation/index.tsx
new file mode 100644
index 00000000000000..3ed7e05316431b
--- /dev/null
+++ b/packages/boot/src/components/navigation/index.tsx
@@ -0,0 +1,126 @@
+/**
+ * WordPress dependencies
+ */
+import { useState, useMemo, useRef } from '@wordpress/element';
+import { useSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { STORE_NAME } from '../../store';
+import NavigationItem from './navigation-item';
+import DrilldownItem from './drilldown-item';
+import DropdownItem from './dropdown-item';
+import NavigationScreen from './navigation-screen';
+import { useSidebarParent } from './use-sidebar-parent';
+import type { MenuItem } from '../../store/types';
+
+function Navigation() {
+ const backButtonRef = useRef< HTMLButtonElement >( null );
+ const [ animationDirection, setAnimationDirection ] = useState<
+ 'forward' | 'backward' | null
+ >( null );
+ const [ parentId, setParentId, parentDropdownId, setParentDropdownId ] =
+ useSidebarParent();
+ const menuItems = useSelect(
+ ( select ) =>
+ // @ts-ignore
+ select( STORE_NAME ).getMenuItems() as MenuItem[],
+ []
+ );
+ const parent = useMemo(
+ () => menuItems.find( ( item ) => item.id === parentId ),
+ [ menuItems, parentId ]
+ );
+ // Create a unique key for the current navigation state
+ // The sidebar will animate when the key changes.
+ const navigationKey = parent ? `drilldown-${ parent.id }` : 'root';
+
+ // We use transitions to handle navigation clicks
+ // This allows smooth animations and non blocking navigation.
+ const handleNavigate = ( {
+ id,
+ direction,
+ }: {
+ id?: string;
+ direction: 'forward' | 'backward';
+ } ) => {
+ setAnimationDirection( direction );
+ setParentId( id );
+ };
+
+ const handleDropdownToggle = ( dropdownId: string ) => {
+ setParentDropdownId(
+ parentDropdownId === dropdownId ? undefined : dropdownId
+ );
+ };
+
+ const items = useMemo(
+ () => menuItems.filter( ( item ) => item.parent === parentId ),
+ [ menuItems, parentId ]
+ );
+
+ const hasRealIcons = items.some( ( item ) => !! item.icon );
+
+ return (
+
+ { items.map( ( item: MenuItem ) => {
+ if ( item.parent_type === 'dropdown' ) {
+ return (
+
+ handleDropdownToggle( item.id )
+ }
+ >
+ { item.label }
+
+ );
+ }
+
+ if ( item.parent_type === 'drilldown' ) {
+ return (
+
+ { item.label }
+
+ );
+ }
+
+ return (
+
+ { item.label }
+
+ );
+ } ) }
+ >
+ }
+ />
+ );
+}
+
+export default Navigation;
diff --git a/packages/boot/src/components/navigation/items.tsx b/packages/boot/src/components/navigation/items.tsx
new file mode 100644
index 00000000000000..8205dcb505f129
--- /dev/null
+++ b/packages/boot/src/components/navigation/items.tsx
@@ -0,0 +1,93 @@
+/**
+ * WordPress dependencies
+ */
+import { isValidElement } from '@wordpress/element';
+import { Dashicon, Icon } from '@wordpress/components';
+import { SVG } from '@wordpress/primitives';
+
+/**
+ * Internal dependencies
+ */
+import type { IconType } from '../../store/types';
+
+/**
+ * Type guard for verifying whether a given element
+ * is a valid SVG element for the Icon component.
+ *
+ * @param element - The element to check
+ */
+function isSvg( element: unknown ): element is JSX.Element {
+ return (
+ isValidElement( element ) &&
+ ( element.type === SVG || element.type === 'svg' )
+ );
+}
+
+/**
+ * Converts the given IconType into a renderable component:
+ * - Dashicon string into a Dashicon component
+ * - JSX SVG element into an Icon component
+ * - Data URL into an img element
+ *
+ * @param icon - The icon to convert
+ * @param shouldShowPlaceholder - Whether to show placeholder when no icon is provided
+ * @return The converted icon as a JSX element
+ */
+export function wrapIcon(
+ icon?: IconType,
+ shouldShowPlaceholder: boolean = true
+) {
+ if ( isSvg( icon ) ) {
+ return ;
+ }
+
+ if ( typeof icon === 'string' && icon.startsWith( 'dashicons-' ) ) {
+ const iconKey = icon.replace(
+ /^dashicons-/,
+ ''
+ ) as React.ComponentProps< typeof Dashicon >[ 'icon' ];
+
+ return (
+
+ );
+ }
+
+ // Handle data URLs (SVG images)
+ if ( typeof icon === 'string' && icon.startsWith( 'data:' ) ) {
+ return (
+
+ );
+ }
+
+ // If icon is provided and valid, return it as-is
+ if ( icon ) {
+ return icon;
+ }
+
+ // Return empty 24px placeholder for alignment when no icon is provided
+ // Only if shouldShowPlaceholder is true
+ if ( shouldShowPlaceholder ) {
+ return (
+
+ );
+ }
+
+ return null;
+}
diff --git a/packages/boot/src/components/navigation/navigation-item/index.tsx b/packages/boot/src/components/navigation/navigation-item/index.tsx
new file mode 100644
index 00000000000000..f353f249068045
--- /dev/null
+++ b/packages/boot/src/components/navigation/navigation-item/index.tsx
@@ -0,0 +1,88 @@
+/**
+ * External dependencies
+ */
+import clsx from 'clsx';
+import type { ReactNode } from 'react';
+
+/**
+ * WordPress dependencies
+ */
+import {
+ FlexBlock,
+ __experimentalItem as Item,
+ // @ts-ignore
+ __experimentalHStack as HStack,
+} from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import RouterLinkItem from '../router-link-item';
+import { wrapIcon } from '../items';
+import type { IconType } from '../../../store/types';
+import './style.scss';
+
+interface NavigationItemProps {
+ /**
+ * Optional CSS class name.
+ */
+ className?: string;
+ /**
+ * Icon to display with the navigation item.
+ */
+ icon?: IconType;
+ /**
+ * Whether to show placeholder icons for alignment.
+ */
+ shouldShowPlaceholder?: boolean;
+ /**
+ * Content to display inside the navigation item.
+ */
+ children: ReactNode;
+ /**
+ * The path to navigate to.
+ */
+ to: string;
+}
+
+export default function NavigationItem( {
+ className,
+ icon,
+ shouldShowPlaceholder = true,
+ children,
+ to,
+}: NavigationItemProps ) {
+ // Check if the 'to' prop is an external URL
+ const isExternal = ! String(
+ new URL( to, window.location.origin )
+ ).startsWith( window.location.origin );
+
+ const content = (
+
+ { wrapIcon( icon, shouldShowPlaceholder ) }
+ { children }
+
+ );
+
+ if ( isExternal ) {
+ // Render as a regular anchor tag for external URLs
+ return (
+ -
+ { content }
+
+ );
+ }
+
+ return (
+
+ { content }
+
+ );
+}
diff --git a/packages/boot/src/components/navigation/navigation-item/style.scss b/packages/boot/src/components/navigation/navigation-item/style.scss
new file mode 100644
index 00000000000000..a402c270711458
--- /dev/null
+++ b/packages/boot/src/components/navigation/navigation-item/style.scss
@@ -0,0 +1,52 @@
+@use "@wordpress/base-styles/variables";
+@use "@wordpress/base-styles/mixins";
+
+.boot-navigation-item.components-item {
+ color: var(--wpds-color-fg-interactive-neutral, #1e1e1e);
+ padding-inline: variables.$grid-unit-05;
+ padding-block: 0;
+ margin-inline: variables.$grid-unit-15;
+ margin-block-end: variables.$grid-unit-05;
+ width: calc(100% - variables.$grid-unit-15 * 2);
+ border: none;
+ min-height: variables.$grid-unit-40;
+ display: flex;
+ align-items: center;
+ @include mixins.body-medium();
+
+ .boot-dropdown-item__children & {
+ min-height: variables.$grid-unit-30;
+ }
+
+ // Rounded focus ring
+ border-radius: var(--wpds-border-radius-small, 2px);
+
+ &.active,
+ &:hover,
+ &:focus,
+ &[aria-current="true"] {
+ color: var(--wpds-color-fg-interactive-brand-active, #0073aa);
+ }
+
+ &.active {
+ font-weight: variables.$font-weight-medium;
+ }
+
+ svg:last-child {
+ padding: variables.$grid-unit-05;
+ }
+
+ &[aria-current="true"] {
+ color: var(--wpds-color-fg-interactive-brand-active, #0073aa);
+ font-weight: variables.$font-weight-medium;
+ }
+
+ // Make sure the focus style is drawn on top of the current item background.
+ &:focus-visible {
+ transform: translateZ(0);
+ }
+
+ &.with-suffix {
+ padding-right: variables.$grid-unit-20;
+ }
+}
diff --git a/packages/boot/src/components/navigation/navigation-screen/index.tsx b/packages/boot/src/components/navigation/navigation-screen/index.tsx
new file mode 100644
index 00000000000000..4ad79fa253fcfc
--- /dev/null
+++ b/packages/boot/src/components/navigation/navigation-screen/index.tsx
@@ -0,0 +1,147 @@
+/**
+ * External dependencies
+ */
+import type { ReactNode, RefObject } from 'react';
+
+/**
+ * WordPress dependencies
+ */
+import {
+ __experimentalHeading as Heading,
+ __unstableMotion as motion,
+ __unstableAnimatePresence as AnimatePresence,
+ Button,
+ __experimentalHStack as HStack,
+} from '@wordpress/components';
+import { isRTL, __ } from '@wordpress/i18n';
+import { chevronRight, chevronLeft } from '@wordpress/icons';
+import { useReducedMotion } from '@wordpress/compose';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+
+const ANIMATION_DURATION = 0.3;
+const slideVariants = {
+ initial: ( direction: 'forward' | 'backward' ) => ( {
+ x: direction === 'forward' ? 100 : -100,
+ opacity: 0,
+ } ),
+ animate: {
+ x: 0,
+ opacity: 1,
+ },
+ exit: ( direction: 'forward' | 'backward' ) => ( {
+ x: direction === 'forward' ? 100 : -100,
+ opacity: 0,
+ } ),
+};
+
+export default function NavigationScreen( {
+ isRoot,
+ title,
+ actions,
+ content,
+ description,
+ animationDirection,
+ backMenuItem,
+ backButtonRef,
+ navigationKey,
+ onNavigate,
+}: {
+ isRoot?: boolean;
+ title: string;
+ actions?: ReactNode;
+ content: ReactNode;
+ description?: ReactNode;
+ backMenuItem?: string;
+ backButtonRef?: RefObject< HTMLButtonElement >;
+ animationDirection?: 'forward' | 'backward';
+ navigationKey?: string;
+ onNavigate: ( {
+ id,
+ direction,
+ }: {
+ id?: string;
+ direction: 'forward' | 'backward';
+ } ) => void;
+} ) {
+ const icon = isRTL() ? chevronRight : chevronLeft;
+ const disableMotion = useReducedMotion();
+
+ const handleBackClick = ( e: React.MouseEvent ) => {
+ e.preventDefault();
+ onNavigate( { id: backMenuItem, direction: 'backward' } );
+ };
+
+ return (
+
+
+
+
+ { ! isRoot && (
+
+ ) }
+
+ { title }
+
+ { actions && (
+
+ { actions }
+
+ ) }
+
+
+ { description && (
+
+ { description }
+
+ ) }
+
+ { content }
+
+
+
+ );
+}
diff --git a/packages/boot/src/components/navigation/navigation-screen/style.scss b/packages/boot/src/components/navigation/navigation-screen/style.scss
new file mode 100644
index 00000000000000..dce76aaf77bed9
--- /dev/null
+++ b/packages/boot/src/components/navigation/navigation-screen/style.scss
@@ -0,0 +1,34 @@
+@use "@wordpress/base-styles/variables";
+
+.boot-navigation-screen {
+ // Avoid cutting off focus ring of the last menu item
+ padding-block-end: variables.$grid-unit-05;
+}
+
+.boot-navigation-screen .components-text {
+ color: var(--wpds-color-fg-content-neutral, #1e1e1e);
+}
+
+.boot-navigation-screen__title-icon {
+ position: sticky;
+ top: 0;
+ padding:
+ variables.$grid-unit-15 variables.$grid-unit-20
+ variables.$grid-unit-10 variables.$grid-unit-20;
+}
+
+.boot-navigation-screen__title {
+ flex-grow: 1;
+ overflow-wrap: break-word;
+
+ {&},
+ {&} .boot-navigation-screen__title {
+ line-height: variables.$font-line-height-x-large;
+ color: var(--wpds-color-fg-content-neutral, #1e1e1e);
+ }
+}
+
+.boot-navigation-screen__actions {
+ display: flex;
+ flex-shrink: 0;
+}
diff --git a/packages/boot/src/components/navigation/path-matching.ts b/packages/boot/src/components/navigation/path-matching.ts
new file mode 100644
index 00000000000000..d916bb26d08683
--- /dev/null
+++ b/packages/boot/src/components/navigation/path-matching.ts
@@ -0,0 +1,149 @@
+/**
+ * Internal dependencies
+ */
+import type { MenuItem } from '../../store/types';
+
+/**
+ * Checks if a menu path is a valid parent path of the current path.
+ * A valid parent path must be a complete path prefix, not just share segments.
+ *
+ * @param currentPath - Current page path
+ * @param menuPath - Menu item path to check as potential parent
+ * @return True if menuPath is a parent of currentPath
+ */
+const isValidParentPath = (
+ currentPath: string,
+ menuPath: string
+): boolean => {
+ if ( ! menuPath || menuPath === currentPath ) {
+ return false;
+ }
+
+ // Normalize paths by removing trailing slashes and ensuring leading slash
+ const normalizePath = ( path: string ) => {
+ const normalized = path.startsWith( '/' ) ? path : '/' + path;
+ return normalized.endsWith( '/' ) && normalized.length > 1
+ ? normalized.slice( 0, -1 )
+ : normalized;
+ };
+
+ const normalizedCurrent = normalizePath( currentPath );
+ const normalizedMenu = normalizePath( menuPath );
+
+ // Menu path must be shorter and current path must start with menu path + '/'
+ return (
+ normalizedCurrent.startsWith( normalizedMenu ) &&
+ ( normalizedCurrent[ normalizedMenu.length ] === '/' ||
+ normalizedMenu === '/' )
+ );
+};
+
+/**
+ * Finds the menu item that is the closest parent of the current path.
+ * Only considers menu items that have a 'to' path defined and are valid parents.
+ *
+ * @param currentPath - Current page path
+ * @param menuItems - Array of all menu items
+ * @return Menu item that is the closest parent, or null if no valid parent found
+ */
+export const findClosestMenuItem = (
+ currentPath: string,
+ menuItems: MenuItem[]
+): MenuItem | null => {
+ const exactMatch = menuItems.find( ( item ) => item.to === currentPath );
+ if ( exactMatch ) {
+ return exactMatch;
+ }
+
+ let bestMatch: MenuItem | null = null;
+ let bestPathLength = 0;
+
+ for ( const item of menuItems ) {
+ if ( ! item.to ) {
+ continue;
+ }
+
+ // Only consider items that are valid parents of the current path
+ if ( isValidParentPath( currentPath, item.to ) ) {
+ // Prefer the longest parent path (most specific)
+ if ( item.to.length > bestPathLength ) {
+ bestMatch = item;
+ bestPathLength = item.to.length;
+ }
+ }
+ }
+
+ return bestMatch;
+};
+
+/**
+ * Finds the drilldown parent of a menu item by traversing up the menu tree.
+ *
+ * @param id - The ID of the menu item to find the drilldown parent for
+ * @param menuItems - Array of all menu items
+ * @return The ID of the drilldown parent, or undefined if none found
+ */
+export const findDrilldownParent = (
+ id: string | undefined,
+ menuItems: MenuItem[]
+): string | undefined => {
+ if ( ! id ) {
+ return undefined;
+ }
+
+ const currentItem = menuItems.find( ( item ) => item.id === id );
+ if ( ! currentItem ) {
+ return undefined;
+ }
+
+ // If the item has a parent, check if that parent is a drilldown
+ if ( currentItem.parent ) {
+ const parentItem = menuItems.find(
+ ( item ) => item.id === currentItem.parent
+ );
+
+ if ( parentItem?.parent_type === 'drilldown' ) {
+ return parentItem.id;
+ }
+
+ if ( parentItem ) {
+ return findDrilldownParent( parentItem.id, menuItems );
+ }
+ }
+
+ return undefined;
+};
+
+/**
+ * Finds the dropdown parent of a menu item.
+ *
+ * @param id - The ID of the menu item to find the dropdown parent for
+ * @param menuItems - Array of all menu items
+ * @return The ID of the dropdown parent, or undefined if none found
+ */
+export const findDropdownParent = (
+ id: string | undefined,
+ menuItems: MenuItem[]
+): string | undefined => {
+ if ( ! id ) {
+ return undefined;
+ }
+
+ const currentItem = menuItems.find( ( item ) => item.id === id );
+ if ( ! currentItem ) {
+ return undefined;
+ }
+
+ // If the item has a parent, check if that parent is a dropdown
+ if ( currentItem.parent ) {
+ const parentItem = menuItems.find(
+ ( item ) => item.id === currentItem.parent
+ );
+
+ if ( parentItem?.parent_type === 'dropdown' ) {
+ return parentItem.id;
+ }
+ }
+
+ return undefined;
+};
diff --git a/packages/boot/src/components/navigation/router-link-item.tsx b/packages/boot/src/components/navigation/router-link-item.tsx
new file mode 100644
index 00000000000000..94cf8bcab08d16
--- /dev/null
+++ b/packages/boot/src/components/navigation/router-link-item.tsx
@@ -0,0 +1,24 @@
+/**
+ * External dependencies
+ */
+import { createLink } from '@tanstack/react-router';
+import type { ForwardedRef } from 'react';
+
+/**
+ * WordPress dependencies
+ */
+import { forwardRef } from '@wordpress/element';
+import { __experimentalItem as Item } from '@wordpress/components';
+import type { ItemProps } from '@wordpress/components/build-types/item-group/types';
+import type { WordPressComponentProps } from '@wordpress/components/build-types/context';
+
+function AnchorOnlyItem(
+ props: WordPressComponentProps< ItemProps, 'a' >,
+ forwardedRef: ForwardedRef< HTMLAnchorElement >
+) {
+ return ;
+}
+
+const RouterLinkItem = createLink( forwardRef( AnchorOnlyItem ) );
+
+export default RouterLinkItem;
diff --git a/packages/boot/src/components/navigation/use-sidebar-parent.ts b/packages/boot/src/components/navigation/use-sidebar-parent.ts
new file mode 100644
index 00000000000000..bd71c2cd7d87b2
--- /dev/null
+++ b/packages/boot/src/components/navigation/use-sidebar-parent.ts
@@ -0,0 +1,77 @@
+/**
+ * External dependencies
+ */
+import { useRouter, useMatches } from '@tanstack/react-router';
+
+/**
+ * WordPress dependencies
+ */
+import { useEffect, useState } from '@wordpress/element';
+import { useSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { STORE_NAME } from '../../store';
+import {
+ findDrilldownParent,
+ findDropdownParent,
+ findClosestMenuItem,
+} from './path-matching';
+
+/**
+ * The `useSidebarParent` hook returns the ID of the parent menu item
+ * to render in the sidebar based on the current route.
+ *
+ * - It finds the closest matching menu item when exact path matches fail
+ * - It allows the user to navigate in the sidebar (local state) without changing the URL.
+ * - If the URL changes, it will update the parent ID to ensure the correct drilldown level is displayed.
+ *
+ * @return The ID of the parent menu item to render in the sidebar.
+ */
+export function useSidebarParent() {
+ const matches = useMatches();
+ const router = useRouter();
+ const menuItems = useSelect(
+ ( select ) =>
+ // @ts-ignore
+ select( STORE_NAME ).getMenuItems(),
+ []
+ );
+
+ const currentPath = matches[ matches.length - 1 ].pathname.slice(
+ router.options.basepath?.length ?? 0
+ );
+
+ const currentMenuItem = findClosestMenuItem( currentPath, menuItems );
+ const [ parentId, setParentId ] = useState< string | undefined >(
+ findDrilldownParent( currentMenuItem?.id, menuItems )
+ );
+ const [ parentDropdownId, setParentDropdownId ] = useState<
+ string | undefined
+ >( findDropdownParent( currentMenuItem?.id, menuItems ) );
+
+ // Effect to update parent IDs when URL or menu items change
+ useEffect( () => {
+ const matchedMenuItem = findClosestMenuItem( currentPath, menuItems );
+ // Find the appropriate parents for the current route
+ const updatedParentId = findDrilldownParent(
+ matchedMenuItem?.id,
+ menuItems
+ );
+ const updatedDropdownParent = findDropdownParent(
+ matchedMenuItem?.id,
+ menuItems
+ );
+
+ setParentId( updatedParentId );
+ setParentDropdownId( updatedDropdownParent );
+ }, [ currentPath, menuItems ] );
+
+ return [
+ parentId,
+ setParentId,
+ parentDropdownId,
+ setParentDropdownId,
+ ] as const;
+}
diff --git a/packages/boot/src/components/root/index.tsx b/packages/boot/src/components/root/index.tsx
new file mode 100644
index 00000000000000..803f8d3ec1cbc7
--- /dev/null
+++ b/packages/boot/src/components/root/index.tsx
@@ -0,0 +1,46 @@
+/**
+ * External dependencies
+ */
+import { Outlet } from '@tanstack/react-router';
+
+/**
+ * WordPress dependencies
+ */
+// @ts-expect-error Commands is not typed properly.
+import { CommandMenu } from '@wordpress/commands';
+import { privateApis as themePrivateApis } from '@wordpress/theme';
+
+/**
+ * Internal dependencies
+ */
+import Sidebar from '../sidebar';
+import { unlock } from '../../lock-unlock';
+import './style.scss';
+
+const { ThemeProvider } = unlock( themePrivateApis );
+
+export default function Root() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/packages/boot/src/components/root/style.scss b/packages/boot/src/components/root/style.scss
new file mode 100644
index 00000000000000..ecc84607e30024
--- /dev/null
+++ b/packages/boot/src/components/root/style.scss
@@ -0,0 +1,38 @@
+@use "@wordpress/base-styles/variables";
+
+.boot-layout {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ color: var(--wpds-color-fg-content-neutral, #1e1e1e);
+ isolation: isolate;
+ background: var(--wpds-color-bg-surface-neutral-weak, #f0f0f0);
+}
+
+.boot-layout__content {
+ height: 100%;
+ flex-grow: 1;
+ display: flex;
+}
+
+.boot-layout__sidebar-region {
+ flex-shrink: 0;
+ width: 240px;
+}
+
+.boot-layout__sidebar {
+ height: 100%;
+ position: relative;
+ overflow: hidden;
+}
+
+.boot-layout__stage {
+ flex: 1;
+ overflow-y: auto;
+ background: var(--wpds-color-bg-surface-neutral, #fff);
+ color: var(--wpds-color-fg-content-neutral, #1e1e1e);
+ margin: variables.$grid-unit-10;
+ border-radius: 8px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+ border: 1px solid var(--wpds-color-stroke-surface-neutral-weak, #ddd);
+}
diff --git a/packages/boot/src/components/sidebar/index.tsx b/packages/boot/src/components/sidebar/index.tsx
new file mode 100644
index 00000000000000..8e0c0a56d42896
--- /dev/null
+++ b/packages/boot/src/components/sidebar/index.tsx
@@ -0,0 +1,17 @@
+/**
+ * Internal dependencies
+ */
+import SiteHub from '../site-hub';
+import Navigation from '../navigation';
+import './style.scss';
+
+export default function Sidebar() {
+ return (
+
+ );
+}
diff --git a/packages/boot/src/components/sidebar/style.scss b/packages/boot/src/components/sidebar/style.scss
new file mode 100644
index 00000000000000..a756532b6410eb
--- /dev/null
+++ b/packages/boot/src/components/sidebar/style.scss
@@ -0,0 +1,15 @@
+@use "@wordpress/base-styles/variables";
+
+.boot-sidebar__scrollable {
+ overflow: auto;
+ height: 100%;
+ position: relative;
+ display: flex;
+ flex-direction: column;
+}
+
+.boot-sidebar__content {
+ flex-grow: 1;
+ contain: content;
+ position: relative;
+}
diff --git a/packages/boot/src/components/site-hub/index.tsx b/packages/boot/src/components/site-hub/index.tsx
new file mode 100644
index 00000000000000..e57829496d7a41
--- /dev/null
+++ b/packages/boot/src/components/site-hub/index.tsx
@@ -0,0 +1,67 @@
+/**
+ * WordPress dependencies
+ */
+import { useSelect, useDispatch } from '@wordpress/data';
+import {
+ ExternalLink,
+ Button,
+ // @ts-ignore
+ __experimentalHStack as HStack,
+} from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { store as coreStore } from '@wordpress/core-data';
+import { decodeEntities } from '@wordpress/html-entities';
+import { search } from '@wordpress/icons';
+import { displayShortcut } from '@wordpress/keycodes';
+// @ts-expect-error Commands is not typed properly.
+import { store as commandsStore } from '@wordpress/commands';
+import { filterURLForDisplay } from '@wordpress/url';
+import type { UnstableBase } from '@wordpress/core-data';
+
+/**
+ * Internal dependencies
+ */
+import SiteIconLink from '../site-icon-link';
+import './style.scss';
+
+function SiteHub() {
+ const { homeUrl, siteTitle } = useSelect( ( select ) => {
+ const { getEntityRecord } = select( coreStore );
+ const _base = getEntityRecord< UnstableBase >(
+ 'root',
+ '__unstableBase'
+ );
+ return {
+ homeUrl: _base?.home,
+ siteTitle:
+ ! _base?.name && !! _base?.url
+ ? filterURLForDisplay( _base?.url )
+ : _base?.name,
+ };
+ }, [] );
+ const { open: openCommandCenter } = useDispatch( commandsStore );
+
+ return (
+
+
+
+ { siteTitle && decodeEntities( siteTitle ) }
+
+
+
+
+ );
+}
+
+export default SiteHub;
diff --git a/packages/boot/src/components/site-hub/style.scss b/packages/boot/src/components/site-hub/style.scss
new file mode 100644
index 00000000000000..f1beddc18ab9ef
--- /dev/null
+++ b/packages/boot/src/components/site-hub/style.scss
@@ -0,0 +1,54 @@
+@use "@wordpress/base-styles/variables";
+
+.boot-site-hub {
+ position: sticky;
+ top: 0;
+ background-color: var(--wpds-color-bg-surface-neutral-weak, #f0f0f0);
+ z-index: 1;
+ display: grid;
+ grid-template-columns: 60px 1fr auto;
+ align-items: center;
+ padding-right: variables.$grid-unit-20;
+ flex-shrink: 0; // Prevent flex parent from shrinking this element.
+}
+
+.boot-site-hub__actions {
+ flex-shrink: 0;
+}
+
+.boot-site-hub__title {
+ color: var(--wpds-color-fg-content-neutral, #1e1e1e);
+ font-size: variables.$font-size-medium;
+ font-weight: variables.$font-weight-medium;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ text-decoration: none;
+
+ .components-external-link__contents {
+ text-decoration: none;
+ margin-inline-start: variables.$grid-unit-05;
+ }
+
+ // Show icon on hover
+ .components-external-link__icon {
+ opacity: 0;
+ transition: opacity 0.1s ease-out;
+ }
+
+ &:hover .components-external-link__icon {
+ opacity: 1;
+ }
+
+ // Focus styles
+ @media not (prefers-reduced-motion) {
+ transition: outline 0.1s ease-out;
+ }
+
+ &:focus:not(:active) {
+ outline:
+ var(--wpds-border-width-focus, 2px) solid
+ var(--wpds-color-stroke-focus-brand, #0073aa);
+ outline-offset: calc(-1 * var(--wpds-border-width-focus, 2px));
+ }
+}
diff --git a/packages/boot/src/components/site-icon-link/index.tsx b/packages/boot/src/components/site-icon-link/index.tsx
new file mode 100644
index 00000000000000..7a3b2764a72fae
--- /dev/null
+++ b/packages/boot/src/components/site-icon-link/index.tsx
@@ -0,0 +1,43 @@
+/**
+ * External dependencies
+ */
+import { Link, useCanGoBack, useRouter } from '@tanstack/react-router';
+
+/**
+ * Internal dependencies
+ */
+import SiteIcon from '../site-icon';
+import './style.scss';
+
+function SiteIconLink( {
+ to,
+ isBackButton,
+ ...props
+}: {
+ to: string;
+ 'aria-label': string;
+ isBackButton?: boolean;
+} ) {
+ const router = useRouter();
+ const canGoBack = useCanGoBack();
+
+ return (
+ {
+ // If possible, restore the previous page with
+ // filters etc.
+ if ( canGoBack && isBackButton ) {
+ event.preventDefault();
+ router.history.back();
+ }
+ } }
+ >
+
+
+ );
+}
+
+export default SiteIconLink;
diff --git a/packages/boot/src/components/site-icon-link/style.scss b/packages/boot/src/components/site-icon-link/style.scss
new file mode 100644
index 00000000000000..db403792faea4f
--- /dev/null
+++ b/packages/boot/src/components/site-icon-link/style.scss
@@ -0,0 +1,24 @@
+@use "@wordpress/base-styles/variables";
+
+$header-height: variables.$header-height;
+
+.boot-site-icon-link {
+ width: $header-height;
+ height: $header-height;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--wpds-color-bg-surface-neutral-weak, #f0f0f0);
+ text-decoration: none;
+
+ @media not (prefers-reduced-motion) {
+ transition: outline 0.1s ease-out;
+ }
+
+ &:focus:not(:active) {
+ outline:
+ var(--wpds-border-width-focus, 2px) solid
+ var(--wpds-color-stroke-focus-brand, #0073aa);
+ outline-offset: calc(-1 * var(--wpds-border-width-focus, 2px));
+ }
+}
diff --git a/packages/boot/src/components/site-icon/index.tsx b/packages/boot/src/components/site-icon/index.tsx
new file mode 100644
index 00000000000000..883ff4c4de8af6
--- /dev/null
+++ b/packages/boot/src/components/site-icon/index.tsx
@@ -0,0 +1,60 @@
+/**
+ * External dependencies
+ */
+import clsx from 'clsx';
+
+/**
+ * WordPress dependencies
+ */
+import { useSelect } from '@wordpress/data';
+import { Icon, wordpress } from '@wordpress/icons';
+import { __ } from '@wordpress/i18n';
+import { store as coreDataStore } from '@wordpress/core-data';
+import type { UnstableBase } from '@wordpress/core-data';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+
+function SiteIcon( { className }: { className?: string } ) {
+ const { isRequestingSite, siteIconUrl } = useSelect( ( select ) => {
+ const { getEntityRecord } = select( coreDataStore );
+ const siteData = getEntityRecord< UnstableBase >(
+ 'root',
+ '__unstableBase',
+ undefined
+ );
+
+ return {
+ isRequestingSite: ! siteData,
+ siteIconUrl: siteData?.site_icon_url,
+ };
+ }, [] );
+
+ let icon = null;
+
+ if ( isRequestingSite && ! siteIconUrl ) {
+ icon = ;
+ } else {
+ icon = siteIconUrl ? (
+
+ ) : (
+
+ );
+ }
+
+ return (
+ { icon }
+ );
+}
+
+export default SiteIcon;
diff --git a/packages/boot/src/components/site-icon/style.scss b/packages/boot/src/components/site-icon/style.scss
new file mode 100644
index 00000000000000..3c5dd689e86124
--- /dev/null
+++ b/packages/boot/src/components/site-icon/style.scss
@@ -0,0 +1,19 @@
+@use "@wordpress/base-styles/variables";
+
+.boot-site-icon {
+ display: flex;
+}
+
+.boot-site-icon__icon {
+ width: variables.$grid-unit-40;
+ height: variables.$grid-unit-40;
+ color: var(--wpds-color-fg-content-neutral, #1e1e1e);
+}
+
+.boot-site-icon__image {
+ width: variables.$grid-unit-40;
+ height: variables.$grid-unit-40;
+ object-fit: cover;
+ aspect-ratio: 1 / 1;
+ border-radius: var(--wpds-border-radius-medium, 4px);
+}
diff --git a/packages/boot/src/index.tsx b/packages/boot/src/index.tsx
new file mode 100644
index 00000000000000..74221742689465
--- /dev/null
+++ b/packages/boot/src/index.tsx
@@ -0,0 +1,5 @@
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+export { init } from './components/app';
diff --git a/packages/boot/src/lock-unlock.ts b/packages/boot/src/lock-unlock.ts
new file mode 100644
index 00000000000000..b5f5b62f78e943
--- /dev/null
+++ b/packages/boot/src/lock-unlock.ts
@@ -0,0 +1,9 @@
+/**
+ * WordPress dependencies
+ */
+import { __dangerousOptInToUnstableAPIsOnlyForCoreModules } from '@wordpress/private-apis';
+export const { lock, unlock } =
+ __dangerousOptInToUnstableAPIsOnlyForCoreModules(
+ 'I acknowledge private features are not for use in themes or plugins and doing so will break in the next version of WordPress.',
+ '@wordpress/boot'
+ );
diff --git a/packages/boot/src/store/actions.ts b/packages/boot/src/store/actions.ts
new file mode 100644
index 00000000000000..014ccff11c4904
--- /dev/null
+++ b/packages/boot/src/store/actions.ts
@@ -0,0 +1,14 @@
+/**
+ * Internal dependencies
+ */
+import type { MenuItem } from './types';
+
+export function registerMenuItem( id: string, menuItem: MenuItem ) {
+ return {
+ type: 'REGISTER_MENU_ITEM' as const,
+ id,
+ menuItem,
+ };
+}
+
+export type Action = ReturnType< typeof registerMenuItem >;
diff --git a/packages/boot/src/store/index.ts b/packages/boot/src/store/index.ts
new file mode 100644
index 00000000000000..e65b53708d3b08
--- /dev/null
+++ b/packages/boot/src/store/index.ts
@@ -0,0 +1,23 @@
+/**
+ * WordPress dependencies
+ */
+import { createReduxStore, register } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { reducer } from './reducer';
+import * as actions from './actions';
+import * as selectors from './selectors';
+
+const STORE_NAME = 'wordpress/boot';
+
+export const store = createReduxStore( STORE_NAME, {
+ reducer,
+ actions,
+ selectors,
+} );
+
+register( store );
+
+export { STORE_NAME };
diff --git a/packages/boot/src/store/reducer.ts b/packages/boot/src/store/reducer.ts
new file mode 100644
index 00000000000000..035dfba393e1c6
--- /dev/null
+++ b/packages/boot/src/store/reducer.ts
@@ -0,0 +1,24 @@
+/**
+ * Internal dependencies
+ */
+import type { Action } from './actions';
+import type { State } from './types';
+
+const initialState: State = {
+ menuItems: {},
+};
+
+export function reducer( state: State = initialState, action: Action ): State {
+ switch ( action.type ) {
+ case 'REGISTER_MENU_ITEM':
+ return {
+ ...state,
+ menuItems: {
+ ...state.menuItems,
+ [ action.id ]: action.menuItem,
+ },
+ };
+ }
+
+ return state;
+}
diff --git a/packages/boot/src/store/selectors.ts b/packages/boot/src/store/selectors.ts
new file mode 100644
index 00000000000000..e76b8320cf22df
--- /dev/null
+++ b/packages/boot/src/store/selectors.ts
@@ -0,0 +1,8 @@
+/**
+ * Internal dependencies
+ */
+import type { State } from './types';
+
+export function getMenuItems( state: State ) {
+ return Object.values( state.menuItems );
+}
diff --git a/packages/boot/src/store/types.ts b/packages/boot/src/store/types.ts
new file mode 100644
index 00000000000000..9e4c6eff911684
--- /dev/null
+++ b/packages/boot/src/store/types.ts
@@ -0,0 +1,26 @@
+/**
+ * External dependencies
+ */
+import type { ReactNode } from 'react';
+
+/**
+ * Icon type supporting multiple formats:
+ * - Dashicon strings (e.g., "dashicons-admin-generic")
+ * - JSX elements
+ * - SVG icons from @wordpress/icons
+ * - Data URLs for images
+ */
+export type IconType = string | JSX.Element | ReactNode;
+
+export interface MenuItem {
+ id: string;
+ label: string;
+ to: string;
+ icon?: IconType;
+ parent?: string;
+ parent_type?: 'drilldown' | 'dropdown';
+}
+
+export interface State {
+ menuItems: Record< string, MenuItem >;
+}
diff --git a/packages/boot/src/style.scss b/packages/boot/src/style.scss
new file mode 100644
index 00000000000000..e2ba6606087ec1
--- /dev/null
+++ b/packages/boot/src/style.scss
@@ -0,0 +1,2 @@
+@use "@wordpress/theme/src/prebuilt/css/design-tokens.css" as *;
+@use "@wordpress/admin-ui/build-style/style.css" as *;
diff --git a/packages/boot/tsconfig.json b/packages/boot/tsconfig.json
new file mode 100644
index 00000000000000..c77d4a844a2a85
--- /dev/null
+++ b/packages/boot/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig.json",
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "checkJs": false
+ },
+ "references": [
+ { "path": "../admin-ui" },
+ { "path": "../components" },
+ { "path": "../compose" },
+ { "path": "../core-data" },
+ { "path": "../data" },
+ { "path": "../element" },
+ { "path": "../html-entities" },
+ { "path": "../i18n" },
+ { "path": "../icons" },
+ { "path": "../keycodes" },
+ { "path": "../primitives" },
+ { "path": "../private-apis" },
+ { "path": "../theme" },
+ { "path": "../url" }
+ ]
+}
diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index b92ee9b8450700..f01fea4e35e500 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -2,6 +2,10 @@
## Unreleased
+
+### Bug Fixes
+
+- Reexport all of the `build-types` folder from the `components` package entry point ([#72809](https://github.com/WordPress/gutenberg/pull/72809)).
- `TextareaControl`: Add `min-height` to the textarea element ([#72867](https://github.com/WordPress/gutenberg/pull/72867)).
## 30.7.0 (2025-10-29)
diff --git a/packages/components/package.json b/packages/components/package.json
index 385596768d5886..9f4d90721c4c19 100644
--- a/packages/components/package.json
+++ b/packages/components/package.json
@@ -26,7 +26,7 @@
"module": "build-module/index.js",
"exports": {
".": {
- "types": "./build-types/index.d.ts",
+ "types": "./build-types",
"import": "./build-module/index.js",
"require": "./build/index.js"
},
diff --git a/packages/private-apis/src/implementation.ts b/packages/private-apis/src/implementation.ts
index e5c7feb2958e5b..2cf342646cca01 100644
--- a/packages/private-apis/src/implementation.ts
+++ b/packages/private-apis/src/implementation.ts
@@ -14,6 +14,7 @@ const CORE_MODULES_USING_PRIVATE_APIS = [
'@wordpress/block-editor',
'@wordpress/block-library',
'@wordpress/blocks',
+ '@wordpress/boot',
'@wordpress/commands',
'@wordpress/components',
'@wordpress/core-commands',
diff --git a/tsconfig.json b/tsconfig.json
index 6a94076539a3de..63b65a44e0e46b 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -10,6 +10,7 @@
{ "path": "packages/block-editor" },
{ "path": "packages/block-library" },
{ "path": "packages/block-serialization-default-parser" },
+ { "path": "packages/boot" },
{ "path": "packages/components" },
{ "path": "packages/compose" },
{ "path": "packages/core-data" },