diff --git a/packages/e2e-test-utils-playwright/src/admin/visit-site-editor.ts b/packages/e2e-test-utils-playwright/src/admin/visit-site-editor.ts
index a4c3adc747a1e7..bd25796d25eccf 100644
--- a/packages/e2e-test-utils-playwright/src/admin/visit-site-editor.ts
+++ b/packages/e2e-test-utils-playwright/src/admin/visit-site-editor.ts
@@ -54,4 +54,9 @@ export async function visitSiteEditor(
 		.locator( 'body > *' )
 		.first()
 		.waitFor();
+
+	// TODO: Ideally the content underneath the spinner should be marked inert until it's ready.
+	await this.page
+		.locator( '.edit-site-canvas-spinner' )
+		.waitFor( { state: 'hidden' } );
 }
diff --git a/packages/e2e-test-utils-playwright/src/editor/site-editor.ts b/packages/e2e-test-utils-playwright/src/editor/site-editor.ts
index d3fb58f9aab401..432e8c15b120a7 100644
--- a/packages/e2e-test-utils-playwright/src/editor/site-editor.ts
+++ b/packages/e2e-test-utils-playwright/src/editor/site-editor.ts
@@ -9,15 +9,25 @@ import type { Editor } from './index';
  * @param this
  */
 export async function saveSiteEditorEntities( this: Editor ) {
-	await this.page.click(
-		'role=region[name="Editor top bar"i] >> role=button[name="Save"i]'
-	);
+	const editorTopBar = this.page.getByRole( 'region', {
+		name: 'Editor top bar',
+	} );
+	const savePanel = this.page.getByRole( 'region', { name: 'Save panel' } );
+
+	// First Save button in the top bar.
+	await editorTopBar
+		.getByRole( 'button', { name: 'Save', exact: true } )
+		.click();
+
 	// Second Save button in the entities panel.
-	await this.page.click(
-		'role=region[name="Save panel"i] >> role=button[name="Save"i]'
-	);
+	await savePanel
+		.getByRole( 'button', { name: 'Save', exact: true } )
+		.click();
+
 	// A role selector cannot be used here because it needs to check that the `is-busy` class is not present.
-	await this.page.waitForSelector( '[aria-label="Saved"].is-busy', {
-		state: 'hidden',
-	} );
+	await this.page
+		.locator( '[aria-label="Editor top bar"] [aria-label="Saved"].is-busy' )
+		.waitFor( {
+			state: 'hidden',
+		} );
 }
diff --git a/packages/edit-site/src/components/editor/index.js b/packages/edit-site/src/components/editor/index.js
index 777f6dd53afdf6..44b31c6945f57f 100644
--- a/packages/edit-site/src/components/editor/index.js
+++ b/packages/edit-site/src/components/editor/index.js
@@ -1,10 +1,15 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+
 /**
  * WordPress dependencies
  */
-import { useEffect, useMemo, useState } from '@wordpress/element';
+import { useMemo } from '@wordpress/element';
 import { useSelect, useDispatch } from '@wordpress/data';
 import { Notice } from '@wordpress/components';
-import { EntityProvider, store as coreStore } from '@wordpress/core-data';
+import { EntityProvider } from '@wordpress/core-data';
 import { store as preferencesStore } from '@wordpress/preferences';
 import {
 	BlockContextProvider,
@@ -50,42 +55,7 @@ const interfaceLabels = {
 	footer: __( 'Editor footer' ),
 };
 
-function useIsSiteEditorLoading() {
-	const { isLoaded: hasLoadedPost } = useEditedEntityRecord();
-	const [ loaded, setLoaded ] = useState( false );
-	const inLoadingPause = useSelect(
-		( select ) => {
-			const hasResolvingSelectors =
-				select( coreStore ).hasResolvingSelectors();
-			return ! loaded && ! hasResolvingSelectors;
-		},
-		[ loaded ]
-	);
-
-	useEffect( () => {
-		if ( inLoadingPause ) {
-			/*
-			 * We're using an arbitrary 1s timeout here to catch brief moments
-			 * without any resolving selectors that would result in displaying
-			 * brief flickers of loading state and loaded state.
-			 *
-			 * It's worth experimenting with different values, since this also
-			 * adds 1s of artificial delay after loading has finished.
-			 */
-			const timeout = setTimeout( () => {
-				setLoaded( true );
-			}, 1000 );
-
-			return () => {
-				clearTimeout( timeout );
-			};
-		}
-	}, [ inLoadingPause ] );
-
-	return ! loaded || ! hasLoadedPost;
-}
-
-export default function Editor() {
+export default function Editor( { isLoading } ) {
 	const {
 		record: editedPost,
 		getTitle,
@@ -188,8 +158,6 @@ export default function Editor() {
 	// action in <URlQueryController> from double-announcing.
 	useTitle( hasLoadedPost && title );
 
-	const isLoading = useIsSiteEditorLoading();
-
 	return (
 		<>
 			{ isLoading ? <CanvasSpinner /> : null }
@@ -205,7 +173,13 @@ export default function Editor() {
 						{ isEditMode && <StartTemplateOptions /> }
 						<InterfaceSkeleton
 							enableRegionNavigation={ false }
-							className={ showIconLabels && 'show-icon-labels' }
+							className={ classnames(
+								'edit-site-editor__interface-skeleton',
+								{
+									'show-icon-labels': showIconLabels,
+									'is-loading': isLoading,
+								}
+							) }
 							notices={
 								( isEditMode ||
 									window?.__experimentalEnableThemePreviews ) && (
diff --git a/packages/edit-site/src/components/editor/style.scss b/packages/edit-site/src/components/editor/style.scss
index 1a24d3ee1475e7..b8795d9ba7cb31 100644
--- a/packages/edit-site/src/components/editor/style.scss
+++ b/packages/edit-site/src/components/editor/style.scss
@@ -1,3 +1,13 @@
+.edit-site-editor__interface-skeleton {
+	opacity: 1;
+	transition: opacity 0.1s ease-out;
+	@include reduce-motion("transition");
+
+	&.is-loading {
+		opacity: 0;
+	}
+}
+
 .edit-site-editor__toggle-save-panel {
 	box-sizing: border-box;
 	width: $sidebar-width;
diff --git a/packages/edit-site/src/components/layout/hooks.js b/packages/edit-site/src/components/layout/hooks.js
new file mode 100644
index 00000000000000..7a89987cb7482c
--- /dev/null
+++ b/packages/edit-site/src/components/layout/hooks.js
@@ -0,0 +1,46 @@
+/**
+ * WordPress dependencies
+ */
+import { useEffect, useState } from '@wordpress/element';
+import { useSelect } from '@wordpress/data';
+import { store as coreStore } from '@wordpress/core-data';
+
+/**
+ * Internal dependencies
+ */
+import useEditedEntityRecord from '../use-edited-entity-record';
+
+export function useIsSiteEditorLoading() {
+	const { isLoaded: hasLoadedPost } = useEditedEntityRecord();
+	const [ loaded, setLoaded ] = useState( false );
+	const inLoadingPause = useSelect(
+		( select ) => {
+			const hasResolvingSelectors =
+				select( coreStore ).hasResolvingSelectors();
+			return ! loaded && ! hasResolvingSelectors;
+		},
+		[ loaded ]
+	);
+
+	useEffect( () => {
+		if ( inLoadingPause ) {
+			/*
+			 * We're using an arbitrary 1s timeout here to catch brief moments
+			 * without any resolving selectors that would result in displaying
+			 * brief flickers of loading state and loaded state.
+			 *
+			 * It's worth experimenting with different values, since this also
+			 * adds 1s of artificial delay after loading has finished.
+			 */
+			const timeout = setTimeout( () => {
+				setLoaded( true );
+			}, 1000 );
+
+			return () => {
+				clearTimeout( timeout );
+			};
+		}
+	}, [ inLoadingPause ] );
+
+	return ! loaded || ! hasLoadedPost;
+}
diff --git a/packages/edit-site/src/components/layout/index.js b/packages/edit-site/src/components/layout/index.js
index 54b3f6ab22a1da..83527bae6d7e3b 100644
--- a/packages/edit-site/src/components/layout/index.js
+++ b/packages/edit-site/src/components/layout/index.js
@@ -11,7 +11,6 @@ import {
 	__unstableMotion as motion,
 	__unstableAnimatePresence as AnimatePresence,
 	__unstableUseNavigateRegions as useNavigateRegions,
-	ResizableBox,
 } from '@wordpress/components';
 import {
 	useReducedMotion,
@@ -42,30 +41,20 @@ import getIsListPage from '../../utils/get-is-list-page';
 import Header from '../header-edit-mode';
 import useInitEditedEntityFromURL from '../sync-state-with-url/use-init-edited-entity-from-url';
 import SiteHub from '../site-hub';
-import ResizeHandle from '../block-editor/resize-handle';
+import ResizableFrame from '../resizable-frame';
 import useSyncCanvasModeWithURL from '../sync-state-with-url/use-sync-canvas-mode-with-url';
 import { unlock } from '../../private-apis';
 import SavePanel from '../save-panel';
 import KeyboardShortcutsRegister from '../keyboard-shortcuts/register';
 import KeyboardShortcutsGlobal from '../keyboard-shortcuts/global';
 import { useEditModeCommands } from '../../hooks/commands/use-edit-mode-commands';
+import { useIsSiteEditorLoading } from './hooks';
 
 const { useCommands } = unlock( coreCommandsPrivateApis );
 const { useCommandContext } = unlock( commandsPrivateApis );
 const { useLocation } = unlock( routerPrivateApis );
 
 const ANIMATION_DURATION = 0.5;
-const emptyResizeHandleStyles = {
-	position: undefined,
-	userSelect: undefined,
-	cursor: undefined,
-	width: undefined,
-	height: undefined,
-	top: undefined,
-	right: undefined,
-	bottom: undefined,
-	left: undefined,
-};
 
 export default function Layout() {
 	// This ensures the edited entity id and type are initialized properly.
@@ -96,36 +85,26 @@ export default function Layout() {
 					select( preferencesStore ).get( 'fixedToolbar' ),
 			};
 		}, [] );
+	const isEditing = canvasMode === 'edit';
 	const navigateRegionsProps = useNavigateRegions( {
 		previous: previousShortcut,
 		next: nextShortcut,
 	} );
 	const disableMotion = useReducedMotion();
 	const isMobileViewport = useViewportMatch( 'medium', '<' );
-	const canvasPadding = isMobileViewport ? 0 : 24;
 	const showSidebar =
 		( isMobileViewport && ! isListPage ) ||
 		( ! isMobileViewport && ( canvasMode === 'view' || ! isEditorPage ) );
 	const showCanvas =
-		( isMobileViewport && isEditorPage && canvasMode === 'edit' ) ||
+		( isMobileViewport && isEditorPage && isEditing ) ||
 		! isMobileViewport ||
 		! isEditorPage;
-	const showFrame =
-		( ! isEditorPage && ! isMobileViewport ) ||
-		( ! isMobileViewport && isEditorPage && canvasMode === 'view' );
 	const isFullCanvas =
-		( isMobileViewport && isListPage ) ||
-		( isEditorPage && canvasMode === 'edit' );
+		( isMobileViewport && isListPage ) || ( isEditorPage && isEditing );
 	const [ canvasResizer, canvasSize ] = useResizeObserver();
-	const [ fullResizer, fullSize ] = useResizeObserver();
-	const [ forcedWidth, setForcedWidth ] = useState( null );
-	const [ isResizing, setIsResizing ] = useState( false );
-	const isResizingEnabled = ! isMobileViewport && canvasMode === 'view';
-	const defaultSidebarWidth = isMobileViewport ? '100vw' : 360;
-	let canvasWidth = isResizing ? '100%' : fullSize.width;
-	if ( showFrame && ! isResizing ) {
-		canvasWidth = canvasSize.width - canvasPadding;
-	}
+	const [ fullResizer ] = useResizeObserver();
+	const [ isResizing ] = useState( false );
+	const isEditorLoading = useIsSiteEditorLoading();
 
 	// Sets the right context for the command center
 	const commandContext =
@@ -155,7 +134,7 @@ export default function Layout() {
 					navigateRegionsProps.className,
 					{
 						'is-full-canvas': isFullCanvas,
-						'is-edit-mode': canvasMode === 'edit',
+						'is-edit-mode': isEditing,
 						'has-fixed-toolbar': hasFixedToolbar,
 					}
 				) }
@@ -163,7 +142,7 @@ export default function Layout() {
 				<SiteHub ref={ hubRef } className="edit-site-layout__hub" />
 
 				<AnimatePresence initial={ false }>
-					{ isEditorPage && canvasMode === 'edit' && (
+					{ isEditorPage && isEditing && (
 						<NavigableRegion
 							className="edit-site-layout__header"
 							ariaLabel={ __( 'Editor top bar' ) }
@@ -185,7 +164,7 @@ export default function Layout() {
 								ease: 'easeOut',
 							} }
 						>
-							<Header />
+							{ isEditing && <Header /> }
 						</NavigableRegion>
 					) }
 				</AnimatePresence>
@@ -193,8 +172,7 @@ export default function Layout() {
 				<div className="edit-site-layout__content">
 					<AnimatePresence initial={ false }>
 						{ showSidebar && (
-							<ResizableBox
-								as={ motion.div }
+							<motion.div
 								initial={ {
 									opacity: 0,
 								} }
@@ -206,69 +184,17 @@ export default function Layout() {
 								} }
 								transition={ {
 									type: 'tween',
-									duration:
-										disableMotion || isResizing
-											? 0
-											: ANIMATION_DURATION,
+									duration: ANIMATION_DURATION,
 									ease: 'easeOut',
 								} }
-								size={ {
-									height: '100%',
-									width:
-										isResizingEnabled && forcedWidth
-											? forcedWidth
-											: defaultSidebarWidth,
-								} }
 								className="edit-site-layout__sidebar"
-								enable={ {
-									right: isResizingEnabled,
-								} }
-								onResizeStop={ ( event, direction, elt ) => {
-									setForcedWidth( elt.clientWidth );
-									setIsResizing( false );
-								} }
-								onResizeStart={ () => {
-									setIsResizing( true );
-								} }
-								onResize={ ( event, direction, elt ) => {
-									// This is a performance optimization
-									// We set the width imperatively to avoid re-rendering
-									// the whole component while resizing.
-									hubRef.current.style.width =
-										elt.clientWidth - 48 + 'px';
-								} }
-								handleComponent={ {
-									right: (
-										<ResizeHandle
-											direction="right"
-											variation="separator"
-											resizeWidthBy={ ( delta ) => {
-												setForcedWidth(
-													( forcedWidth ??
-														defaultSidebarWidth ) +
-														delta
-												);
-											} }
-										/>
-									),
-								} }
-								handleClasses={ undefined }
-								handleStyles={ {
-									right: emptyResizeHandleStyles,
-								} }
-								minWidth={ isResizingEnabled ? 320 : undefined }
-								maxWidth={
-									isResizingEnabled && fullSize
-										? fullSize.width - 360
-										: undefined
-								}
 							>
 								<NavigableRegion
 									ariaLabel={ __( 'Navigation' ) }
 								>
 									<Sidebar />
 								</NavigableRegion>
-							</ResizableBox>
+							</motion.div>
 						) }
 					</AnimatePresence>
 
@@ -282,10 +208,6 @@ export default function Layout() {
 									'is-resizing': isResizing,
 								}
 							) }
-							style={ {
-								paddingTop: showFrame ? canvasPadding : 0,
-								paddingBottom: showFrame ? canvasPadding : 0,
-							} }
 						>
 							{ canvasResizer }
 							{ !! canvasSize.width && (
@@ -317,31 +239,22 @@ export default function Layout() {
 										ease: 'easeOut',
 									} }
 								>
-									<motion.div
-										style={ {
-											position: 'absolute',
-											top: 0,
-											left: 0,
-											bottom: 0,
-										} }
-										initial={ false }
-										animate={ {
-											width: canvasWidth,
-										} }
-										transition={ {
-											type: 'tween',
-											duration:
-												disableMotion || isResizing
-													? 0
-													: ANIMATION_DURATION,
-											ease: 'easeOut',
-										} }
-									>
-										<ErrorBoundary>
-											{ isEditorPage && <Editor /> }
-											{ isListPage && <ListPage /> }
-										</ErrorBoundary>
-									</motion.div>
+									<ErrorBoundary>
+										{ isEditorPage && (
+											<ResizableFrame
+												isReady={ ! isEditorLoading }
+												isFullWidth={ isEditing }
+												oversizedClassName="edit-site-layout__resizable-frame-oversized"
+											>
+												<Editor
+													isLoading={
+														isEditorLoading
+													}
+												/>
+											</ResizableFrame>
+										) }
+										{ isListPage && <ListPage /> }
+									</ErrorBoundary>
 								</motion.div>
 							) }
 						</div>
diff --git a/packages/edit-site/src/components/layout/style.scss b/packages/edit-site/src/components/layout/style.scss
index 89a6fa3c9ccf18..ecb15aac8fe1e0 100644
--- a/packages/edit-site/src/components/layout/style.scss
+++ b/packages/edit-site/src/components/layout/style.scss
@@ -105,7 +105,13 @@
 	left: 0;
 	bottom: 0;
 	width: 100%;
-	overflow: hidden;
+	display: flex;
+	justify-content: center;
+	align-items: center;
+
+	&:has(.edit-site-layout__resizable-frame-oversized) {
+		justify-content: flex-end;
+	}
 
 	& > div {
 		color: $gray-900;
@@ -243,5 +249,5 @@
 			z-index: 3;
 		}
 	}
-
 }
+
diff --git a/packages/edit-site/src/components/resizable-frame/index.js b/packages/edit-site/src/components/resizable-frame/index.js
new file mode 100644
index 00000000000000..f5dff65f3749b5
--- /dev/null
+++ b/packages/edit-site/src/components/resizable-frame/index.js
@@ -0,0 +1,253 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+
+/**
+ * WordPress dependencies
+ */
+import { useState, useRef, useEffect } from '@wordpress/element';
+import {
+	ResizableBox,
+	__unstableMotion as motion,
+} from '@wordpress/components';
+import { useDispatch } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { unlock } from '../../private-apis';
+import { store as editSiteStore } from '../../store';
+
+// Removes the inline styles in the drag handles.
+const HANDLE_STYLES_OVERRIDE = {
+	position: undefined,
+	userSelect: undefined,
+	cursor: undefined,
+	width: undefined,
+	height: undefined,
+	top: undefined,
+	right: undefined,
+	bottom: undefined,
+	left: undefined,
+};
+
+// The minimum width of the frame (in px) while resizing.
+const FRAME_MIN_WIDTH = 340;
+// The reference width of the frame (in px) used to calculate the aspect ratio.
+const FRAME_REFERENCE_WIDTH = 1300;
+// 9 : 19.5 is the target aspect ratio enforced (when possible) while resizing.
+const FRAME_TARGET_ASPECT_RATIO = 9 / 19.5;
+// The minimum distance (in px) between the frame resize handle and the
+// viewport's edge. If the frame is resized to be closer to the viewport's edge
+// than this distance, then "canvas mode" will be enabled.
+const SNAP_TO_EDIT_CANVAS_MODE_THRESHOLD = 200;
+
+function calculateNewHeight( width, initialAspectRatio ) {
+	const lerp = ( a, b, amount ) => {
+		return a + ( b - a ) * amount;
+	};
+
+	// Calculate the intermediate aspect ratio based on the current width.
+	const lerpFactor =
+		1 -
+		Math.max(
+			0,
+			Math.min(
+				1,
+				( width - FRAME_MIN_WIDTH ) /
+					( FRAME_REFERENCE_WIDTH - FRAME_MIN_WIDTH )
+			)
+		);
+
+	// Calculate the height based on the intermediate aspect ratio
+	// ensuring the frame arrives at the target aspect ratio.
+	const intermediateAspectRatio = lerp(
+		initialAspectRatio,
+		FRAME_TARGET_ASPECT_RATIO,
+		lerpFactor
+	);
+
+	return width / intermediateAspectRatio;
+}
+
+function ResizableFrame( {
+	isFullWidth,
+	isReady,
+	children,
+	oversizedClassName,
+} ) {
+	const [ frameSize, setFrameSize ] = useState( {
+		width: '100%',
+		height: '100%',
+	} );
+	// The width of the resizable frame when a new resize gesture starts.
+	const [ startingWidth, setStartingWidth ] = useState();
+	const [ isResizing, setIsResizing ] = useState( false );
+	const [ isHovering, setIsHovering ] = useState( false );
+	const [ isOversized, setIsOversized ] = useState( false );
+	const [ resizeRatio, setResizeRatio ] = useState( 1 );
+	const { setCanvasMode } = unlock( useDispatch( editSiteStore ) );
+	const initialAspectRatioRef = useRef( null );
+	// The width of the resizable frame on initial render.
+	const initialComputedWidthRef = useRef( null );
+	const FRAME_TRANSITION = { type: 'tween', duration: isResizing ? 0 : 0.5 };
+	const frameRef = useRef( null );
+
+	// Remember frame dimensions on initial render.
+	useEffect( () => {
+		const { offsetWidth, offsetHeight } = frameRef.current.resizable;
+		initialComputedWidthRef.current = offsetWidth;
+		initialAspectRatioRef.current = offsetWidth / offsetHeight;
+	}, [] );
+
+	const handleResizeStart = ( _event, _direction, ref ) => {
+		// Remember the starting width so we don't have to get `ref.offsetWidth` on
+		// every resize event thereafter, which will cause layout thrashing.
+		setStartingWidth( ref.offsetWidth );
+		setIsResizing( true );
+	};
+
+	// Calculate the frame size based on the window width as its resized.
+	const handleResize = ( _event, _direction, _ref, delta ) => {
+		const normalizedDelta = delta.width / resizeRatio;
+		const deltaAbs = Math.abs( normalizedDelta );
+		const maxDoubledDelta =
+			delta.width < 0 // is shrinking
+				? deltaAbs
+				: ( initialComputedWidthRef.current - startingWidth ) / 2;
+		const deltaToDouble = Math.min( deltaAbs, maxDoubledDelta );
+		const doubleSegment = deltaAbs === 0 ? 0 : deltaToDouble / deltaAbs;
+		const singleSegment = 1 - doubleSegment;
+
+		setResizeRatio( singleSegment + doubleSegment * 2 );
+
+		const updatedWidth = startingWidth + delta.width;
+
+		setIsOversized( updatedWidth > initialComputedWidthRef.current );
+
+		// Width will be controlled by the library (via `resizeRatio`),
+		// so we only need to update the height.
+		setFrameSize( {
+			height: isOversized
+				? '100%'
+				: calculateNewHeight(
+						updatedWidth,
+						initialAspectRatioRef.current
+				  ),
+		} );
+	};
+
+	const handleResizeStop = ( _event, _direction, ref ) => {
+		setIsResizing( false );
+
+		if ( ! isOversized ) {
+			return;
+		}
+
+		setIsOversized( false );
+
+		const remainingWidth =
+			ref.ownerDocument.documentElement.offsetWidth - ref.offsetWidth;
+
+		if ( remainingWidth > SNAP_TO_EDIT_CANVAS_MODE_THRESHOLD ) {
+			// Reset the initial aspect ratio if the frame is resized slightly
+			// above the sidebar but not far enough to trigger full screen.
+			setFrameSize( { width: '100%', height: '100%' } );
+		} else {
+			// Trigger full screen if the frame is resized far enough to the left.
+			setCanvasMode( 'edit' );
+		}
+	};
+
+	const frameAnimationVariants = {
+		default: {
+			flexGrow: 0,
+			height: frameSize.height,
+		},
+		fullWidth: {
+			flexGrow: 1,
+			height: frameSize.height,
+		},
+	};
+
+	return (
+		<ResizableBox
+			as={ motion.div }
+			ref={ frameRef }
+			initial={ false }
+			variants={ frameAnimationVariants }
+			animate={ isFullWidth ? 'fullWidth' : 'default' }
+			onAnimationComplete={ ( definition ) => {
+				if ( definition === 'fullWidth' )
+					setFrameSize( { width: '100%', height: '100%' } );
+			} }
+			transition={ FRAME_TRANSITION }
+			size={ frameSize }
+			enable={ {
+				top: false,
+				right: false,
+				bottom: false,
+				// Resizing will be disabled until the editor content is loaded.
+				left: isReady,
+				topRight: false,
+				bottomRight: false,
+				bottomLeft: false,
+				topLeft: false,
+			} }
+			resizeRatio={ resizeRatio }
+			handleClasses={ undefined }
+			handleStyles={ {
+				left: HANDLE_STYLES_OVERRIDE,
+				right: HANDLE_STYLES_OVERRIDE,
+			} }
+			minWidth={ FRAME_MIN_WIDTH }
+			maxWidth={ isFullWidth ? '100%' : '150%' }
+			maxHeight={ '100%' }
+			onMouseOver={ () => setIsHovering( true ) }
+			onMouseOut={ () => setIsHovering( false ) }
+			handleComponent={ {
+				left:
+					isHovering || isResizing ? (
+						<motion.div
+							key="handle"
+							className="edit-site-resizable-frame__handle"
+							title="Drag to resize"
+							initial={ {
+								opacity: 0,
+								left: 0,
+							} }
+							animate={ {
+								opacity: 1,
+								left: -15,
+							} }
+							exit={ {
+								opacity: 0,
+								left: 0,
+							} }
+							whileHover={ { scale: 1.1 } }
+						/>
+					) : null,
+			} }
+			onResizeStart={ handleResizeStart }
+			onResize={ handleResize }
+			onResizeStop={ handleResizeStop }
+			className={ classnames( 'edit-site-resizable-frame__inner', {
+				'is-resizing': isResizing,
+				[ oversizedClassName ]: isOversized,
+			} ) }
+		>
+			<motion.div
+				className="edit-site-resizable-frame__inner-content"
+				animate={ {
+					borderRadius: isFullWidth ? 0 : 8,
+				} }
+				transition={ FRAME_TRANSITION }
+			>
+				{ children }
+			</motion.div>
+		</ResizableBox>
+	);
+}
+
+export default ResizableFrame;
diff --git a/packages/edit-site/src/components/resizable-frame/style.scss b/packages/edit-site/src/components/resizable-frame/style.scss
new file mode 100644
index 00000000000000..2bd478b9bf9916
--- /dev/null
+++ b/packages/edit-site/src/components/resizable-frame/style.scss
@@ -0,0 +1,69 @@
+.edit-site-resizable-frame__inner {
+	position: relative;
+
+	&.is-resizing {
+		@at-root {
+			body:has(&) {
+				cursor: col-resize;
+				user-select: none;
+				-webkit-user-select: none;
+			}
+		}
+
+		&::before {
+			// This covers the whole content which ensures mouse up triggers
+			// even if the content is "inert".
+			position: absolute;
+			z-index: 1;
+			inset: 0;
+			content: "";
+		}
+	}
+}
+
+.edit-site-resizable-frame__inner-content {
+	position: absolute;
+	z-index: 0;
+	inset: 0;
+}
+
+.edit-site-resizable-frame__handle {
+	position: absolute;
+	width: 5px;
+	height: 50px;
+	background-color: rgba(255, 255, 255, 0.3);
+	z-index: 100;
+	border-radius: 5px;
+	cursor: col-resize;
+	display: flex;
+	align-items: center;
+	justify-content: flex-end;
+	top: 50%;
+	&::before {
+		position: absolute;
+		left: 100%;
+		height: 100%;
+		width: $grid-unit-30;
+		content: "";
+	}
+
+	&::after {
+		position: absolute;
+		right: 100%;
+		height: 100%;
+		width: $grid-unit-30;
+		content: "";
+	}
+
+	&:hover {
+		background-color: var(--wp-admin-theme-color);
+	}
+
+	.edit-site-resizable-frame__handle-label {
+		border-radius: 2px;
+		background: var(--wp-admin-theme-color);
+		padding: 4px 8px;
+		color: #fff;
+		margin-right: $grid-unit-10;
+	}
+}
diff --git a/packages/edit-site/src/style.scss b/packages/edit-site/src/style.scss
index 7a0233ebee5247..3be15cd02d2599 100644
--- a/packages/edit-site/src/style.scss
+++ b/packages/edit-site/src/style.scss
@@ -34,6 +34,7 @@
 @import "./components/site-icon/style.scss";
 @import "./components/style-book/style.scss";
 @import "./components/editor-canvas-container/style.scss";
+@import "./components/resizable-frame/style.scss";
 @import "./hooks/push-changes-to-global-styles/style.scss";
 
 html #wpadminbar {
diff --git a/test/e2e/specs/site-editor/style-book.spec.js b/test/e2e/specs/site-editor/style-book.spec.js
index 6231175584554b..b7002c6dea8f0e 100644
--- a/test/e2e/specs/site-editor/style-book.spec.js
+++ b/test/e2e/specs/site-editor/style-book.spec.js
@@ -173,7 +173,10 @@ class StyleBook {
 
 	async open() {
 		await this.disableWelcomeGuide();
-		await this.page.click( 'role=button[name="Styles"i]' );
-		await this.page.click( 'role=button[name="Style Book"i]' );
+		await this.page
+			.getByRole( 'region', { name: 'Editor top bar' } )
+			.getByRole( 'button', { name: 'Styles' } )
+			.click();
+		await this.page.getByRole( 'button', { name: 'Style Book' } ).click();
 	}
 }